成本模块

This commit is contained in:
2025-12-02 17:58:16 +08:00
parent be91905508
commit 4b9cce2777
22 changed files with 4808 additions and 3 deletions

View File

@@ -0,0 +1,568 @@
<template>
<div class="cost-search-page">
<div class="hero">
<div class="hero-content">
<h1>成本检索中心</h1>
<p>基于 wms_material_coil 实时计算输入入场钢卷号即可查看成本</p>
<div class="search-bar">
<el-input
v-model.trim="searchForm.enterCoilNo"
placeholder="请输入入场钢卷号(支持前缀)"
clearable
class="search-input"
@input="handleInputChange"
@keyup.enter.native="handleSearchClick"
/>
<el-date-picker
v-model="searchForm.calcDate"
type="date"
placeholder="可选:指定计算日期"
value-format="yyyy-MM-dd"
class="search-date"
:picker-options="datePickerOptions"
@change="handleCalcDateChange"
/>
<el-button
type="primary"
icon="el-icon-search"
:loading="loading"
@click="handleSearchClick"
>
检索成本
</el-button>
</div>
<div class="hero-meta">
<span>未发货计算到选择日期默认今日</span>
<span>已发货计算到发货前一天</span>
</div>
</div>
</div>
<div class="result-wrapper">
<div class="result-actions">
<el-button type="text" icon="el-icon-setting" @click="goToStandardConfig">成本标准配置</el-button>
<el-divider direction="vertical"></el-divider>
<el-button type="text" icon="el-icon-tickets" @click="goToDetail">查看历史明细</el-button>
</div>
<el-empty
v-if="!searchExecuted && !loading"
description="请输入入场钢卷号后开始检索"
/>
<div v-else>
<el-card v-loading="loading" shadow="never" class="summary-card">
<div class="summary-header">
<div>
<div class="summary-label">入场钢卷号</div>
<div class="summary-value">{{ summary.enterCoilNo || '-' }}</div>
</div>
<el-tag type="info" effect="plain">
统计日期{{ summary.calcDate || '-' }}
</el-tag>
</div>
<div class="summary-grid">
<div class="grid-item">
<div class="grid-label">总子钢卷数</div>
<div class="grid-value primary">{{ summary.totalCoils }}</div>
<div class="grid-desc">
<span>在库 {{ summary.unshippedCount }}</span>
<span>已发货 {{ summary.shippedCount }}</span>
</div>
</div>
<div class="grid-item">
<div class="grid-label">总毛重</div>
<div class="grid-value">{{ formatWeight(summary.totalGrossWeight) }} </div>
<div class="grid-desc">毛重优先缺失时用净重</div>
</div>
<div class="grid-item">
<div class="grid-label">总净重</div>
<div class="grid-value">{{ formatWeight(summary.totalNetWeight) }} </div>
<div class="grid-desc">实时换算为吨</div>
</div>
<div class="grid-item">
<div class="grid-label">累计成本</div>
<div class="grid-value accent">{{ formatMoney(summary.totalCost) }} </div>
<div class="grid-desc">所有子钢卷累计值</div>
</div>
<div class="grid-item">
<div class="grid-label">平均在库天数</div>
<div class="grid-value">{{ formatNumber(summary.avgStorageDays, 2) }} </div>
<div class="grid-desc">含在库与已发货</div>
</div>
</div>
</el-card>
<div v-if="cards.length" class="card-section">
<div class="card-grid">
<div class="coil-card" v-for="card in cards" :key="card.coilId">
<div class="card-header">
<div>
<div class="coil-no">{{ card.currentCoilNo }}</div>
<div class="coil-meta">入库{{ formatDate(card.startDate) }}</div>
</div>
<el-tag
size="mini"
:type="card.isShipped ? 'info' : 'success'"
effect="plain"
>
{{ card.isShipped ? '已发货' : '在库' }}
</el-tag>
</div>
<div class="card-body">
<div class="metric-row">
<span class="metric-label">毛重()</span>
<span class="metric-value">{{ formatWeight(card.grossWeightTon) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">净重()</span>
<span class="metric-value">{{ formatWeight(card.netWeightTon) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">计费基准</span>
<span class="metric-value">
<el-tag size="mini" :type="card.weightBasis === 'gross' ? 'success' : 'info'" effect="plain">
{{ card.weightBasis === 'gross' ? '毛重' : '净重' }}
</el-tag>
</span>
</div>
<div class="metric-row">
<span class="metric-label">在库天数</span>
<span :class="['metric-value', getStorageDaysClass(card.storageDays)]">
{{ card.storageDays || '-' }}
</span>
</div>
<div class="metric-row">
<span class="metric-label">单位成本</span>
<span class="metric-value">{{ formatMoney(card.unitCost) }} //</span>
</div>
<div class="metric-row">
<span class="metric-label">累计成本</span>
<span class="metric-value accent">{{ formatMoney(card.totalCost) }} </span>
</div>
<div class="metric-row">
<span class="metric-label">计费截至</span>
<span class="metric-value">{{ formatDate(card.endDate) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">所在库区</span>
<span class="metric-value">{{ card.warehouseName || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="pagination" v-if="pagination.total > pagination.pageSize">
<el-pagination
background
layout="prev, pager, next"
:page-size="pagination.pageSize"
:total="pagination.total"
:current-page.sync="pagination.pageNum"
@current-change="handlePageChange"
/>
</div>
</div>
<el-empty
v-else-if="!loading"
description="暂无匹配钢卷"
/>
</div>
</div>
</div>
</template>
<script>
import { searchMaterialCost } from '@/api/wms/cost'
export default {
name: 'CostSearchDashboard',
data() {
return {
loading: false,
searchExecuted: false,
searchForm: {
enterCoilNo: '',
calcDate: null
},
pagination: {
pageNum: 1,
pageSize: 20,
total: 0
},
summary: {
enterCoilNo: '',
calcDate: '',
totalCoils: 0,
shippedCount: 0,
unshippedCount: 0,
totalGrossWeight: 0,
totalNetWeight: 0,
totalCost: 0,
avgStorageDays: 0
},
cards: [],
debounceTimer: null,
datePickerOptions: {
disabledDate(time) {
return time.getTime() > Date.now()
}
}
}
},
beforeDestroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
},
methods: {
handleInputChange() {
this.pagination.pageNum = 1
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.fetchResults()
}, 400)
},
handleCalcDateChange() {
if (!this.searchForm.enterCoilNo) return
this.pagination.pageNum = 1
this.fetchResults()
},
handleSearchClick() {
this.pagination.pageNum = 1
this.fetchResults()
},
async fetchResults() {
if (!this.searchForm.enterCoilNo) {
this.resetResults(false)
return
}
this.loading = true
const params = {
enterCoilNo: this.searchForm.enterCoilNo.trim(),
calcDate: this.searchForm.calcDate,
pageNum: this.pagination.pageNum,
pageSize: this.pagination.pageSize
}
try {
const res = await searchMaterialCost(params)
this.searchExecuted = true
if (res.code === 200 && res.data) {
const data = res.data
const summaryData = data.summary || {}
this.summary = {
enterCoilNo: summaryData.enterCoilNo || params.enterCoilNo,
calcDate: summaryData.calcDate || params.calcDate || this.formatDate(new Date()),
totalCoils: summaryData.totalCoils || 0,
shippedCount: summaryData.shippedCount || 0,
unshippedCount: summaryData.unshippedCount || 0,
totalGrossWeight: summaryData.totalGrossWeight || 0,
totalNetWeight: summaryData.totalNetWeight || 0,
totalCost: summaryData.totalCost || 0,
avgStorageDays: summaryData.avgStorageDays || 0
}
this.cards = (data.records || []).map(item => ({
...item,
isShipped: item.isShipped === 1 || item.isShipped === true
}))
this.pagination.total = data.total || 0
this.pagination.pageNum = data.pageNum || params.pageNum
} else {
this.resetResults(true)
this.$message.warning(res.msg || '未查询到成本数据')
}
} catch (error) {
this.$message.error('检索失败,请稍后再试')
} finally {
this.loading = false
}
},
handlePageChange(page) {
this.pagination.pageNum = page
this.fetchResults()
},
resetResults(keepExecuted) {
if (!keepExecuted) {
this.searchExecuted = false
}
this.cards = []
this.pagination.total = 0
this.summary = {
enterCoilNo: this.searchForm.enterCoilNo || '',
calcDate: this.searchForm.calcDate || '',
totalCoils: 0,
shippedCount: 0,
unshippedCount: 0,
totalGrossWeight: 0,
totalNetWeight: 0,
totalCost: 0,
avgStorageDays: 0
}
},
goToStandardConfig() {
this.$router.push('/wms/cost/standard')
},
goToDetail() {
this.$router.push('/wms/cost/detail')
},
formatMoney(value) {
if (!value) return '0.00'
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
},
formatWeight(value) {
if (!value) return '0.000'
return Number(value).toFixed(3)
},
formatNumber(value, fraction = 2) {
if (!value) return Number(0).toFixed(fraction)
return Number(value).toFixed(fraction)
},
formatDate(value) {
if (!value) return '-'
if (value instanceof Date) {
const yyyy = value.getFullYear()
const mm = `${value.getMonth() + 1}`.padStart(2, '0')
const dd = `${value.getDate()}`.padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
return value
},
getStorageDaysClass(days) {
if (!days) return ''
if (days >= 60) return 'storage-days-high'
if (days >= 30) return 'storage-days-medium'
return 'storage-days-normal'
}
}
}
</script>
<style lang="scss" scoped>
.cost-search-page {
min-height: 100%;
background: #eef1f4;
padding-bottom: 40px;
}
.hero {
background: linear-gradient(120deg, #f7f8fa 0%, #e2e5ec 100%);
border-bottom: 1px solid #d5d9e0;
padding: 48px 20px 36px;
color: #2f3135;
}
.hero-content {
max-width: 900px;
margin: 0 auto;
text-align: center;
h1 {
font-size: 34px;
font-weight: 600;
margin-bottom: 8px;
letter-spacing: 1px;
}
p {
margin-bottom: 24px;
color: #5f646e;
}
}
.search-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.search-input {
width: 320px;
}
.search-date {
width: 220px;
}
}
.hero-meta {
margin-top: 14px;
font-size: 13px;
color: #757a85;
display: flex;
justify-content: center;
gap: 20px;
}
.result-wrapper {
max-width: 1100px;
margin: -25px auto 0;
padding: 0 20px 40px;
}
.result-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 14px;
::v-deep .el-button--text {
color: #5f6470;
}
}
.summary-card {
border: 1px solid #d7dbe2;
border-radius: 10px;
background: #fefefe;
margin-bottom: 20px;
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.summary-label {
font-size: 13px;
color: #80848f;
}
.summary-value {
font-size: 24px;
font-weight: 600;
color: #2f3135;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
.grid-item {
padding: 14px;
border-radius: 8px;
border: 1px solid #e2e5ea;
background: linear-gradient(180deg, #f9fafc 0%, #f0f2f5 100%);
.grid-label {
font-size: 13px;
color: #7b808a;
margin-bottom: 4px;
}
.grid-value {
font-size: 22px;
font-weight: 600;
color: #2f3135;
&.primary {
color: #435d7a;
}
&.accent {
color: #aa7728;
}
}
.grid-desc {
margin-top: 6px;
font-size: 12px;
color: #9a9fac;
display: flex;
justify-content: space-between;
}
}
}
.card-section {
border: 1px solid #d7dbe2;
border-radius: 10px;
padding: 20px;
background: #fdfdfd;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.coil-card {
border: 1px solid #d0d4dc;
border-radius: 10px;
padding: 16px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(32, 41, 58, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.coil-no {
font-size: 18px;
font-weight: 600;
color: #2e3240;
}
.coil-meta {
font-size: 12px;
color: #8a8f99;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.metric-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #5a5f6b;
.metric-value {
font-weight: 600;
color: #30343c;
}
.metric-value.accent {
color: #b4792b;
}
}
.pagination {
margin-top: 20px;
text-align: right;
}
.storage-days-normal {
color: #4c805c;
}
.storage-days-medium {
color: #b78b38;
}
.storage-days-high {
color: #b3473f;
}
</style>