Files
klp-oa/klp-ui/src/views/wms/coil/info/components/CostInfoSection.vue
砂糖 768b18c22a feat(cost+wms): 新增生产指标标签功能并优化钢卷成本展示
1. 为生产指标实体、BO、VO新增tags标签字段并完善MyBatis映射
2. 在生产指标查询中添加标签模糊筛选条件
3. 新增生产指标计算结果API接口
4. 优化成本综合页面:支持标签字段的增改查,新增指标结果批量保存逻辑
5. 移除废弃的costDataService文件,重构钢卷详情页成本展示模块,新增加工路径可视化和吨钢成本计算展示
6. 注释并禁用了原有的检验任务相关代码逻辑
2026-06-17 15:09:08 +08:00

710 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="section basic-info-section">
<div class="section-header">
<span class="section-icon">&#128176;</span>
<span class="section-title">成本信息</span>
</div>
<div class="section-body">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="囤积成本">
<span class="cost-value">{{ hoardingCost }}</span>
<span class="cost-unit">t&#183;</span>
</el-descriptions-item>
<el-descriptions-item label="囤积天数">
<span>{{ hoardingDays }} </span>
</el-descriptions-item>
<el-descriptions-item label="钢卷净重">
<span>{{ coilInfo.netWeight || 0 }} t</span>
</el-descriptions-item>
</el-descriptions>
<div v-if="!pathChecked || pathGraphData.nodes.length > 1" class="cost-chart-wrap">
<div class="cost-table-title">加工路径</div>
<div v-if="processedCostTable.length" class="ton-cost-table-wrap">
<el-table
:data="processedCostTable"
border
size="small"
:header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 600 }"
>
<el-table-column prop="actionName" label="工序" min-width="120" />
<el-table-column prop="lineName" label="产线" width="100" />
<el-table-column prop="coilNo" label="钢卷号" min-width="140" show-overflow-tooltip />
<el-table-column prop="costPerTon" label="吨钢成本(元/吨)" width="130" align="right">
<template slot-scope="s">
<span :class="s.row.costPerTon ? 'cost-ton-val' : 'cost-ton-zero'">{{ s.row.costPerTon || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="netWeight" label="净重(t)" width="100" align="right">
<template slot-scope="s">{{ s.row.netWeight }}</template>
</el-table-column>
<el-table-column prop="totalCost" label="总成本(元)" width="120" align="right">
<template slot-scope="s">
<span class="cost-total-val">{{ s.row.costPerTon ? s.row.totalCost : '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div ref="pathChart" v-loading="pathLoading || costLoading" class="path-chart" />
</div>
<div v-else-if="!pathLoading && pathChecked" class="cost-chart-wrap">
<el-empty description="暂无加工路径数据" :image-size="60" />
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { listPendingAction } from '@/api/wms/pendingAction'
import { getMaterialCoil } from '@/api/wms/coil'
import { listProdReport } from '@/api/cost/prodReport'
import { listProdMetric } from '@/api/cost/prodMetric'
import { listProdMetricResult } from '@/api/cost/prodMetricResult'
const ACTION_TO_PROD_LINE = {
11: 2, 120: 2, 201: 2, 520: 2,
501: 1, 202: 1, 521: 1
}
const PROD_LINE_NAME = { 1: '镀锌产线', 2: '酸轧产线' }
const ACTION_TYPE_MAP = {
11: '酸连轧工序',
120: '酸轧分条',
201: '酸轧合卷',
202: '镀锌合卷',
203: '脱脂合卷',
204: '拉矫平整合卷',
205: '双机架合卷',
206: '镀铬合卷',
501: '镀锌工序',
502: '脱脂工序',
504: '双机架工序',
506: '纵剪分条工序'
}
export default {
name: 'CostInfoSection',
props: {
coilInfo: { type: Object, default: () => ({}) },
traceResult: { type: Object, default: null },
hoardingDays: { type: Number, default: 0 },
hoardingCost: { type: [String, Number], default: '0.00' }
},
data() {
return {
pathLoading: false,
costLoading: false,
pathChecked: false,
chart: null,
pathGraphData: {
nodes: [],
links: []
},
processedCostTable: []
}
},
watch: {
'coilInfo.coilId': {
handler(val) {
if (val) {
this.loadProcessingPath()
}
},
immediate: true
}
},
beforeDestroy() {
if (this.chart) {
this.chart.dispose()
this.chart = null
}
window.removeEventListener('resize', this.handleResize)
},
methods: {
formatVal(val) {
return val != null ? val : '-'
},
async loadProcessingPath() {
const coilId = this.coilInfo.coilId
if (!coilId) return
this.pathLoading = true
this.pathChecked = false
try {
const lineage = await this.buildCoilLineage(coilId, new Set(), 0)
if (lineage.length <= 1) {
this.pathGraphData = { nodes: [], links: [] }
return
}
const coilMap = {}
lineage.forEach(c => { coilMap[String(c.coilId)] = c })
const allCoilIds = lineage.map(c => c.coilId).filter(Boolean)
// 用每个卷的 coilId 查询完整的 pendingAction批量并发
const actionResults = await Promise.allSettled(
allCoilIds.map(id => listPendingAction({
coilId: id,
actionStatus: 2,
pageSize: 99999
}))
)
const actionMap = {}
actionResults.forEach(r => {
if (r.status === 'fulfilled' && r.value?.rows) {
r.value.rows.forEach(a => {
if (a.actionId) {
actionMap[a.actionId] = a
}
})
}
})
if (Object.keys(actionMap).length) {
const sampleAction = Object.values(actionMap)[0]
console.log('pendingAction 字段列表:', Object.keys(sampleAction))
console.log('pendingAction 样例:', sampleAction)
}
this.pathGraphData = this.buildGraphData(lineage, coilId, Object.values(actionMap), coilMap)
await this.loadCostData()
} catch (e) {
console.error('获取加工路径失败:', e)
this.pathGraphData = { nodes: [], links: [] }
} finally {
this.pathLoading = false
this.pathChecked = true
this.$nextTick(() => {
this.initChart()
})
}
},
async buildCoilLineage(coilId, visited, depth) {
if (!coilId || visited.has(String(coilId)) || depth > 20) return []
visited.add(String(coilId))
const res = await getMaterialCoil(coilId)
const coil = res.data || {}
if (!coil.coilId) return []
const parents = []
if (coil.parentCoilId) {
const parentIds = String(coil.parentCoilId).split(',').map(id => id.trim()).filter(Boolean)
for (const pid of parentIds) {
if (!visited.has(pid)) {
const pLineage = await this.buildCoilLineage(pid, visited, depth + 1)
parents.push(...pLineage)
}
}
}
return [...parents, coil]
},
buildGraphData(lineage, currentCoilId, actions, coilMap) {
const nodes = []
const links = []
const nodeIdxMap = {}
// 建立 outputCoilIds → action 的索引,兼容多种后端字段名
const actionByOutput = {}
const actionByInput = {}
function getOutputIds(a) {
return a.processedCoilIds || a.newCoilIds || a.outputCoilIds ||
a.completedCoilIds || a.coilIds || ''
}
actions.forEach(a => {
const outIds = getOutputIds(a)
if (outIds) {
String(outIds).split(',').forEach(id => {
const tid = id.trim()
if (tid) actionByOutput[tid] = a
})
}
// 同时建立输入侧索引作为兜底
if (a.coilId) {
const key = String(a.coilId)
if (!actionByInput[key]) actionByInput[key] = a
}
})
// 计算层级
const levels = {}
lineage.forEach(coil => {
if (!coil.parentCoilId) {
levels[String(coil.coilId)] = 0
} else {
const parentIds = String(coil.parentCoilId).split(',').map(id => id.trim()).filter(Boolean)
const maxParentLevel = Math.max(...parentIds.map(pid => levels[pid] || 0))
levels[String(coil.coilId)] = maxParentLevel + 1
}
})
const levelGroups = {}
lineage.forEach(coil => {
const lv = levels[String(coil.coilId)]
if (!levelGroups[lv]) levelGroups[lv] = []
levelGroups[lv].push(coil)
})
// 构建节点
lineage.forEach(coil => {
const isRoot = !coil.parentCoilId
const isCurrent = String(coil.coilId) === String(currentCoilId)
const category = isCurrent ? 'current' : (isRoot ? 'root' : 'intermediate')
const lv = levels[String(coil.coilId)]
const siblings = levelGroups[lv] || []
const sibIdx = siblings.findIndex(c => String(c.coilId) === String(coil.coilId))
const totalInLevel = siblings.length
nodeIdxMap[String(coil.coilId)] = nodes.length
const nodeName = String(coil.currentCoilNo || coil.coilId || '')
nodes.push({
name: nodeName,
id: String(coil.coilId),
category,
level: lv,
levelIndex: sibIdx,
levelTotal: totalInLevel,
symbolSize: 65,
coilNo: coil.currentCoilNo,
coilInfo: {
enterCoilNo: coil.enterCoilNo,
currentCoilNo: coil.currentCoilNo,
supplierCoilNo: coil.supplierCoilNo,
itemName: coil.itemName,
specification: coil.specification,
material: coil.material,
netWeight: coil.netWeight,
grossWeight: coil.grossWeight,
warehouseName: coil.warehouseName,
actualWarehouseName: coil.actualWarehouseName,
manufacturer: coil.manufacturer,
zincLayer: coil.zincLayer,
qualityStatus: coil.qualityStatus,
surfaceTreatmentDesc: coil.surfaceTreatmentDesc,
createTime: coil.createTime
},
isCurrent
})
})
// 确保节点 name 唯一ECharts 以 name 标识节点,重名会合并,导致 dataIndex 错误)
const nameCount = {}
nodes.forEach(n => {
nameCount[n.name] = (nameCount[n.name] || 0) + 1
})
const nameUsed = {}
nodes.forEach(n => {
if (nameCount[n.name] > 1) {
nameUsed[n.name] = (nameUsed[n.name] || 0) + 1
n.name = `${n.name}(${nameUsed[n.name]})`
}
})
// 构建边
lineage.forEach(coil => {
if (!coil.parentCoilId) return
const childIdx = nodeIdxMap[String(coil.coilId)]
const parentIds = String(coil.parentCoilId).split(',').map(id => id.trim()).filter(Boolean)
const action = actionByOutput[String(coil.coilId)] || {}
parentIds.forEach(pid => {
const parentIdx = nodeIdxMap[pid]
if (parentIdx != null && childIdx != null) {
const edgeAction = Object.keys(action).length ? action
: (actionByInput[pid] || {})
const actionTypeVal = edgeAction.actionType != null ? edgeAction.actionType : null
const actionLabel = ACTION_TYPE_MAP[actionTypeVal] ||
(actionTypeVal != null ? `工序${actionTypeVal}` : '')
links.push({
source: parentIdx,
target: childIdx,
label: {
show: !!actionLabel,
formatter: actionLabel,
fontSize: 11,
color: '#606266'
},
actionInfo: {
actionId: edgeAction.actionId,
actionType: edgeAction.actionType,
actionTypeName: actionLabel,
processTime: edgeAction.processTime,
completeTime: edgeAction.completeTime,
createTime: edgeAction.createTime,
createBy: edgeAction.createBy,
priority: edgeAction.priority,
remark: edgeAction.remark
}
})
}
})
})
return { nodes, links }
},
async loadCostData() {
const { nodes, links } = this.pathGraphData
if (!links.length) return
this.costLoading = true
this.processedCostTable = []
const buildFirstDay = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr)
if (isNaN(d.getTime())) return null
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`
}
const buildDate = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr)
if (isNaN(d.getTime())) return null
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const cache = {}
const costQueries = links.map(async(link, idx) => {
const actionType = link.actionInfo?.actionType
if (actionType == null) return
const prodLineId = ACTION_TO_PROD_LINE[actionType]
if (!prodLineId) return
const targetNode = nodes[link.target]
const createTime = targetNode?.coilInfo?.createTime
if (!createTime) return
const firstDay = buildFirstDay(createTime)
const prodDate = buildDate(createTime)
if (!firstDay || !prodDate) return
const cacheKey = `${prodLineId}_${firstDay}`
try {
let rpMetric = cache[cacheKey]
if (rpMetric === undefined) {
const rpRes = await listProdReport({ lineType: prodLineId, reportDate: firstDay, pageNum: 1, pageSize: 10 })
const reports = rpRes?.rows || []
if (reports.length !== 1) { cache[cacheKey] = null; return }
const reportId = reports[0].reportId
const mRes = await listProdMetric({ reportId, pageNum: 1, pageSize: 9999, tags: '吨钢成本' })
const metrics = (mRes?.rows || []).filter(m => (m.tags || '').includes('吨钢成本'))
if (metrics.length !== 1) { cache[cacheKey] = null; return }
rpMetric = { reportId, metricId: metrics[0].metricId }
cache[cacheKey] = rpMetric
}
if (!rpMetric) return
const rRes = await listProdMetricResult({
reportId: rpMetric.reportId,
metricId: rpMetric.metricId,
metricDate: prodDate,
pageNum: 1, pageSize: 10
})
const results = rRes?.rows || []
const costPerTon = results.length > 0 ? parseFloat(results[0].calcValue) || 0 : 0
link.costPerTon = costPerTon
link.lineName = PROD_LINE_NAME[prodLineId] || ''
const netWeight = parseFloat(targetNode.coilInfo.netWeight) || 0
this.processedCostTable.push({
idx,
actionName: link.actionInfo?.actionTypeName || link.label?.formatter || '',
lineName: link.lineName,
coilNo: targetNode.coilNo || targetNode.name || '',
costPerTon,
netWeight,
totalCost: Math.round(costPerTon * netWeight * 100) / 100
})
} catch (e) {
console.error('查询吨钢成本失败(actionType=' + actionType + '):', e)
}
})
await Promise.allSettled(costQueries)
this.costLoading = false
},
initChart() {
if (this.chart) {
this.chart.dispose()
this.chart = null
}
const dom = this.$refs.pathChart
if (!dom) return
const { nodes, links } = this.pathGraphData
if (!nodes.length) return
const containerWidth = dom.clientWidth || 800
const maxLevel = Math.max(...nodes.map(n => n.level || 0), 0)
const totalLevels = maxLevel + 1
const levelGroups = {}
nodes.forEach(n => {
const lv = n.level || 0
if (!levelGroups[lv]) levelGroups[lv] = []
levelGroups[lv].push(n)
})
const maxNodesInLevel = Math.max(...Object.values(levelGroups).map(g => g.length), 1)
const paddingX = 100
const paddingY = 70
const LEVEL_GAP_X = totalLevels > 1
? Math.max(240, (containerWidth - paddingX * 2) / (totalLevels - 1))
: 240
const LEVEL_GAP_Y = 110
const START_X = paddingX
const START_Y = paddingY
nodes.forEach(n => {
const lv = n.level || 0
const sibIdx = n.levelIndex != null ? n.levelIndex : 0
const totalInLevel = n.levelTotal || 1
n.x = START_X + lv * LEVEL_GAP_X
n.y = START_Y + (sibIdx - (totalInLevel - 1) / 2) * LEVEL_GAP_Y
})
const chartHeight = Math.max(350, maxNodesInLevel * LEVEL_GAP_Y + paddingY * 2)
dom.style.width = containerWidth + 'px'
dom.style.height = chartHeight + 'px'
this.chart = echarts.init(dom)
const rootGradient = { type: 'radial', x: 0.5, y: 0.5, r: 0.5, colorStops: [{ offset: 0, color: '#93c5fd' }, { offset: 1, color: '#3b82f6' }] }
const intermediateGradient = { type: 'radial', x: 0.5, y: 0.5, r: 0.5, colorStops: [{ offset: 0, color: '#6ee7b7' }, { offset: 1, color: '#10b981' }] }
const currentGradient = { type: 'radial', x: 0.5, y: 0.5, r: 0.5, colorStops: [{ offset: 0, color: '#fde68a' }, { offset: 1, color: '#f59e0b' }] }
const gradientMap = {
root: rootGradient,
intermediate: intermediateGradient,
current: currentGradient
}
const option = {
tooltip: {
trigger: 'item',
confine: true,
backgroundColor: 'rgba(20,20,30,0.92)',
borderColor: '#3b82f6',
borderWidth: 1,
borderRadius: 8,
padding: [10, 14],
textStyle: { color: '#e5e7eb', fontSize: 12 },
formatter: (params) => {
if (params.dataType === 'edge') {
const ai = params.data.actionInfo || {}
const cp = params.data.costPerTon
const rows = [
`<div style="font-weight:bold;color:#fbbf24;margin-bottom:4px;font-size:13px">${ai.actionTypeName || '工序'}</div>`
]
if (cp) rows.push(`<span style="color:#34d399">吨钢成本</span>${cp} 元/吨`)
let hasInfo = false
if (ai.processTime) { rows.push(`处理时间:${ai.processTime}`); hasInfo = true }
if (ai.completeTime) { rows.push(`完成时间:${ai.completeTime}`); hasInfo = true }
if (ai.createTime) { rows.push(`创建时间:${ai.createTime}`); hasInfo = true }
if (ai.createBy) { rows.push(`创建人:${ai.createBy}`); hasInfo = true }
if (!cp && !hasInfo) return ''
return rows.join('<br/>')
}
const d = params.data
const ci = d.coilInfo || {}
const badge = d.isCurrent ? '<span style="display:inline-block;background:#f59e0b;color:#fff;padding:1px 6px;border-radius:4px;font-size:11px;margin-left:6px">当前卷</span>' : ''
const rows = [
`<div style="font-weight:bold;color:#60a5fa;margin-bottom:4px;font-size:13px">${ci.currentCoilNo || '-'}${badge}</div>`
]
if (ci.enterCoilNo) rows.push(`<span style="color:#9ca3af">入场钢卷号</span>${ci.enterCoilNo}`)
if (ci.supplierCoilNo) rows.push(`<span style="color:#9ca3af">厂家原料号</span>${ci.supplierCoilNo}`)
if (ci.itemName) rows.push(`<span style="color:#9ca3af">物料名</span>${ci.itemName}`)
if (ci.specification) rows.push(`<span style="color:#9ca3af">规格</span>${ci.specification}`)
if (ci.material) rows.push(`<span style="color:#9ca3af">材质</span>${ci.material}`)
if (ci.netWeight != null) rows.push(`<span style="color:#9ca3af">净重</span>${ci.netWeight} t`)
if (ci.manufacturer) rows.push(`<span style="color:#9ca3af">厂家</span>${ci.manufacturer}`)
if (ci.warehouseName) rows.push(`<span style="color:#9ca3af">逻辑库位</span>${ci.warehouseName}`)
if (ci.actualWarehouseName) rows.push(`<span style="color:#9ca3af">实际库区</span>${ci.actualWarehouseName}`)
if (ci.zincLayer) rows.push(`<span style="color:#9ca3af">镀层质量</span>${ci.zincLayer}`)
if (ci.qualityStatus) rows.push(`<span style="color:#9ca3af">质量状态</span>${ci.qualityStatus}`)
if (ci.surfaceTreatmentDesc) rows.push(`<span style="color:#9ca3af">表面处理</span>${ci.surfaceTreatmentDesc}`)
if (ci.createTime) rows.push(`<span style="color:#9ca3af">创建时间</span>${ci.createTime}`)
if (ci.createBy) rows.push(`<span style="color:#9ca3af">创建人</span>${ci.createBy}`)
return rows.join('<br/>')
}
},
animationDuration: 1000,
animationEasing: 'cubicOut',
animationEasingUpdate: 'quinticInOut',
series: [
{
type: 'graph',
layout: 'none',
roam: true,
draggable: false,
data: nodes.map(n => ({
name: n.name,
x: n.x,
y: n.y,
symbol: 'roundRect',
symbolSize: n.symbolSize,
category: undefined,
itemStyle: {
color: gradientMap[n.category] || '#909399',
borderColor: n.isCurrent ? '#f59e0b' : 'rgba(255,255,255,0.6)',
borderWidth: n.isCurrent ? 4 : 2,
shadowBlur: n.isCurrent ? 20 : 8,
shadowColor: n.isCurrent ? 'rgba(245,158,11,0.55)' : 'rgba(0,0,0,0.2)',
shadowOffsetY: 2
},
label: {
show: true,
formatter: (p) => {
const cn = p.data.coilNo || p.data.name
return cn && cn.length > 12 ? cn.slice(0, 11) + '...' : (cn || '-')
},
fontSize: 11,
fontWeight: n.isCurrent ? 'bold' : 'normal',
color: n.isCurrent ? '#f59e0b' : '#374151',
position: 'bottom',
distance: 8
},
emphasis: {
scale: 1.2,
itemStyle: {
shadowBlur: n.isCurrent ? 30 : 20,
shadowColor: n.isCurrent ? 'rgba(245,158,11,0.7)' : 'rgba(59,130,246,0.5)',
borderWidth: n.isCurrent ? 5 : 3
}
},
coilNo: n.coilNo,
coilInfo: n.coilInfo,
isCurrent: n.isCurrent
})),
links: links.map(l => ({
source: l.source,
target: l.target,
label: {
show: !!(l.label && l.label.formatter) || !!l.costPerTon,
formatter: (l.costPerTon != null)
? `${l.label && l.label.formatter ? l.label.formatter + ' ' : ''}${l.costPerTon}元/吨`
: (l.label && l.label.formatter ? l.label.formatter : ''),
fontSize: 11,
color: l.costPerTon != null ? '#059669' : '#606266'
},
lineStyle: {
color: l.costPerTon != null ? 'rgba(5,150,105,0.6)' : 'rgba(107,114,128,0.55)',
width: l.costPerTon != null ? 3 : 2.5,
curveness: 0.15,
cap: 'round',
opacity: 0.9
},
emphasis: {
lineStyle: {
color: '#3b82f6',
width: 4,
opacity: 1
},
label: {
fontSize: 12,
fontWeight: 'bold',
color: '#2563eb'
}
},
actionInfo: l.actionInfo,
costPerTon: l.costPerTon
})),
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [0, 12],
edgeLabel: {
show: true,
fontSize: 11,
color: '#4b5563',
position: 'middle',
backgroundColor: 'rgba(255,255,255,0.85)',
borderRadius: 4,
padding: [2, 6],
distance: 6
},
categories: [
{ name: 'root', itemStyle: { color: '#3b82f6' }},
{ name: 'intermediate', itemStyle: { color: '#10b981' }},
{ name: 'current', itemStyle: { color: '#f59e0b' }}
]
}
]
}
this.chart.setOption(option)
this.handleResize = () => {
if (this.chart && !this.chart.isDisposed()) {
this.chart.resize()
}
}
window.addEventListener('resize', this.handleResize)
}
}
}
</script>
<style scoped>
.cost-value {
font-weight: 600;
color: #f56c6c;
}
.cost-unit {
font-size: 12px;
color: #909399;
margin-left: 2px;
}
.cost-chart-wrap {
margin-top: 12px;
}
.cost-table-title {
font-size: 13px;
font-weight: 600;
color: #303133;
padding: 6px 0;
border-bottom: 1px solid #ebeef5;
margin-bottom: 8px;
}
.path-chart {
width: 100%;
min-height: 300px;
}
.ton-cost-table-wrap {
margin-bottom: 12px;
}
.cost-ton-val {
font-weight: 600;
color: #059669;
}
.cost-ton-zero {
color: #c0c4cc;
}
.cost-total-val {
font-weight: 600;
color: #f56c6c;
}
</style>