Files
klp-oa/klp-ui/src/views/wms/coil/info/components/CostInfoSection.vue
2026-07-13 10:39:34 +08:00

710 lines
24 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 { getCoilChain } 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: '脱脂工序',
503: '拉矫平整工序',
504: '双机架工序',
505: '镀铬工序',
506: '纵剪分条工序',
520: '酸轧修复工序',
521: '镀锌修复工序',
522: '脱脂修复工序',
523: '拉矫修复工序',
524: '双机架修复工序',
525: '镀铬修复工序',
600: '退火工序'
}
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 res = await getCoilChain(coilId)
const chainData = res.data || {}
const traceList = chainData.traceList || []
if (traceList.length <= 1) {
this.pathGraphData = { nodes: [], links: [] }
return
}
const allCoilIds = []
traceList.forEach(node => {
const coil = node.coil
if (coil && coil.coilId) {
allCoilIds.push(coil.coilId)
}
})
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(traceList, coilId, Object.values(actionMap))
await this.loadCostData()
} catch (e) {
console.error('获取加工路径失败:', e)
this.pathGraphData = { nodes: [], links: [] }
} finally {
this.pathLoading = false
this.pathChecked = true
this.$nextTick(() => {
this.initChart()
})
}
},
buildGraphData(traceList, currentCoilId, actions) {
const nodes = []
const links = []
const nodeIdxMap = {}
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
}
})
// 使用 traceList 自带的 depth归一化使根节点 level=0
const depths = traceList.map(n => n.depth != null ? n.depth : 0)
const minDepth = Math.min(...depths)
const levelGroups = {}
traceList.forEach(node => {
const coil = node.coil
if (!coil || !coil.coilId) return
const lv = (node.depth != null ? node.depth : 0) - minDepth
if (!levelGroups[lv]) levelGroups[lv] = []
levelGroups[lv].push(coil)
})
const levelIndexMap = {}
Object.keys(levelGroups).forEach(lv => {
levelGroups[lv].forEach((coil, idx) => {
levelIndexMap[String(coil.coilId)] = idx
})
})
traceList.forEach(node => {
const coil = node.coil
if (!coil || !coil.coilId) return
const isRoot = !node.parentCoilId
const isCurrent = String(coil.coilId) === String(currentCoilId)
const category = isCurrent ? 'current' : (isRoot ? 'root' : 'intermediate')
const lv = (node.depth != null ? node.depth : 0) - minDepth
const siblings = levelGroups[lv] || []
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: levelIndexMap[String(coil.coilId)] || 0,
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
})
})
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]})`
}
})
traceList.forEach(node => {
const coil = node.coil
if (!coil || !coil.coilId) return
const childIdx = nodeIdxMap[String(coil.coilId)]
const parentIdStr = node.parentCoilId || (coil.parentCoilId)
if (!parentIdStr) return
const parentIds = String(parentIdStr).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>