feat(cost): 新增成本综合页的异常分析功能

1. 新增异常分析弹窗组件AnomalyAnalysis,支持基于3σ法则检测异常数据
2. 在综合成本页面右上角添加异常分析入口按钮
3. 为异常单元格添加红色高亮样式,标记异常数据
4. 实现异常数据的接收、存储和表格标记逻辑
This commit is contained in:
2026-06-27 17:58:19 +08:00
parent dbea29eb23
commit 59dad19296
2 changed files with 673 additions and 4 deletions

View File

@@ -0,0 +1,640 @@
<template>
<el-dialog title="异常分析" :visible.sync="dialogVisible" width="960px" top="3vh" append-to-body :close-on-click-modal="false" @opened="onOpened" @closed="onClosed">
<div class="anomaly-top-bar">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" size="small" style="width:260px" />
<el-button type="primary" size="small" icon="el-icon-search" :loading="running" @click="startAnalysis">开始分析</el-button>
<el-button size="small" :disabled="!anomalyList.length" @click="$emit('anomaly-found', anomalyList)">应用到表格</el-button>
</div>
<!-- Progress -->
<div class="anomaly-progress" v-if="running || steps.length">
<div v-for="(s, i) in steps" :key="i" class="anomaly-step">
<i v-if="s.status==='pending'" class="el-icon-time step-icon pending"></i>
<i v-else-if="s.status==='running'" class="el-icon-loading step-icon running"></i>
<i v-else-if="s.status==='success'" class="el-icon-circle-check step-icon success"></i>
<i v-else class="el-icon-circle-close step-icon fail"></i>
<span class="step-label">{{ s.label }}</span>
<span v-if="s.msg" class="step-msg">{{ s.msg }}</span>
</div>
</div>
<!-- Chart -->
<div v-if="chartReady" style="margin-top:12px">
<div style="margin-bottom:6px;display:flex;align-items:center;gap:8px">
<span style="font-size:13px;color:#303133">吨钢指标分布3σ</span>
<el-select v-model="chartItemId" size="mini" style="width:200px" @change="updateChart">
<el-option v-for="it in analysisItems" :key="it.itemId" :label="it.itemName" :value="String(it.itemId)" />
</el-select>
</div>
<div ref="chart" style="width:100%;height:380px"></div>
</div>
<!-- Anomaly table -->
<div v-if="anomalyList.length" style="margin-top:12px">
<div style="font-size:13px;color:#303133;margin-bottom:6px">异常数据列表 {{ anomalyList.length }} </div>
<el-table :data="anomalyList" border stripe size="mini" max-height="280">
<el-table-column label="日期" prop="detailDate" width="110" />
<el-table-column label="指标" prop="itemName" min-width="120" />
<el-table-column label="吨钢值" prop="perTon" width="90" align="center" />
<el-table-column label="均值 μ" prop="mu" width="90" align="center" />
<el-table-column label="标准差 σ" prop="sigma" width="90" align="center" />
<el-table-column label="偏离(σ)" width="90" align="center">
<template slot-scope="s">
<span :style="{color: Math.abs(s.row.deviationSigma) > 3 ? '#f56c6c' : '#e6a23c'}">{{ s.row.deviationSigma }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-else-if="chartReady && !running" style="margin-top:12px;text-align:center;padding:20px;color:#67c23a">
<i class="el-icon-success" /> 未发现异常数据
</div>
<div slot="footer">
<el-button @click="dialogVisible = false"> </el-button>
</div>
</el-dialog>
</template>
<script>
import * as echarts from 'echarts'
import { listProdDetail } from '@/api/cost/prodDetail'
export default {
name: 'AnomalyAnalysis',
props: {
visible: { type: Boolean, default: false },
reportId: { type: Number, default: null },
allItems: { type: Array, default: () => [] },
gridRows: { type: Array, default: () => [] },
allCols: { type: Array, default: () => [] }
},
data() {
return {
dateRange: this.defaultDateRange(),
running: false,
steps: [],
chartReady: false,
chart: null,
chartItemId: '',
anomalyList: [],
// cached for toggling chart by item
_analysisCache: null
}
},
computed: {
dialogVisible: {
get() { return this.visible },
set(v) { this.$emit('update:visible', v) }
},
analysisItems() {
if (!this._analysisCache) return []
return this._analysisCache.items || []
}
},
mounted() {
this._resizeHandler = () => { if (this.chart) this.chart.resize() }
window.addEventListener('resize', this._resizeHandler)
},
beforeDestroy() {
window.removeEventListener('resize', this._resizeHandler)
if (this.chart) { this.chart.dispose(); this.chart = null }
},
methods: {
defaultDateRange() {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 180)
const fmt = d => d.toISOString().slice(0,10)
return [fmt(start), fmt(end)]
},
async startAnalysis() {
if (!this.allItems || !this.allItems.length) { this.$modal.msgWarning('请先加载成本项目数据'); return }
if (!this.dateRange || this.dateRange.length !== 2) { this.$modal.msgWarning('请选择日期范围'); return }
this.running = true
this.chartReady = false
this.anomalyList = []
this._analysisCache = null
this.steps = [
{ label: '正在获取参考数据集', status: 'pending', msg: '' },
{ label: '正在处理参考吨钢数据', status: 'pending', msg: '' },
{ label: '正在计算3σ基线', status: 'pending', msg: '' },
{ label: '正在比对当前表格数据', status: 'pending', msg: '' },
{ label: '正在生成分析图表', status: 'pending', msg: '' }
]
try {
// Step 1: fetch historical reference dataset
this.steps[0].status = 'running'
const res = await listProdDetail({
detailDateStart: this.dateRange[0],
detailDateEnd: this.dateRange[1],
pageSize: 99999
})
const allDetails = res.rows || []
this.steps[0].status = 'success'
this.steps[0].msg = `${allDetails.length} 条记录`
// Step 2: build per-ton from reference data
this.steps[1].status = 'running'
const refPerTon = this.buildPerTonData(allDetails)
const totalSamples = refPerTon.items.reduce((s, it) => s + it.series.filter(v => v !== null).length, 0)
this.steps[1].status = 'success'
this.steps[1].msg = `${refPerTon.items.length} 个指标,${refPerTon.dates.length} 个日期,${totalSamples} 个有效样本`
// Step 3: compute 3σ baseline from reference data (no anomaly detection yet)
this.steps[2].status = 'running'
const baseline = this.compute3SigmaBaseline(refPerTon)
this.steps[2].status = 'success'
this.steps[2].msg = `${baseline.items.length} 个指标基线已建立`
// Step 4: build per-ton from current gridRows, check against baseline
this.steps[3].status = 'running'
const gridPerTonItems = this.buildPerTonFromGrid()
const gridAnomalies = this.checkGridAgainstBaseline(gridPerTonItems, baseline)
this.anomalyList = gridAnomalies
this.steps[3].status = 'success'
this.steps[3].msg = `发现 ${gridAnomalies.length} 条异常`
// Step 5: chart (show reference distribution)
this.steps[4].status = 'running'
this._analysisCache = baseline
if (baseline.items.length && !this.chartItemId) {
this.chartItemId = String(baseline.items[0].itemId)
}
this.chartReady = true
await this.$nextTick()
await new Promise(r => setTimeout(r, 100))
this.initChart()
this.steps[4].status = 'success'
} catch (e) {
const active = this.steps.find(s => s.status === 'running')
if (active) { active.status = 'fail'; active.msg = e.message || '执行失败' }
} finally {
this.running = false
}
},
buildPerTonData(details) {
// 找出产出item分母和辅料/能耗item分子
const outputItems = this.allItems.filter(it => it.category === '产出')
const auxItems = this.allItems.filter(it => it.category === '辅料' || it.category === '能耗')
if (!outputItems.length || !auxItems.length) return { items: [], dates: [] }
// 按日期分组:{ date: { itemId_shift: quantity } }
const dateMap = {}
details.forEach(d => {
if (!d.detailDate) return
const dt = (d.detailDate || '').slice ? d.detailDate.slice(0, 10) : String(d.detailDate).slice(0, 10)
if (!dateMap[dt]) dateMap[dt] = {}
const sfx = (d.shift && d.shift !== '0') ? '_' + d.shift : ''
const key = String(d.itemId) + sfx
const qty = parseFloat(d.quantity) || 0
dateMap[dt][key] = (dateMap[dt][key] || 0) + qty
})
const dates = Object.keys(dateMap).sort()
// 计算每个日期的总产出所有产出item的quantity之和含分班次
const outputIds = new Set(outputItems.map(out => String(out.itemId)))
const outputTotals = {}
dates.forEach(dt => {
const map = dateMap[dt]
let total = 0
Object.keys(map).forEach(key => {
// Match any key that starts with an output itemId (handles shift suffixes like _1, _2)
const baseId = key.replace(/_\d+$/, '')
if (outputIds.has(baseId)) total += map[key] || 0
})
outputTotals[dt] = total
})
// 计算每个辅料/能耗 item 的吨钢值序列
const items = []
auxItems.forEach(aux => {
const auxId = String(aux.itemId)
const series = dates.map(dt => {
const map = dateMap[dt]
// Sum all quantities for this aux item (handles shift suffixes)
let auxQty = 0
Object.keys(map).forEach(key => {
const baseId = key.replace(/_\d+$/, '')
if (baseId === auxId) auxQty += map[key] || 0
})
const outQty = outputTotals[dt] || 0
if (outQty === 0) return null
return auxQty / outQty
})
if (series.every(v => v === null)) return
items.push({
itemId: aux.itemId,
itemName: aux.itemName || aux.itemCode,
itemCode: aux.itemCode,
category: aux.category,
series,
dates
})
})
return { items, dates }
},
compute3SigmaBaseline(perTonData) {
// Compute μ/σ baseline from reference data (no anomaly detection)
const { items, dates } = perTonData
const itemResults = []
items.forEach(item => {
const rawValues = item.series.filter(v => v !== null)
if (rawValues.length < 3) {
itemResults.push({ ...item, mu: 0, sigma: 0, anomalies: [] })
return
}
const computeMuSigma = (vals) => {
const n = vals.length
const mu = vals.reduce((a, b) => a + b, 0) / n
const variance = vals.reduce((s, v) => s + (v - mu) * (v - mu), 0) / n
return { mu, sigma: Math.sqrt(variance) }
}
// Iterative: remove gross outliers before establishing baseline
const p1 = computeMuSigma(rawValues)
const cleanValues = rawValues.filter(v => p1.sigma === 0 || Math.abs(v - p1.mu) <= 5 * p1.sigma)
const p2 = cleanValues.length >= 3 ? computeMuSigma(cleanValues) : p1
itemResults.push({ ...item, mu: p2.mu, sigma: p2.sigma, anomalies: [] })
})
return { items: itemResults, dates }
},
buildPerTonFromGrid() {
// Build per-ton values from current gridRows
const outputItems = this.allItems.filter(it => it.category === '产出')
const auxItems = this.allItems.filter(it => it.category === '辅料' || it.category === '能耗')
if (!outputItems.length || !auxItems.length) return []
const outputIds = new Set(outputItems.map(o => String(o.itemId)))
const items = []
auxItems.forEach(aux => {
const auxId = String(aux.itemId)
const auxCol = this.allCols.find(c => c.$type === 'detail' && String(c.itemId) === auxId)
const auxIsShift = auxCol ? auxCol.isShift : false
const points = []
this.gridRows.forEach(row => {
if (!row.detailDate) return
const dt = (row.detailDate || '').slice ? row.detailDate.slice(0, 10) : String(row.detailDate).slice(0, 10)
// Sum output quantities for this date from grid row
let outQty = 0
this.allCols.forEach(col => {
if (col.$type !== 'detail') return
const cid = String(col.itemId)
if (!outputIds.has(cid)) return
if (col.isShift) {
outQty += parseFloat(row['q' + cid + '_1']) || 0
outQty += parseFloat(row['q' + cid + '_2']) || 0
} else {
outQty += parseFloat(row['q' + cid]) || 0
}
})
// Sum aux quantity for this item
let auxQty = 0
if (auxIsShift) {
auxQty += parseFloat(row['q' + auxId + '_1']) || 0
auxQty += parseFloat(row['q' + auxId + '_2']) || 0
} else {
auxQty += parseFloat(row['q' + auxId]) || 0
}
if (outQty > 0) {
points.push({ detailDate: dt, perTon: auxQty / outQty })
}
})
if (points.length) {
items.push({
itemId: aux.itemId,
itemName: aux.itemName || aux.itemCode,
itemCode: aux.itemCode,
category: aux.category,
points
})
}
})
return items
},
checkGridAgainstBaseline(gridItems, baseline) {
// Check grid data points against reference baseline μ/σ
const anomalies = []
const baseMap = {}
baseline.items.forEach(it => {
if (it.mu != null && it.sigma > 0) baseMap[String(it.itemId)] = it
})
gridItems.forEach(gItem => {
const base = baseMap[String(gItem.itemId)]
if (!base) return
gItem.points.forEach(p => {
const diff = Math.abs(p.perTon - base.mu)
if (diff > 3 * base.sigma) {
const deviationSigma = (p.perTon - base.mu) / base.sigma
anomalies.push({
detailDate: p.detailDate,
itemId: gItem.itemId,
itemName: gItem.itemName,
itemCode: gItem.itemCode,
category: gItem.category,
perTon: Math.round(p.perTon * 1e6) / 1e6,
mu: Math.round(base.mu * 1e6) / 1e6,
sigma: Math.round(base.sigma * 1e6) / 1e6,
deviationSigma: Math.round(deviationSigma * 100) / 100
})
}
})
})
return anomalies
},
initChart() {
if (this.chart) { this.chart.dispose(); this.chart = null }
if (!this.$refs.chart) {
console.warn('AnomalyAnalysis: chart ref not found')
return
}
this.chart = echarts.init(this.$refs.chart)
this.updateChart()
},
updateChart() {
if (!this.chart || !this._analysisCache) return
const { items, dates } = this._analysisCache
const filtered = this.chartItemId
? items.filter(it => String(it.itemId) === this.chartItemId)
: items
if (!filtered.length) return
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9b59b6', '#1abc9c', '#e74c3c']
const series = []
// Collect all per-ton values for histogram
const allValues = []
filtered.forEach(item => {
item.series.forEach(v => { if (v !== null) allValues.push(v) })
})
if (!allValues.length) return
// Compute global range and histogram binning first (needed for scaling)
const globalMin = Math.min(...allValues)
const globalMax = Math.max(...allValues)
// Compute x-axis range from first item's μ/σ (used for both histogram & x-axis)
let axisMin, axisMax
const firstItem = filtered[0]
if (firstItem && firstItem.mu != null && firstItem.sigma != null && firstItem.sigma > 0) {
const margin = 3.5 * firstItem.sigma
axisMin = firstItem.mu - margin
axisMax = firstItem.mu + margin
} else {
axisMin = globalMin
axisMax = globalMax
}
// Histogram binning within the x-axis range
const binCount = Math.min(30, Math.max(10, Math.ceil(Math.sqrt(allValues.length))))
const binWidth = (axisMax - axisMin) / binCount
const bins = new Array(binCount).fill(0)
allValues.forEach(v => {
const i = Math.min(binCount - 1, Math.max(0, Math.floor((v - axisMin) / binWidth)))
bins[i]++
})
filtered.forEach((item, idx) => {
const color = colors[idx % colors.length]
const { mu, sigma } = item
if (mu == null || sigma == null || sigma <= 0) return
// Use ±4σ as curve range to show tails
const xMin = mu - 4 * sigma
const xMax = mu + 4 * sigma
const N = 300
const step = (xMax - xMin) / N
// Scale factor: convert PDF density → proportion (%) per histogram bin-width
const scale = binWidth * 100
// Generate normal curve scaled to proportion (%)
const curve = []
for (let i = 0; i <= N; i++) {
const x = xMin + i * step
const y = (1 / (sigma * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * ((x - mu) / sigma) ** 2) * scale
curve.push([x, y])
}
// Band 1: within ±1σ (center, darkest)
const band1 = curve.map(([x, y]) => ({
value: [x, (x >= mu - sigma && x <= mu + sigma) ? y : 0]
}))
series.push({
name: '±1σ (68.3%)',
type: 'line', data: band1,
lineStyle: { color: 'transparent', width: 0 },
areaStyle: { color: 'rgba(64,158,255,0.35)', origin: 'start' },
symbol: 'none', silent: true, z: 1
})
// Band 2: ±2σ excluding ±1σ
const band2 = curve.map(([x, y]) => ({
value: [x, ((x >= mu - 2*sigma && x < mu - sigma) || (x > mu + sigma && x <= mu + 2*sigma)) ? y : 0]
}))
series.push({
name: '±2σ (95.4%)',
type: 'line', data: band2,
lineStyle: { color: 'transparent', width: 0 },
areaStyle: { color: 'rgba(64,158,255,0.20)', origin: 'start' },
symbol: 'none', silent: true, z: 1
})
// Band 3: ±3σ excluding inner bands
const band3 = curve.map(([x, y]) => ({
value: [x, ((x >= mu - 3*sigma && x < mu - 2*sigma) || (x > mu + 2*sigma && x <= mu + 3*sigma)) ? y : 0]
}))
series.push({
name: '±3σ (99.7%)',
type: 'line', data: band3,
lineStyle: { color: 'transparent', width: 0 },
areaStyle: { color: 'rgba(64,158,255,0.10)', origin: 'start' },
symbol: 'none', silent: true, z: 1
})
// Bell curve outline
series.push({
name: item.itemName + ' 正态分布',
type: 'line', data: curve,
lineStyle: { color, width: 2 },
symbol: 'none', z: 5, silent: true
})
// Vertical reference lines using markLine
series.push({
name: item.itemName + ' 参考线',
type: 'scatter', data: [],
markLine: {
silent: true,
symbol: 'none',
label: { fontSize: 10, formatter: '{b}' },
lineStyle: { type: 'dashed', width: 1 },
data: [
{ name: 'μ=' + mu.toFixed(4), xAxis: mu, lineStyle: { color, width: 1.5, type: 'solid' } },
{ name: 'μ+σ', xAxis: mu + sigma, lineStyle: { color: '#67c23a' } },
{ name: 'μ-σ', xAxis: mu - sigma, lineStyle: { color: '#67c23a' } },
{ name: 'μ+2σ', xAxis: mu + 2*sigma, lineStyle: { color: '#e6a23c' } },
{ name: 'μ-2σ', xAxis: mu - 2*sigma, lineStyle: { color: '#e6a23c' } },
{ name: 'μ+3σ', xAxis: mu + 3*sigma, lineStyle: { color: '#f56c6c' } },
{ name: 'μ-3σ', xAxis: mu - 3*sigma, lineStyle: { color: '#f56c6c' } }
]
},
z: 3
})
// Anomaly markers: grid data anomalies overlaid at y=0
const itemAnomalies = (this.anomalyList || []).filter(a => String(a.itemId) === String(item.itemId))
if (itemAnomalies.length) {
series.push({
name: item.itemName + ' 异常点',
type: 'scatter',
data: itemAnomalies.map(a => ({
name: a.detailDate + ' ' + item.itemName + ': ' + a.perTon.toFixed(4),
value: [a.perTon, 0]
})),
symbolSize: 12,
itemStyle: { color: '#f56c6c', borderColor: '#fff', borderWidth: 1.5 },
z: 20
})
}
})
// Histogram: actual proportion per bin
const total = allValues.length
const histData = bins.map((count, i) => {
const x = axisMin + (i + 0.5) * binWidth
return [x, (count / total) * 100]
})
// Step-area chart for actual distribution (steps look like histogram bars)
// Extend data to create step effect at bin boundaries
const stepData = []
histData.forEach((d, i) => {
const lo = axisMin + i * binWidth
const hi = axisMin + (i + 1) * binWidth
stepData.push([lo, d[1]])
stepData.push([hi, d[1]])
})
// Close to y=0 at ends
if (stepData.length) {
stepData.unshift([stepData[0][0], 0])
stepData.push([stepData[stepData.length - 1][0], 0])
}
series.push({
name: '实际分布',
type: 'line',
data: stepData,
step: 'end',
lineStyle: { color: 'rgba(150,150,150,0.8)', width: 1 },
areaStyle: { color: 'rgba(180,180,180,0.4)', origin: 'start' },
symbol: 'none',
z: 0
})
// Capture for tooltip closure
const _binWidth = binWidth
const _axisMin = axisMin
const _binCount = binCount
this.chart.setOption({
tooltip: {
trigger: 'item',
formatter: function(p) {
if (!p.seriesName) return ''
const sn = p.seriesName
if (sn.includes('±') || sn.includes('参考线')) return ''
const v = p.value
const x = Array.isArray(v) ? v[0] : null
const y = Array.isArray(v) ? v[1] : v
if (sn.includes('异常')) {
return p.name + '<br/><b style="color:#f56c6c">⚠ 超出3σ</b>'
}
if (sn.includes('实际分布')) {
// Find which bin this boundary falls in
const rel = (x - _axisMin) / _binWidth
const idx = Math.min(_binCount - 1, Math.max(0, Math.floor(rel)))
const lo = (_axisMin + idx * _binWidth).toFixed(4)
const hi = (_axisMin + (idx + 1) * _binWidth).toFixed(4)
return '实际分布<br/>区间: ' + lo + ' ~ ' + hi +
'<br/>占比: ' + (typeof y === 'number' ? y.toFixed(2) + '%' : '')
}
if (sn.includes('正态分布')) {
return sn + '<br/>吨钢值: ' + (typeof x === 'number' ? x.toFixed(4) : '') +
'<br/>占比: ' + (typeof y === 'number' ? y.toFixed(2) + '%' : '')
}
return ''
}
},
legend: {
type: 'scroll',
bottom: 0,
textStyle: { fontSize: 10 }
},
grid: { left: '3%', right: '4%', top: '5%', bottom: '14%', containLabel: true },
xAxis: {
type: 'value',
name: '吨钢值',
min: axisMin,
max: axisMax,
axisLabel: { fontSize: 10 }
},
yAxis: {
type: 'value',
name: '占比 (%)',
axisLabel: { fontSize: 10 },
splitLine: { lineStyle: { type: 'dashed', color: '#e8e8e8' } }
},
series
}, true)
},
onOpened() {
if (this.chartReady) {
this.$nextTick(() => { if (!this.chart) this.initChart(); else { this.chart.resize(); this.updateChart() } })
}
},
onClosed() {
if (this.chart) { this.chart.dispose(); this.chart = null }
this.chartReady = false
this.anomalyList = []
this.steps = []
this._analysisCache = null
this.chartItemId = ''
}
}
}
</script>
<style scoped>
.anomaly-top-bar { display:flex; align-items:center; gap:10px; }
.anomaly-progress { margin-top:10px; display:flex; flex-wrap:wrap; gap:6px 20px; background:#f5f7fa; border-radius:3px; padding:8px 12px; border:1px solid #e4e7ed; }
.anomaly-step { display:inline-flex; align-items:center; gap:4px; font-size:13px; }
.step-icon { font-size:14px; }
.step-icon.pending { color:#c0c4cc; }
.step-icon.running { color:#409eff; }
.step-icon.success { color:#67c23a; }
.step-icon.fail { color:#f56c6c; }
.step-label { color:#606266; }
.step-msg { color:#909399; font-size:12px; margin-left:2px; }
</style>

View File

@@ -22,6 +22,7 @@
<el-button type="primary" size="mini" style="float:right;margin-left:8px" @click="saveGrid" :loading="saving">保存</el-button>
<el-button size="mini" style="float:right;margin-left:8px" @click="openColCfg">列配置</el-button>
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-money" @click="openPriceMgr">价格管理</el-button>
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-warning-outline" @click="anomalyOpen = true">异常分析</el-button>
<!-- <el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-upload2" @click="backfillCost" :loading="backfilling">反填</el-button> -->
<span style="float:right;margin-right:12px;font-size:12px;color:#606266;display:flex;align-items:center">
<span style="margin-right:4px">{{ inputMode ? '录入' : '查看' }}</span>
@@ -39,7 +40,7 @@
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
</template>
<template slot-scope="s">
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll">
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions">
<i title="反填" v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row)" />
<i title="自动获取" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row)" />
@@ -54,13 +55,13 @@
<template slot-scope="s">
<div class="shift-cell">
<span class="shift-tag"></span>
<el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll">
<el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '1')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '1')" /></span> -->
</el-input>
</div>
<div class="shift-cell">
<span class="shift-tag"></span>
<el-input v-model="s.row['q'+col.itemId+'_2']" size="mini" class="shift-input" @input="recalcAll">
<el-input v-model="s.row['q'+col.itemId+'_2']" size="mini" class="shift-input" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '2')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '2')" /></span> -->
</el-input>
</div>
@@ -317,6 +318,9 @@
</el-table>
<div slot="footer"><el-button @click="progressOpen=false"> </el-button></div>
</el-dialog>
<!-- Anomaly analysis -->
<AnomalyAnalysis :visible.sync="anomalyOpen" :reportId="activeReport ? activeReport.reportId : null" :allItems="allItems" :gridRows="gridRows" :allCols="allCols" @anomaly-found="onAnomalyFound" />
</template>
</div>
</div>
@@ -334,6 +338,7 @@ import { listAuxiliaryConsume, addAuxiliaryConsume, updateAuxiliaryConsume } fro
import { listProductionLine } from "@/api/wms/productionLine"
import { listRollGrindAll } from "@/api/mes/roll/rollGrind"
import { listEnergyRecord, addEnergyRecord, updateEnergyRecord } from "@/api/ems/energyRecord"
import AnomalyAnalysis from "./components/AnomalyAnalysis"
function parseDateRange(detailDate) {
const d = (detailDate || '').slice(0, 10)
@@ -444,6 +449,7 @@ registerBackfillHandler('能耗', async (queryCondition, row, col, report, shift
export default {
name: "CostComprehensive",
components: { AnomalyAnalysis },
data() {
return {
loading: false, list: [], tabs: [], total: 0, sel: 0, selIds: [], showSearch: true,
@@ -465,7 +471,8 @@ export default {
inputMode: false,
priceOpen: false, priceList: [], priceSaving: false,
backfilling: false,
progressOpen: false, progressTitle: '', progressTasks: []
progressOpen: false, progressTitle: '', progressTasks: [],
anomalyOpen: false, anomalyMap: {}
}
},
computed: {
@@ -1060,6 +1067,27 @@ export default {
else return row.lineType || '-'
}
},
onAnomalyFound(list) {
const map = {}
list.forEach(a => {
// Normalize date to yyyy-MM-dd string
const dt = (a.detailDate || '').slice(0, 10)
if (!dt) return
if (!map[dt]) map[dt] = new Set()
map[dt].add(String(a.itemId))
})
this.anomalyMap = map
this.anomalyOpen = false
this.$forceUpdate()
this.$modal.msgSuccess(`已将 ${list.length} 条异常数据标记到表格`)
},
isAnomalyCell(detailDate, itemId) {
if (!detailDate) return false
const dt = (detailDate || '').slice ? detailDate.slice(0, 10) : String(detailDate).slice(0, 10)
const set = this.anomalyMap[dt]
if (!set) return false
return set.has(String(itemId))
},
async loadItems() { if (!this.allItems.length) { const r = await listItem({ pageNum:1, pageSize:999 }); this.allItems = r.rows || [] } },
async loadAllMetrics(rid) {
const q = { pageNum:1, pageSize:99999 }; if (rid) q.reportId = rid
@@ -1113,4 +1141,5 @@ export default {
.ica-fetch { color: #409eff; }
.ica-backfill { color: #67c23a; margin-right: 1px; }
.ica:hover { opacity: 0.7; }
/deep/ .anomaly-input .el-input__inner { background: #fef0f0 !important; border-color: #f56c6c !important; }
</style>