feat(cost): 新增钢卷囤积成本统计页面及相关接口

新增了钢卷囤积成本统计的API接口,包括囤积统计和明细列表接口,同时新建了对应的页面页面,包含筛选查询、统计卡片、维度对比图表和明细表格功能,实现钢卷囤积成本的可视化管理。
This commit is contained in:
2026-06-05 10:45:46 +08:00
parent 5e0cb69bb8
commit d8051abf8e
2 changed files with 395 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import request from '@/utils/request'
const BASE = '/wms/materialCoil'
export function getCoilHoardingStats(data) {
return request({
url: BASE + '/hoardingStatistics',
method: 'post',
data: data
})
}
export function listCoilHoardingDetail(query) {
return request({
url: BASE + '/listWithQrcode',
method: 'get',
params: query
})
}

View File

@@ -0,0 +1,376 @@
<template>
<div class="app-container coil-cost-page">
<!-- 筛选区 -->
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="入场卷号" prop="enterCoilNo">
<el-input v-model="queryParams.enterCoilNo" placeholder="请输入入场钢卷号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="当前卷号" prop="currentCoilNo">
<el-input v-model="queryParams.currentCoilNo" placeholder="请输入当前钢卷号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="逻辑库位" prop="warehouseId">
<warehouse-select v-model="queryParams.warehouseId" placeholder="请选择逻辑库位" clearable style="width:180px" />
</el-form-item>
<el-form-item label="物料类型" prop="materialType">
<el-select v-model="queryParams.materialType" placeholder="请选择" clearable style="width:100px">
<el-option label="原料" value="原料" />
<el-option label="成品" value="成品" />
</el-select>
</el-form-item>
<el-form-item label="产品名称" prop="itemName">
<el-select v-model="queryParams.itemName" placeholder="请选择产品名称" clearable style="width:130px">
<el-option label="镀锌卷" value="镀锌卷" />
<el-option label="镀铬卷" value="镀铬卷" />
<el-option label="冷硬卷" value="冷硬卷" />
<el-option label="热轧卷板" value="热轧卷板" />
</el-select>
</el-form-item>
<el-form-item label="规格" prop="itemSpecification">
<el-input v-model="queryParams.itemSpecification" placeholder="请选择规格" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="材质" prop="itemMaterial">
<el-select v-model="queryParams.itemMaterial" placeholder="请选择材质" clearable style="width:130px">
<el-option v-for="d in dict.type.coil_material" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<el-form-item label="厂家" prop="itemManufacturer">
<el-select v-model="queryParams.itemManufacturer" placeholder="请选择厂家" clearable style="width:130px">
<el-option v-for="d in dict.type.coil_manufacturer" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<el-form-item label="品质" prop="qualityStatus">
<el-select v-model="queryParams.qualityStatus" placeholder="请选择品质" clearable style="width:120px">
<el-option v-for="d in dict.type.coil_quality_status" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<el-form-item label="发货时间">
<el-date-picker
v-model="queryParams.exportTimeRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width:240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<right-toolbar :show-search.sync="showSearch" @query-table="handleQuery" />
</el-row>
<!-- 统计卡片 -->
<div v-loading="statsLoading" class="stats-row">
<div class="stat-item stat-blue">
<span class="stat-val">{{ overview.avgHoardingDays }}</span>
<span class="stat-label">平均囤积周期()</span>
</div>
<div class="stat-item stat-orange">
<span class="stat-val">{{ formatMoney(overview.totalHoardingCost) }}</span>
<span class="stat-label">总囤积成本()</span>
</div>
<div class="stat-item stat-red">
<span class="stat-val">{{ formatMoney(overview.avgHoardingCost) }}</span>
<span class="stat-label">平均单卷成本()</span>
</div>
</div>
<!-- 维度对比图表 -->
<div class="charts-row">
<div class="chart-box chart-box-full">
<div class="chart-box-hd">产品维度对比 · 平均囤积天数 &amp; 成本</div>
<div ref="dimChart" v-loading="dimLoading" class="chart-box-bd-tall" />
</div>
</div>
<!-- 明细表格 -->
<el-table v-loading="loading" :data="detailList" border stripe size="mini" style="width:100%">
<el-table-column label="入场钢卷号" align="center" prop="enterCoilNo" show-overflow-tooltip />
<el-table-column label="当前钢卷号" align="center" prop="currentCoilNo" show-overflow-tooltip />
<el-table-column label="产品名称" align="center" prop="itemName" width="80" />
<el-table-column label="规格" align="center" prop="specification" width="100" />
<el-table-column label="材质" align="center" prop="material" width="85" />
<el-table-column label="净重(t)" align="center" prop="netWeight" width="80" />
<el-table-column label="入库时间" align="center" prop="createTime" width="150" />
<el-table-column label="发货时间" align="center" prop="exportTime" width="150">
<template slot-scope="scope">
<span>{{ scope.row.exportTime || '未发货' }}</span>
</template>
</el-table-column>
<el-table-column label="囤积天数" align="center" prop="hoardingDays" width="80" sortable />
<el-table-column label="囤积成本(元)" align="center" prop="hoardingCost" width="100" sortable />
<el-table-column label="状态" align="center" width="70">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success" size="mini">已发货</el-tag>
<el-tag v-else type="info" size="mini">在库</el-tag>
</template>
</el-table-column>
<el-table-column label="逻辑库位" align="center" prop="warehouseName" width="100" show-overflow-tooltip />
<el-table-column label="厂家" align="center" prop="manufacturer" width="75" show-overflow-tooltip />
<el-table-column label="品质" align="center" prop="qualityStatus" width="65" />
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDetailList" />
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getCoilHoardingStats, listCoilHoardingDetail } from '@/api/cost/coil'
import WarehouseSelect from '@/components/KLPService/WarehouseSelect'
const PRODUCT_NAMES = ['镀锌卷', '镀铬卷', '冷硬卷', '热轧卷板']
function parseFirstCreateTime(row) {
try {
if (row.qrcodeRecord && row.qrcodeRecord.content) {
const content = JSON.parse(row.qrcodeRecord.content)
if (content.steps && content.steps.length > 0) {
const ts = content.steps[0].create_time
if (ts) return new Date(ts)
}
}
} catch (e) { /* ignore */ }
return null
}
function computeHoarding(row) {
const firstTime = parseFirstCreateTime(row)
if (!firstTime) return { hoardingDays: '-', hoardingCost: '-' }
const endTime = row.exportTime ? new Date(row.exportTime) : new Date()
const days = Math.ceil((endTime.getTime() - firstTime.getTime()) / (24 * 3600 * 1000))
const weight = parseFloat(row.netWeight) || 0
return {
hoardingDays: days,
hoardingCost: parseFloat((days * weight).toFixed(2))
}
}
export default {
name: 'CoilHoardingCost',
components: { WarehouseSelect },
dicts: ['coil_material', 'coil_manufacturer', 'coil_quality_status'],
data() {
return {
loading: false,
statsLoading: false,
dimLoading: false,
showSearch: true,
total: 0,
detailList: [],
overview: { avgHoardingDays: 0, totalHoardingCost: 0, avgHoardingCost: 0 },
dimensionData: [],
queryParams: {
pageNum: 1,
pageSize: 10,
enterCoilNo: undefined,
currentCoilNo: undefined,
warehouseId: undefined,
materialType: undefined,
itemName: undefined,
itemSpecification: undefined,
itemMaterial: undefined,
itemManufacturer: undefined,
qualityStatus: undefined,
status: '',
exportTimeRange: undefined
},
dimChart: null
}
},
mounted() {
this.setDefaultDateRange()
this.handleQuery()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.dimChart) { this.dimChart.dispose(); this.dimChart = null }
},
methods: {
setDefaultDateRange() {
const now = new Date()
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const fmt = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
this.queryParams.exportTimeRange = [fmt(first), fmt(now)]
},
handleQuery() {
this.queryParams.pageNum = 1
this.fetchOverview()
this.fetchDimension()
this.getDetailList()
},
resetQuery() {
this.resetForm('queryForm')
this.setDefaultDateRange()
this.queryParams.status = ''
this.handleQuery()
},
buildQuery(overrides) {
const p = { ...this.queryParams, dataType: 1 }
if (p.exportTimeRange && p.exportTimeRange.length === 2) {
p.byExportTimeStart = p.exportTimeRange[0] + ' 00:00:00'
p.byExportTimeEnd = p.exportTimeRange[1] + ' 23:59:59'
}
delete p.exportTimeRange
delete p.pageNum
delete p.pageSize
if (overrides) Object.assign(p, overrides)
return p
},
fetchOverview() {
this.statsLoading = true
getCoilHoardingStats(this.buildQuery()).then(res => {
const d = res.data || {}
const shipped = parseInt(d.totalCount) || 0
this.overview = {
avgHoardingDays: parseFloat(d.avgHoardingDays) || 0,
totalHoardingCost: parseFloat((parseFloat(d.avgHoardingCost) * shipped).toFixed(2)) || 0,
avgHoardingCost: parseFloat(d.avgHoardingCost) || 0
}
}).finally(() => { this.statsLoading = false })
},
fetchDimension() {
this.dimLoading = true
const base = this.buildQuery()
const promises = PRODUCT_NAMES.map(v => {
const body = { ...base, itemName: v }
return getCoilHoardingStats(body).then(res => ({
label: v,
avgDays: parseFloat((res.data && res.data.avgHoardingDays) || 0),
avgCost: parseFloat((res.data && res.data.avgHoardingCost) || 0),
count: parseInt((res.data && res.data.totalCount) || 0)
}))
})
Promise.all(promises).then(data => {
this.dimensionData = data.filter(d => d.count > 0)
this.$nextTick(() => this.updateDimChart())
}).finally(() => { this.dimLoading = false })
},
getDetailList() {
this.loading = true
const params = { ...this.queryParams, dataType: 1 }
if (params.exportTimeRange && params.exportTimeRange.length === 2) {
params.byExportTimeStart = params.exportTimeRange[0] + ' 00:00:00'
params.byExportTimeEnd = params.exportTimeRange[1] + ' 23:59:59'
}
delete params.exportTimeRange
listCoilHoardingDetail(params).then(res => {
const rows = (res.rows || []).map(row => {
const h = computeHoarding(row)
return { ...row, hoardingDays: h.hoardingDays, hoardingCost: h.hoardingCost }
})
this.detailList = rows
this.total = res.total || 0
this.loading = false
this.fetchOverview()
}).catch(() => { this.loading = false })
},
formatMoney(val) {
if (val == null || val === '' || isNaN(val)) return '0'
const num = Number(val)
if (num >= 10000) return (num / 10000).toFixed(2) + '万'
return num.toFixed(2)
},
handleResize() {
if (this.dimChart) this.dimChart.resize()
},
initDimChart() {
if (!this.$refs.dimChart) return
if (this.dimChart) this.dimChart.dispose()
this.dimChart = echarts.init(this.$refs.dimChart)
this.updateDimChart()
},
updateDimChart() {
if (!this.dimChart) { this.initDimChart(); return }
const data = this.dimensionData || []
this.dimChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }},
legend: { data: ['平均囤积天数', '平均囤积成本'], top: 0, textStyle: { fontSize: 11 }},
grid: { left: '3%', right: '5%', bottom: '3%', top: '12%', containLabel: true },
xAxis: { type: 'category', data: data.map(d => d.label), axisLabel: { fontSize: 11 }},
yAxis: [
{ type: 'value', name: '天', axisLabel: { fontSize: 10 }},
{ type: 'value', name: '元', axisLabel: { fontSize: 10 }}
],
series: [
{ name: '平均囤积天数', type: 'bar', data: data.map(d => d.avgDays),
itemStyle: { color: '#409eff' }, label: { show: true, position: 'top', fontSize: 10 }, barMaxWidth: 40 },
{ name: '平均囤积成本', type: 'bar', yAxisIndex: 1,
data: data.map(d => parseFloat((d.avgCost || 0).toFixed(2))),
itemStyle: { color: '#e6a23c' }, label: { show: true, position: 'top', fontSize: 10 }, barMaxWidth: 40 }
]
}, true)
}
}
}
</script>
<style scoped>
.mb8 { margin-bottom: 8px }
.stats-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.stat-item {
flex: 1;
min-width: 100px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 2px;
padding: 10px 12px;
text-align: center;
}
.stat-item .stat-val {
display: block;
font-size: 22px;
font-weight: 600;
color: #303133;
line-height: 1.2;
}
.stat-item .stat-label {
display: block;
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.stat-blue .stat-val { color: #409eff }
.stat-orange .stat-val { color: #e6a23c }
.stat-red .stat-val { color: #f56c6c }
.charts-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.chart-box {
flex: 1;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 2px;
min-width: 0;
}
.chart-box-full {
flex: none;
width: 100%;
}
.chart-box-hd {
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
color: #303133;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
}
.chart-box-bd-tall {
height: 300px;
padding: 4px;
}
</style>