|
|
@@ -19,48 +19,331 @@
|
|
|
<span class="idx">{{ item.code }}</span><span class="kpi-go">查看明细 ›</span>
|
|
|
<div class="lb">{{ item.label }} <span class="q" :title="item.info">?</span></div>
|
|
|
<div class="val" :style="{ color: item.color }">{{ displayKpi(item) }}<small v-if="item.value != null">{{ item.unit }}</small></div>
|
|
|
- <div class="delta dim" :title="item.deltaTitle || item.reason">{{ item.delta || item.status || item.reason || '数据待核实' }}</div>
|
|
|
+ <div :class="'delta ' + (item.deltaTone || 'dim')" :title="item.deltaTitle || item.reason">{{ item.delta || item.status || item.reason || '数据待核实' }}</div>
|
|
|
<div v-if="item.subtotal" class="delta dim" :title="item.subtotal.title">{{ item.subtotal.text }}</div>
|
|
|
<div v-if="item.locked" class="delta dim" :title="item.locked.title">{{ item.locked.text }}</div>
|
|
|
<div v-if="item.progress != null" class="bar-mini"><i :style="{ width: item.progress + '%', background: item.color, color: item.color }"></i></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="grid-2-1">
|
|
|
- <section class="card corner"><div class="card-hd"><div><h3>出入库动态</h3><div class="sub">近 30 天滚动 · 净趋势=当日入库−当日出库</div></div><a class="link" href="#" @click.prevent="$emit('open', 'io')">查看动态 ›</a></div><div class="chart-body"><div class="legend"><span><i style="background:#38C8F5"></i>入库量</span><span><i style="background:#F0A03A"></i>出库量</span><span><i style="background:#9B7DF7;height:2px"></i>净趋势</span></div><dashboard-chart v-if="trend.length" kind="trend" :rows="trend" :unit="unit" @select="trendSelect" /><div v-else class="empty-chart">{{ trendReason || '暂无已完成出入库流水' }}</div></div></section>
|
|
|
+ <section class="card corner">
|
|
|
+ <div class="card-hd">
|
|
|
+ <div>
|
|
|
+ <h3>出入库动态</h3>
|
|
|
+ <!-- 窗口口径必须显式写「近 30 天滚动窗口」并给出起止日期,避免被当成自然月或固定周期。 -->
|
|
|
+ <div class="sub">近 30 天滚动窗口(含今日 {{ trendWindow.start }} ~ {{ trendWindow.end }})· 净趋势=当日入库−当日出库 · 右轴单日净差</div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;align-items:center;gap:8px">
|
|
|
+ <!-- 连续同号风险:连续为正=积压风险(橙)、连续为负=缺货风险(红);不足 2 天不渲染。 -->
|
|
|
+ <span v-if="trendRiskHint" class="tag" :class="trendRiskHint.tone === 'danger' ? 'r' : 'o'" :title="trendRiskHint.title">{{ trendRiskHint.text }}</span>
|
|
|
+ <a class="link" href="#" @click.prevent="$emit('open', 'io')">查看动态 ›</a>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-body">
|
|
|
+ <div class="legend">
|
|
|
+ <span><i style="background:#38C8F5"></i>入库量</span>
|
|
|
+ <span><i style="background:#F0A03A"></i>出库量</span>
|
|
|
+ <span><i style="background:#9B7DF7;height:2px"></i>净趋势(单日净差,右轴)</span>
|
|
|
+ <span><i style="background:rgba(159,184,212,.35)"></i>该日无作业</span>
|
|
|
+ </div>
|
|
|
+ <dashboard-chart v-if="trend.length" kind="trend" :rows="trend" :unit="unit" @select="trendSelect" />
|
|
|
+ <div v-else class="empty-chart">{{ trendReason || '暂无已完成出入库流水' }}</div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
<section class="card corner"><div class="card-hd"><div><h3>库存分类分布</h3><div class="sub">按物料分类 TOP8(前 7 + 其他兜底)· 按基本单位</div></div><a class="link" href="#" @click.prevent="$emit('open', 'stockLedger')">查看台账 ›</a></div><div class="chart-body"><dashboard-chart v-if="categories.length" kind="category" :rows="categories" :unit="unit" @select="categorySelect" /><div v-else class="empty-chart">{{ categoryReason || '当前筛选无可追溯库存' }}</div></div></section>
|
|
|
</div>
|
|
|
<div class="grid-3">
|
|
|
- <section class="card corner"><div class="card-hd"><div><h3>库龄结构</h3><div class="sub">在库批次按库龄分段 · 与呆滞口径联动 · 平均库龄 {{ summary.averageAgeDays == null ? '—' : summary.averageAgeDays + '天' }}</div></div><a class="link" href="#" @click.prevent="$emit('open', 'age')">明细 ›</a></div><div class="chart-body"><dashboard-chart v-if="aging.length" kind="aging" :rows="aging" :unit="unit" @select="ageSelect" /><div v-else class="empty-panel">{{ agingReason || '日期待核实,暂无完整库龄结构' }}</div></div></section>
|
|
|
- <section class="card corner"><div class="card-hd"><div><h3>安全库存预警</h3><div class="sub">低于安全库存 / 超储 · 准实时(≤5 分钟)</div></div><a class="link" href="#" @click.prevent="$emit('open', 'alerts')">全部 {{ summary.alertCount == null ? '—' : summary.alertCount }} 项 ›</a></div><div v-if="alerts.length"><div v-for="item in alerts" :key="item.id" class="alert-item" role="button" tabindex="0" @click="$emit('open', 'alerts', { categoryId: item.categoryId, warehouseId: item.warehouseId })" @keydown.enter="$emit('open', 'alerts', { categoryId: item.categoryId, warehouseId: item.warehouseId })"><span class="dot" :style="{ background: item.color || '#F0A03A', color: item.color || '#F0A03A' }"></span><b>{{ item.name }}</b><span class="meta">{{ item.message }}</span></div></div><div v-else class="empty-panel">{{ alertReason || '暂无可核对的预警数据' }}</div></section>
|
|
|
- <section class="card corner"><div class="card-hd"><div><h3>最近作业动态</h3><div class="sub">出入库 / 调拨 / 盘点流水</div></div><a class="link" href="#" @click.prevent="$emit('open', 'io')">全部动态 ›</a></div><div v-if="activities.length" class="feed"><div v-for="item in activities" :key="item.id" class="feed-item" role="button" tabindex="0" @click="$emit('business', 'io', item)" @keydown.enter="$emit('business', 'io', item)"><span class="tm" :title="item.time">{{ activityTime(item.time) }}</span><span><b>{{ item.title }}</b> · {{ item.description }}</span><span class="feed-status" :class="{ active: item.activityType === 'inventory' && Number(item.status) === 1, waiting: item.activityType === 'inventory' && Number(item.status) === 0 }">{{ item.activityType === 'inventory' ? Number(item.status) === 2 ? '已完成' : Number(item.status) === 1 ? '进行中' : '待处理' : '已完成' }}</span></div></div><div v-else class="empty-panel">{{ activityReason || '暂无可核对的作业动态' }}</div></section>
|
|
|
+ <section class="card corner">
|
|
|
+ <div class="card-hd">
|
|
|
+ <div>
|
|
|
+ <h3>库龄结构</h3>
|
|
|
+ <div class="sub">
|
|
|
+ 在库批次按库龄分段 · 与呆滞口径联动 · 平均库龄 {{ averageAge.text }}
|
|
|
+ <!-- 较上月角标:averageAgeChange 是天数差,负数=平均库龄下降=库存更“新”,向好走绿;
|
|
|
+ 正数=库龄变长,恶化走红;null=无基准,只渲染后端原因,绝不显示成 0 天。 -->
|
|
|
+ <span v-if="averageAgeChange.text" class="tag aging-delta" :class="averageAgeChange.tone === 'good' ? 'g' : averageAgeChange.tone === 'bad' ? 'r' : 'gr'" :title="averageAgeChange.title">{{ averageAgeChange.text }}</span>
|
|
|
+ <span v-else class="aging-delta-note" :title="averageAgeChange.title">{{ averageAgeChange.title }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <a class="link" href="#" @click.prevent="$emit('open', 'age')">明细 ›</a>
|
|
|
+ </div>
|
|
|
+ <div class="chart-body">
|
|
|
+ <dashboard-chart v-if="aging.length" kind="aging" :rows="aging" :unit="unit" @select="ageSelect" />
|
|
|
+ <div v-else class="empty-panel">{{ agingReason || '日期待核实,暂无完整库龄结构' }}</div>
|
|
|
+ <!-- 四段占比:只渲染后端 ratio(前端不复算,合计 100% 由后端保证);>180 天用红色,
|
|
|
+ unknown 单独成项并注明「不参与四段占比」,绝不混进四段的百分比与色阶。 -->
|
|
|
+ <div v-if="agingRatio.length || agingUnknown" class="legend aging-ratio">
|
|
|
+ <span v-for="item in agingRatio" :key="item.key" :class="{ 'aging-hot': item.stagnant }" :title="item.label + ':' + item.ratioText">
|
|
|
+ <i :style="{ background: item.color }"></i>{{ item.label }} {{ item.ratioText }}
|
|
|
+ </span>
|
|
|
+ <span v-if="agingUnknown" class="aging-unknown" :title="'日期待核实 ' + (agingUnknown.quantity == null ? '—' : agingUnknown.quantity) + ',不计入四段占比合计'">
|
|
|
+ <i :style="{ background: agingUnknown.color }"></i>{{ agingUnknown.label }} {{ agingUnknown.ratioText }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <!-- 含 unknown 段或不可出图时后端给 agingReason,必须展示:否则「日期待核实」会被读成 0 库存。 -->
|
|
|
+ <div v-if="agingReason" class="aging-note">{{ agingReason }}</div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section class="card corner">
|
|
|
+ <div class="card-hd">
|
|
|
+ <div>
|
|
|
+ <h3>安全库存预警</h3>
|
|
|
+ <!-- 三色口径写在卡面上:缺料=可用量<安全库存 / 偏低=安全库存≤可用量<安全库存×偏低系数(默认 1.2)/
|
|
|
+ 超储=可用量>最大库存(最大库存为空时按 安全库存×超储兜底系数,默认 3)。判定与系数都由后端下发,前端只展示。 -->
|
|
|
+ <div class="sub">缺料 / 偏低 / 超储 三色分级 · 缺口金额倒序 TOP{{ alertTopLimit }} · 准实时(≤5 分钟)</div>
|
|
|
+ </div>
|
|
|
+ <a class="link" href="#" @click.prevent="openAlertAll()">全部 {{ alertSummary.countText }} 项 ›</a>
|
|
|
+ </div>
|
|
|
+ <div v-if="alerts.length">
|
|
|
+ <!-- 行内:状态点(配色取后端 color)+ 物料名 + 状态中文标签 + 缺口量/缺口金额(缺价显示「金额待补价」,绝不显示 0)。
|
|
|
+ 行尾补「已生成申请 单号」标记,用于识别已经下过采购申请的行(禁用再次发起补货的视觉态由接口动作层负责)。 -->
|
|
|
+ <div v-for="item in alerts" :key="item.id" class="alert-item" role="button" tabindex="0" :title="alertRowTitle(item)" @click="alertSelect(item)" @keydown.enter="alertSelect(item)">
|
|
|
+ <span class="dot" :style="{ background: item.color, color: item.color }"></span>
|
|
|
+ <b>{{ item.name }}</b>
|
|
|
+ <span class="alert-state" :style="{ color: item.color }">{{ item.statusLabel }}</span>
|
|
|
+ <span class="alert-gap">{{ item.gapText }}</span>
|
|
|
+ <span v-if="item.workOrderCount" class="alert-wo">影响 {{ item.workOrderCount }} 单</span>
|
|
|
+ <span v-if="item.purchaseRequestCode" class="alert-requested" :title="'已生成采购申请(t_wms_dashboard_command,command_type=PURCHASE):' + item.purchaseRequestCode">已生成申请 {{ item.purchaseRequestCode }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-panel">{{ alertReason || '暂无可核对的预警数据' }}</div>
|
|
|
+ <!-- 汇总条:工单数可下钻到缺料清单;alertWorkOrderCount 为 null 时显示「—」并给原因,绝不当 0;
|
|
|
+ 待核实行(阈值无效或可用量为负)单列说明且不计入上方 N。 -->
|
|
|
+ <div class="alert-summary">
|
|
|
+ <span class="alert-sum-item" role="button" tabindex="0" :title="alertSummary.workOrderTitle" @click="openAlertWorkOrders()" @keydown.enter="openAlertWorkOrders()">影响生产工单 <b>{{ alertSummary.workOrderText }}</b> 单</span>
|
|
|
+ <span class="alert-sum-item" :title="alertSummary.gapAmountTitle">缺口金额合计 <b>{{ alertSummary.gapAmountText }}</b></span>
|
|
|
+ <span v-if="alertSummary.unknownCount > 0" class="alert-unknown-note" :title="alertSummary.unknownTitle">待核实 {{ alertSummary.unknownText }} 项(不计入 {{ alertSummary.countText }} 项)</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <!-- E3 最近作业动态:四段结构 —— ①时间(今日 HH:mm / 昨日 / 更早 MM-DD)
|
|
|
+ ②单据类型 + 单号(可点击下钻)③动作描述(按类型套模板:数量 + 库区 [+ 进度])
|
|
|
+ ④状态(done 绿 / waiting 橙 / doing 青 / danger 红 / neutral 灰);
|
|
|
+ 橙色「待*」超过 24h 追加红色「超时」角标(与待办条同口径)。
|
|
|
+ 数据侧:同单号只留最新一条 + 事件时间倒序 + 默认 5 条。交互:整行可点击/可键盘 Enter,
|
|
|
+ 单号段单独可点(@click.stop,避免冒泡到整行触发两次下钻)。新增事件只加 is-new 类做 3 秒渐隐,
|
|
|
+ 不移动 DOM、不调用任何滚动 API。 -->
|
|
|
+ <section class="card corner">
|
|
|
+ <div class="card-hd">
|
|
|
+ <div><h3>最近作业动态</h3><div class="sub">出入库 / 调拨 / 盘点流水</div></div>
|
|
|
+ <a class="link" href="#" @click.prevent="$emit('open', 'io')">全部动态 ›</a>
|
|
|
+ </div>
|
|
|
+ <div v-if="activities.length" class="feed">
|
|
|
+ <div
|
|
|
+ v-for="item in activities"
|
|
|
+ :key="item.id"
|
|
|
+ class="feed-item"
|
|
|
+ :class="{ 'is-new': newActivityIds.indexOf(item.id) !== -1 }"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="openActivity(item)"
|
|
|
+ @keydown.enter="openActivity(item)"
|
|
|
+ >
|
|
|
+ <span class="tm" :title="item.timeTitle">{{ item.timeText }}</span>
|
|
|
+ <span class="feed-doc">
|
|
|
+ {{ item.docType }}
|
|
|
+ <a
|
|
|
+ class="feed-no"
|
|
|
+ href="#"
|
|
|
+ :title="'打开' + item.docType + ' ' + (item.orderNo || '')"
|
|
|
+ @click.prevent.stop="openActivity(item)"
|
|
|
+ @keydown.enter.stop="openActivity(item)"
|
|
|
+ >{{ item.orderNo || '单号待核实' }}</a>
|
|
|
+ </span>
|
|
|
+ <span class="feed-desc" :title="item.actionText">{{ item.actionText }}</span>
|
|
|
+ <span :class="'feed-status ' + item.tone">{{ item.progressText ? item.statusText + ' ' + item.progressText : item.statusText }}<span v-if="item.overdue" class="badge-overdue">超时</span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-panel">今日暂无作业动态<span v-if="activityReason"> · {{ activityReason }}</span></div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
- <details class="data-note"><summary>统计口径与数据覆盖</summary>估值覆盖率 {{ display(summary.valuationCoverage) }}% · 库龄来源覆盖率 {{ display(summary.ageCoverage) }}% · {{ summary.comparisonReason || '日末采集历史待核实' }}。批次估值按可核实入库价计算。</details>
|
|
|
+ <details class="data-note"><summary>统计口径与数据覆盖</summary>库存金额=Σ(在库数量×出库成本单价),按采购收货不含税单价还原移动加权平均成本,全部可追溯批次都有成本价才给总额(历史主库存不可归属时注明未计入),缺价时只给覆盖率和已计价部分;估值覆盖率 {{ display(summary.valuationCoverage) }}%<span v-if="summary.inventoryValue == null && summary.pricedValuation != null">(已计价部分 {{ display(summary.pricedValuation) }} 元)</span> · 库龄来源覆盖率 {{ display(summary.ageCoverage) }}% · {{ summary.comparisonReason || '日末采集历史待核实' }}。明细页「批次估值」仍按入库价估算,与首页库存金额口径不同。</details>
|
|
|
</div>
|
|
|
</template>
|
|
|
<script>
|
|
|
import DashboardChart from './DashboardChart.vue';
|
|
|
-import { displayNumber, formatSignedNumber, formatComparisonBase, formatUnitSubtotals, homeLog } from '../dashboard-utils';
|
|
|
+import { displayNumber, formatSignedNumber, formatComparisonBase, formatPpChange, formatUnitSubtotals, buildTrendWindow, trendWindowDates, trendStreak, trendRisk, buildAgingSegments, formatAverageAgeDays, STAGNANT_DEFAULT_DAYS, ALERT_TOP_LIMIT, buildAlertRows, buildAlertSummary, alertGapAmountText, alertGapAmountReason, formatAlertQuantity, logAlertDrill, homeLog, homeWarn, buildActivityFeed, diffActivityIds } from '../dashboard-utils';
|
|
|
+
|
|
|
+// 周转状态 → 配色与展示文案:目标/预警天数由后端按仓库配置判定状态,前端只做映射,不再内置阈值。
|
|
|
+// 未识别的状态或 null 一律回落中性色 #9FB8D4 且状态文案留空,避免把「算不出来」误报成健康。
|
|
|
+const TURNOVER_STATUS_STYLE = {
|
|
|
+ '达标': { color: '#3BD48A', text: '达标' },
|
|
|
+ '超目标': { color: '#F0A03A', text: '超目标' },
|
|
|
+ '偏慢': { color: '#F25E5E', text: '周转偏慢' }
|
|
|
+};
|
|
|
+// 后端未给状态(字段缺失或不可计算)时的取值色:中性蓝,不表达健康度。
|
|
|
+const TURNOVER_NEUTRAL_COLOR = '#9FB8D4';
|
|
|
+
|
|
|
+// 后端数值字段容错解析(周转、呆滞两组字段共用):null/空串/非数字一律返回 null 表示「后端没给值」,
|
|
|
+// 避免 Number(null)===0 把「没有值」显示成 0。
|
|
|
+const parseBackendNumber = (value) => {
|
|
|
+ if (value == null || value === '') return null;
|
|
|
+ const number = Number(value);
|
|
|
+ return Number.isFinite(number) ? number : null;
|
|
|
+};
|
|
|
+// 日末快照日期只接受 yyyy-MM-dd;缺失或格式不符返回空串,调用方据此省略「(基准 …)」并退回默认措辞。
|
|
|
+const parseBackendDate = (value) => {
|
|
|
+ const text = value == null ? '' : String(value).trim();
|
|
|
+ return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
|
|
+};
|
|
|
+
|
|
|
+// 呆滞分级 → 配色与展示文案:后端按 t_wms_warehouse.stagnant_days(默认 180 天)判定库龄阈值,
|
|
|
+// 再按「<8% normal / >=8% 且 <10% 或环比 >= +0.5pp 且 <10% near / >=10% alert(红优先)」定级,
|
|
|
+// 前端只做映射、不再内置 8%/10% 阈值。移到后端的理由:分级还依赖上月最后一个完整日结快照的环比,
|
|
|
+// 前端既取不到该快照,也不该复制一份阈值——仓库改阈值或走多仓汇总后,前端自算必然与口径分叉。
|
|
|
+const STAGNANT_LEVEL_STYLE = {
|
|
|
+ normal: { color: '#3BD48A', text: '正常' },
|
|
|
+ near: { color: '#F0A03A', text: '接近阈值' },
|
|
|
+ alert: { color: '#F25E5E', text: '呆滞预警' }
|
|
|
+};
|
|
|
+// 后端未给分级(字段缺失、分母不可核实或阈值未配置)时的取值色:中性蓝,不表达健康度。
|
|
|
+const STAGNANT_NEUTRAL_COLOR = '#9FB8D4';
|
|
|
+// 天数展示:最多 2 位小数且不补零(整数天不加小数位);null 显示「—」,绝不显示 0。
|
|
|
+const formatTurnoverDays = (value) => (value == null ? '—' : Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 }));
|
|
|
+
|
|
|
+// 质检通过率分级 → 配色与状态文案:阈值来自业务确认(≥98 绿 / 95~98 橙 / <95 红),
|
|
|
+// 与后端 qualityPassRate 同一口径。本次只是把原先内联在 kpis() 里的三元表达式抽成常量并补注释,
|
|
|
+// 判定边界(98、95 含等于)与三条文案逐位不变,不引入新的判定依据。
|
|
|
+const QUALITY_LEVEL_STYLE = {
|
|
|
+ pass: { color: '#3BD48A', text: '达标' },
|
|
|
+ near: { color: '#F0A03A', text: '接近阈值' },
|
|
|
+ fail: { color: '#F25E5E', text: '低于目标' }
|
|
|
+};
|
|
|
+// 后端没给通过率(qualityPassRate 为 null,例如 qualityInspectedCount 为 0)时的取值色:
|
|
|
+// 中性蓝不表达健康度,避免把「本月没有已检批次」误报成不达标。
|
|
|
+const QUALITY_NEUTRAL_COLOR = '#9FB8D4';
|
|
|
+// 通过率 → 分级:阈值来自业务确认(≥98 达标绿 / 95~98 接近阈值橙 / <95 低于目标红)。
|
|
|
+// 边界值按 >= 归档:98 归绿、95 归橙,与改动前的内联三元逐位一致;null(或非数字)返回 null,
|
|
|
+// 调用方据此走中性色且状态留空,绝不把「算不出来」显示成健康或不健康。
|
|
|
+const resolveQualityStyle = (rate) => {
|
|
|
+ if (rate == null) return null;
|
|
|
+ if (rate >= 98) return QUALITY_LEVEL_STYLE.pass;
|
|
|
+ return rate >= 95 ? QUALITY_LEVEL_STYLE.near : QUALITY_LEVEL_STYLE.fail;
|
|
|
+};
|
|
|
+
|
|
|
+// 待办条日志去重:todoCards 是计算属性,父组件重渲染、筛选联动都会让它反复求值,
|
|
|
+// 每次求值都 homeLog 会把控制台刷满、反而看不见真实变化。这里用模块级快照记住上一次打印的
|
|
|
+// 4 个计数与 2 个逾期数,只有任一数值变化时才打一条;数值未变的重算一律静默。
|
|
|
+let todoCardsLoggedSnapshot = null;
|
|
|
+const todoCardsSnapshot = (cards) => cards
|
|
|
+ .map((card) => card.key + '=' + (card.value == null ? '' : card.value) + '/' + (card.overdue == null ? '' : card.overdue))
|
|
|
+ .join('|');
|
|
|
+
|
|
|
+// D1 出入库动态窗口日志去重:窗口的起止日、来源行数、无作业天数任一变化才打一条,
|
|
|
+// 只记关键字段与构建耗时,绝不打印后端整包响应。
|
|
|
+let trendWindowLoggedSnapshot = null;
|
|
|
+
|
|
|
+// E1 库龄结构日志去重:segments 是计算属性,父组件重渲染/筛选联动都会反复求值,
|
|
|
+// 每次都打会把控制台刷满。只有分段数值、行数、平均库龄或 unknown 出现情况变化时才打一条汇总。
|
|
|
+let agingLoggedSnapshot = null;
|
|
|
+const logTrendWindow = (window, sourceRows, costMs) => {
|
|
|
+ const idleDays = window.filter((row) => row.idle).length;
|
|
|
+ const start = window.length ? window[0].date : '';
|
|
|
+ const end = window.length ? window[window.length - 1].date : '';
|
|
|
+ const snapshot = start + '~' + end + '|' + sourceRows + '|' + idleDays;
|
|
|
+ if (snapshot === trendWindowLoggedSnapshot) return;
|
|
|
+ trendWindowLoggedSnapshot = snapshot;
|
|
|
+ homeLog('D1 出入库动态窗口', { windowDays: window.length, sourceRows, idleDays, start, end, costMs });
|
|
|
+};
|
|
|
|
|
|
export default {
|
|
|
name: 'WarehouseHomeOverview',
|
|
|
components: { DashboardChart },
|
|
|
props: { summary: { type: Object, default: () => ({}) }, options: { type: Object, default: () => ({}) }, warehouseId: { type: String, default: '' }, unit: { type: String, default: '' }, updatedAt: { type: String, default: '' }, error: { type: String, default: '' } },
|
|
|
+ // TOP 上限只用于卡片标题文案(TOP5),取 dashboard-utils 的同一常量,避免文案与截断条数各写一个数字。
|
|
|
+ // alertTopLimit 只用于 F1 卡片标题文案;E3 两个状态放这里:newActivityIds 必须声明在 data 里才是响应式的,
|
|
|
+ // highlightTimer 存 3 秒渐隐的定时器句柄,供 beforeDestroy 清理。
|
|
|
+ data() { return { alertTopLimit: ALERT_TOP_LIMIT, newActivityIds: [], highlightTimer: null }; },
|
|
|
computed: {
|
|
|
+ // D1 出入库动态:窗口=近 30 天滚动(含今日),后端只返回有作业的日期,
|
|
|
+ // 缺的日期补 null 而不是 0;无作业判定与净差口径全部收在 dashboard-utils 的纯函数里,
|
|
|
+ // 组件只负责取数、留痕日志。没有任何流水时返回空数组,交给 trendReason 显示空态。
|
|
|
trend() {
|
|
|
const rows = this.summary.trend || [];
|
|
|
if (!rows.length) return [];
|
|
|
- const byDate = new Map(rows.map((row) => [row.date, row]));
|
|
|
- const today = new Date();
|
|
|
- return Array.from({ length: 30 }, (_, index) => {
|
|
|
- const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29 + index);
|
|
|
- const date = day.getFullYear() + '-' + String(day.getMonth() + 1).padStart(2, '0') + '-' + String(day.getDate()).padStart(2, '0');
|
|
|
- return byDate.get(date) || { date, inbound: null, outbound: null, net: null };
|
|
|
- });
|
|
|
+ const startedAt = Date.now();
|
|
|
+ const window = buildTrendWindow(rows);
|
|
|
+ logTrendWindow(window, rows.length, Date.now() - startedAt);
|
|
|
+ return window;
|
|
|
},
|
|
|
+ // 窗口标签:副标必须写明「近 30 天滚动窗口(含今日)+ 起止日期」,避免被当成自然月看。
|
|
|
+ trendWindow() {
|
|
|
+ const dates = trendWindowDates();
|
|
|
+ return { start: dates[0] || '', end: dates[dates.length - 1] || '', days: dates.length };
|
|
|
+ },
|
|
|
+ // 窗口末尾最近连续同号段(0 与无作业日都算断点),供风险提示使用。
|
|
|
+ trendStreakInfo() { return trendStreak(this.trend); },
|
|
|
+ // 连续同号风险:连续为正=积压风险(橙)、连续为负=缺货风险(红),不足 2 天不提示。
|
|
|
+ trendRiskHint() { return trendRisk(this.trendStreakInfo); },
|
|
|
categories() { return this.summary.categories || []; },
|
|
|
aging() { return this.summary.aging || []; },
|
|
|
- alerts() { return this.summary.alerts || []; },
|
|
|
- activities() { return this.summary.activities || []; },
|
|
|
+ // E1 库龄结构:分段 key、label、ratio 全部来自后端 summary.aging(固定 5 行),
|
|
|
+ // 前端只做分拣与格式化,不复算占比、不重算库龄(口径唯一来源在后端)。
|
|
|
+ // 日志只在数值变化时打一条汇总:五行数量、四段占比合计、平均库龄、是否含 unknown。
|
|
|
+ agingSegments() {
|
|
|
+ const built = buildAgingSegments(this.summary.aging);
|
|
|
+ const snapshot = built.rowCount + '#' + built.segments.map((item) => item.key + '=' + item.quantity + '/' + item.ratio).join('|')
|
|
|
+ + '#' + this.summary.averageAgeDays + '#' + (built.hasUnknown ? 'unknown' : '');
|
|
|
+ if (snapshot !== agingLoggedSnapshot) {
|
|
|
+ agingLoggedSnapshot = snapshot;
|
|
|
+ homeLog('E1 库龄结构已解析', { rows: built.rowCount, ratioSum: built.ratioSum, averageAgeDays: this.summary.averageAgeDays, hasUnknown: built.hasUnknown });
|
|
|
+ // 未识别的分段 key 说明前后端分段口径不一致,按 warn 打一次,便于第一时间发现。
|
|
|
+ if (built.unexpectedKeys.length) homeWarn('E1 库龄结构出现未识别的分段 key', { keys: built.unexpectedKeys.join(',') });
|
|
|
+ }
|
|
|
+ return built;
|
|
|
+ },
|
|
|
+ agingRatio() { return this.agingSegments.segments; },
|
|
|
+ agingUnknown() { return this.agingSegments.unknown; },
|
|
|
+ // 平均库龄:后端给一位小数(按数量加权),null=日期不完整不可算 → 显示「—」,绝不显示 0 天。
|
|
|
+ averageAge() {
|
|
|
+ const text = formatAverageAgeDays(this.summary.averageAgeDays);
|
|
|
+ return { text, title: text === '—' ? (this.summary.agingReason || '批次首次入库日期不完整,平均库龄不可计算') : '在库批次按数量加权的平均库龄(后端计算,单位:天)' };
|
|
|
+ },
|
|
|
+ // 「较上月」角标:averageAgeChange 是天数差,负数=平均库龄下降=库存更“新”,向好走绿;
|
|
|
+ // 正数=库龄变长,恶化走红;0 持平;null=无基准,只展示后端原因(averageAgeChangeReason),绝不显示 0 天。
|
|
|
+ // 与同屏「库存周转天数」保持同一套红升绿降语义,同一块屏不教两种视觉语言。
|
|
|
+ averageAgeChange() {
|
|
|
+ const s = this.summary;
|
|
|
+ const change = parseBackendNumber(s.averageAgeChange);
|
|
|
+ if (change == null) {
|
|
|
+ const reason = s.averageAgeChangeReason ? String(s.averageAgeChangeReason) : '';
|
|
|
+ return { text: '', tone: 'dim', title: reason || '无上月可比基准,不展示平均库龄较上月差值' };
|
|
|
+ }
|
|
|
+ const signed = formatSignedNumber(change);
|
|
|
+ return {
|
|
|
+ text: '较上月' + (signed === '持平' ? '持平' : ' ' + signed + ' 天'),
|
|
|
+ tone: change < 0 ? 'good' : change > 0 ? 'bad' : 'dim',
|
|
|
+ title: '本月平均库龄 − 上月平均库龄(单位:天,可为负);负值=库龄下降(向好),正值=库龄上升'
|
|
|
+ };
|
|
|
+ },
|
|
|
+ // F1 安全库存预警卡 TOP5:三色阈值、缺口量、缺口金额、影响工单数、申请单号全部由后端下发,
|
|
|
+ // 前端只做归一、配色兜底与截断。不在这里复算 available/secure×K1,也不重排(排序契约在后端 SQL)。
|
|
|
+ alerts() {
|
|
|
+ const built = buildAlertRows(this.summary.alerts, ALERT_TOP_LIMIT);
|
|
|
+ // 缺价行(gapAmount 为 null)已经在 buildAlertRows 里逐行记过 warn,这里不重复打印。
|
|
|
+ return built.rows;
|
|
|
+ },
|
|
|
+ // F1 汇总条:alertCount(缺料+偏低+超储,待核实不计入)/ alertUnknownCount / alertGapAmount / alertWorkOrderCount
|
|
|
+ // 全部取 summary 级字段,前端只格式化;原因字段缺失时给一句可核对的事实说明,不编造业务原因。
|
|
|
+ alertSummary() {
|
|
|
+ const built = buildAlertSummary(this.summary);
|
|
|
+ return {
|
|
|
+ ...built,
|
|
|
+ workOrderTitle: built.workOrderCount == null
|
|
|
+ ? '影响生产工单数待核实:' + (built.workOrderReason || '后端未下发 alertWorkOrderCount(BOM 未展开或工单状态未回填)')
|
|
|
+ : '缺料行经 BOM 展开关联的未完工工单(待生产/生产中/已延期)去重数;点击查看缺料清单',
|
|
|
+ gapAmountTitle: built.gapAmount == null
|
|
|
+ ? '缺口金额合计待核实:' + (built.gapAmountReason || '存在缺价行或成本单价未维护,缺价行不计入合计')
|
|
|
+ : 'Σ缺口量×移动加权平均不含税单价(缺价行不计入)' + (built.gapAmountReason ? ';' + built.gapAmountReason : ''),
|
|
|
+ unknownTitle: built.unknownCount > 0 ? '待核实=无唯一有效阈值配置(或可用量为负)的行数,不计入预警条数 N' : ''
|
|
|
+ };
|
|
|
+ },
|
|
|
+ // E3 最近作业动态:去重(同单号只留最新)→ 按事件时间倒序 → 截断 5 条,全部在 dashboard-utils 里完成,
|
|
|
+ // 组件只负责渲染。口径不允许再内联进模板:一旦写成模板三元表达式,模板、日志与断言会各说一套。
|
|
|
+ // 解析/未识别日志由 buildActivityFeed 内部按模块级快照去重打印,这里不重复打。
|
|
|
+ activities() {
|
|
|
+ const rows = this.summary && this.summary.activities;
|
|
|
+ return buildActivityFeed(rows).items;
|
|
|
+ },
|
|
|
trendReason() { return this.summary.trendReason || this.summary.unitReason || this.summary.periodReason || ''; },
|
|
|
categoryReason() { return this.summary.categoryReason || this.summary.unitReason || ''; },
|
|
|
agingReason() { return this.summary.agingReason || this.summary.unitReason || ''; },
|
|
|
@@ -110,29 +393,300 @@ export default {
|
|
|
return { text, title: unit + ':锁定数量 ' + locked + ';0 为真实零,缺失显示「—」' };
|
|
|
},
|
|
|
|
|
|
+ // 库存金额「较上周」:口径为 Σ(在库数量×出库成本单价) 本期金额与 7 天前日末同刻快照金额的百分比变化。
|
|
|
+ // 后端只有两侧快照完整、无缺价批次且基准金额为正时才给 inventoryValueChangeRate;
|
|
|
+ // null/缺失/非数字一律按「不可比」处理并显示原因,绝不显示 0% 或拿部分金额估算。
|
|
|
+ stockValueWeekOverWeek() {
|
|
|
+ const s = this.summary;
|
|
|
+ const rate = s.inventoryValueChangeRate;
|
|
|
+ const base = formatComparisonBase(s.comparisonBaseAt);
|
|
|
+ const comparable = rate != null && rate !== '' && Number.isFinite(Number(rate));
|
|
|
+ if (!comparable) {
|
|
|
+ const reason = s.inventoryValueComparisonReason || '暂无上周同时点可比成本快照';
|
|
|
+ homeLog('库存金额周环比不可比', { inventoryValueChangeRate: rate, comparisonBaseAt: s.comparisonBaseAt, inventoryValueComparisonReason: s.inventoryValueComparisonReason });
|
|
|
+ return { text: reason, title: s.inventoryValueComparisonReason || reason };
|
|
|
+ }
|
|
|
+ const value = Number(rate);
|
|
|
+ const text = '较上周' + (value === 0 ? '持平' : ' ' + (value > 0 ? '+' : '-') + Math.abs(value).toFixed(2) + '%')
|
|
|
+ + (base ? '(基准 ' + base + ')' : '');
|
|
|
+ homeLog('库存金额周环比已解析', { inventoryValue: s.inventoryValue, inventoryValueBase: s.inventoryValueBase, inventoryValueChangeRate: rate, comparisonBaseAt: s.comparisonBaseAt, text });
|
|
|
+ return { text, title: '(本期库存金额 − 7 天前日末同刻金额)÷ 基准金额;金额按元跨基本单位汇总' + (base ? '(基准 ' + base + ')' : '') };
|
|
|
+ },
|
|
|
+
|
|
|
+ // 库存周转 KPI:周转天数、目标/预警天数、状态、较上月差值全部由后端下发,前端只做展示映射。
|
|
|
+ // 兼容后端字段未上线的过渡期:字段缺失时颜色走中性色、状态留空、较上月不显示 0,页面不报错。
|
|
|
+ turnoverCard() {
|
|
|
+ const s = this.summary;
|
|
|
+ const target = parseBackendNumber(s.turnoverTargetDays);
|
|
|
+ const warn = parseBackendNumber(s.turnoverWarnDays);
|
|
|
+ const change = parseBackendNumber(s.turnoverChange);
|
|
|
+ const asOf = parseBackendDate(s.turnoverAsOf);
|
|
|
+ const statusText = s.turnoverStatus == null ? '' : String(s.turnoverStatus).trim();
|
|
|
+ const style = Object.prototype.hasOwnProperty.call(TURNOVER_STATUS_STYLE, statusText) ? TURNOVER_STATUS_STYLE[statusText] : null;
|
|
|
+ // 联调日志:后端原始字段原样打印,便于核对字段名、单位与降级路径。
|
|
|
+ homeLog('库存周转字段已解析', { turnoverDays: s.turnoverDays, turnoverTargetDays: s.turnoverTargetDays, turnoverWarnDays: s.turnoverWarnDays, turnoverStatus: s.turnoverStatus, turnoverChange: s.turnoverChange, turnoverAsOf: s.turnoverAsOf, turnoverChangeReason: s.turnoverChangeReason });
|
|
|
+
|
|
|
+ // 较上月(单位:天):差值为负=周转天数下降(向好)用绿,为正=变慢用红,0 显示「较上月持平」;
|
|
|
+ // turnoverChange 为 null 表示上月无可比日末快照,只展示原因,绝不显示成 0。
|
|
|
+ let delta = '';
|
|
|
+ let deltaTitle = '';
|
|
|
+ let deltaTone = 'dim';
|
|
|
+ if (change == null) {
|
|
|
+ delta = s.turnoverChangeReason ? String(s.turnoverChangeReason) : '';
|
|
|
+ deltaTitle = delta || '上月无可比日末快照,不展示「较上月」差值';
|
|
|
+ } else {
|
|
|
+ const signed = formatSignedNumber(change);
|
|
|
+ delta = '较上月' + (signed === '持平' ? '持平' : ' ' + signed + ' 天');
|
|
|
+ deltaTone = change > 0 ? 'bad' : change < 0 ? 'good' : 'dim';
|
|
|
+ deltaTitle = '本月周转天数 − 上月周转天数(单位:天);负值=周转加快,正值=周转变慢';
|
|
|
+ }
|
|
|
+
|
|
|
+ // T+1 标注:turnoverAsOf 是本值对应的日末快照日期(昨日),缺失时退回「截至昨日」。
|
|
|
+ const asOfText = asOf ? '截至 ' + asOf + ' 日末' : '截至昨日';
|
|
|
+ // 目标/预警天数只用于第二行小字与提示说明,不参与前端的状态判定。
|
|
|
+ const targetText = target == null ? '' : formatTurnoverDays(target) + ' 天';
|
|
|
+ const warnText = warn == null ? '' : formatTurnoverDays(warn) + ' 天';
|
|
|
+ const subtotalTitle = (targetText || warnText
|
|
|
+ ? '目标 ' + (targetText || '—') + '、预警 ' + (warnText || '—') + '(按仓库配置下发);'
|
|
|
+ : '目标/预警天数待后端下发;')
|
|
|
+ + '周转天数取日末库存快照,T+1 展示';
|
|
|
+
|
|
|
+ return {
|
|
|
+ color: style ? style.color : TURNOVER_NEUTRAL_COLOR,
|
|
|
+ status: style ? style.text : '',
|
|
|
+ delta,
|
|
|
+ deltaTitle,
|
|
|
+ deltaTone,
|
|
|
+ subtotal: { text: style ? style.text + ' · ' + asOfText : asOfText, title: subtotalTitle },
|
|
|
+ // info 提示:口径说明固定,目标/预警天数在后端下发时补上当前取值。
|
|
|
+ info: '按月初与昨日期末库存快照均值、除以本月日均消耗性出库(不含调拨)估算;目标值按仓库配置'
|
|
|
+ + (targetText && warnText ? '(当前 ' + targetText + ' 达标 / ' + warnText + ' 预警)' : '') + '。'
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ // 呆滞品占比 KPI:阈值、环比差值、分级状态全部由后端下发(stagnantDays / stagnantChange / stagnantLevel),
|
|
|
+ // 前端只做展示映射,不再用 8%/10% 自行判级。兼容后端字段未上线的过渡期:
|
|
|
+ // 字段缺失时颜色走中性色、状态留空、环比不显示 0,页面不报错。
|
|
|
+ stagnantCard() {
|
|
|
+ const s = this.summary;
|
|
|
+ const rate = parseBackendNumber(s.stagnantRate);
|
|
|
+ const days = parseBackendNumber(s.stagnantDays);
|
|
|
+ const change = parseBackendNumber(s.stagnantChange);
|
|
|
+ const baseDate = parseBackendDate(s.stagnantBaseDate);
|
|
|
+ const level = s.stagnantLevel == null ? '' : String(s.stagnantLevel).trim();
|
|
|
+ const style = Object.prototype.hasOwnProperty.call(STAGNANT_LEVEL_STYLE, level) ? STAGNANT_LEVEL_STYLE[level] : null;
|
|
|
+ // 后端契约(分子/分母不可颠倒):stagnantBatchCount=呆滞批次数(分子 X),
|
|
|
+ // stagnantKnownBatchCount=可核实批次数(分母 Y,已排除 provenance_status<>1 与首次入库/最近消耗日期缺失的批次),
|
|
|
+ // stagnantUnknownBatchCount=日期未知批次数。总批次数 total 只用于「可核实 X/Y 批次」文案与 tooltip,不参与任何比值。
|
|
|
+ const numerator = parseBackendNumber(s.stagnantBatchCount);
|
|
|
+ const denominator = parseBackendNumber(s.stagnantKnownBatchCount);
|
|
|
+ const unknown = parseBackendNumber(s.stagnantUnknownBatchCount);
|
|
|
+ const total = denominator != null && unknown != null ? denominator + unknown : null;
|
|
|
+ const verifiable = denominator != null && denominator > 0;
|
|
|
+ // 分子÷分母的复核用占比:分母为可核实批次数,缺失/为 0 时返回 null,绝不拿总批次或后端 stagnantRate 顶替;
|
|
|
+ // 只用于 tooltip 说明「呆滞 X ÷ 可核实 Y = Z%」,卡片主数值仍以后端 stagnantRate 为准。
|
|
|
+ const batchRatioPercent = numerator != null && verifiable ? Math.round((numerator / denominator) * 10000) / 100 : null;
|
|
|
+ // 联调日志:后端原始字段原样打印,便于核对字段名、单位与降级路径。
|
|
|
+ homeLog('呆滞品字段已解析', { stagnantRate: s.stagnantRate, stagnantDays: s.stagnantDays, stagnantLevel: s.stagnantLevel, stagnantChange: s.stagnantChange, stagnantBaseDate: s.stagnantBaseDate, stagnantBatchCount: s.stagnantBatchCount, stagnantKnownBatchCount: s.stagnantKnownBatchCount, stagnantUnknownBatchCount: s.stagnantUnknownBatchCount, batchRatioPercent, verifiableBase: verifiable, unit: this.unit });
|
|
|
+ if (!verifiable) homeWarn('呆滞品分母不可核实', { stagnantRate: s.stagnantRate, stagnantKnownBatchCount: s.stagnantKnownBatchCount, stagnantUnknownBatchCount: s.stagnantUnknownBatchCount, stagnantReason: s.stagnantReason, stagnantChangeReason: s.stagnantChangeReason });
|
|
|
+
|
|
|
+ // 阈值文案:后端下发 90/180/365 时按实际值展示;缺失(多仓汇总或配置未下发)时天数位仍写默认 180,
|
|
|
+ // 但用括号注明这是默认阈值、仓库未配置,既不谎称配置值,也不把「按默认」塞进天数位置。
|
|
|
+ const daysText = days == null ? STAGNANT_DEFAULT_DAYS + ' 天(默认阈值,仓库未配置)' : String(days) + ' 天';
|
|
|
+ // 「可核实 X/Y 批次」:X=可核实批次数(分母),Y=总批次数(可核实+日期未知)。
|
|
|
+ // Y 缺失时显示「—」;分母 X 本身缺失时只给「可核实批次数待核实」,绝不拿 total 顶替 Y 的位置冒充可核实分母。
|
|
|
+ const verifiableText = denominator == null
|
|
|
+ ? '可核实批次数待核实'
|
|
|
+ : '可核实 ' + denominator + '/' + (total == null ? '—' : total) + ' 批次';
|
|
|
+
|
|
|
+ // 环比:stagnantChange 为 null(上月无完整日结快照或分母不可核实)只展示原因,
|
|
|
+ // 依次退回 stagnantChangeReason → stagnantReason → 「数据待核实」,绝不渲染成 0 或 +0.00pp。
|
|
|
+ let delta = '';
|
|
|
+ let deltaTitle = '';
|
|
|
+ let deltaTone = 'dim';
|
|
|
+ if (change == null) {
|
|
|
+ delta = s.stagnantChangeReason || s.stagnantReason || '数据待核实';
|
|
|
+ deltaTitle = delta;
|
|
|
+ homeWarn('呆滞品环比不可用', { stagnantChange: s.stagnantChange, stagnantBaseDate: s.stagnantBaseDate, stagnantChangeReason: s.stagnantChangeReason, stagnantReason: s.stagnantReason, text: delta });
|
|
|
+ } else {
|
|
|
+ const formatted = formatPpChange(change);
|
|
|
+ // 持平不拼数值;基准快照日期缺失时省略括号,日期本身不做任何补造。
|
|
|
+ delta = '较上月' + (formatted === '持平' ? '持平' : ' ' + formatted) + (baseDate ? '(基准 ' + baseDate + ' 日末)' : '');
|
|
|
+ // 呆滞占比上升是恶化(bad 红)、下降是好转(good 绿)、持平不带健康度(dim)。
|
|
|
+ // 与同屏「库存周转天数」卡保持一致的红升绿降语义,同一块屏不教两种视觉语言。
|
|
|
+ deltaTone = change > 0 ? 'bad' : change < 0 ? 'good' : 'dim';
|
|
|
+ deltaTitle = '本月呆滞占比 − 上月最后一个完整日结快照占比(单位:百分点 pp,可为负);正值=占比抬升,负值=占比下降'
|
|
|
+ + (baseDate ? '(基准 ' + baseDate + ' 日末)' : '');
|
|
|
+ homeLog('呆滞品环比已解析', { stagnantRate: s.stagnantRate, stagnantChange: s.stagnantChange, stagnantBaseDate: s.stagnantBaseDate, text: delta, deltaTone });
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ color: style ? style.color : STAGNANT_NEUTRAL_COLOR,
|
|
|
+ status: style ? style.text : '',
|
|
|
+ delta,
|
|
|
+ deltaTitle,
|
|
|
+ deltaTone,
|
|
|
+ // 第二行小字:后端分级文案 + 可核实分母(分级缺失时只留分母,不补造状态)。
|
|
|
+ // tooltip 里给出 分子÷分母 的复核算式,便于核对「呆滞批次数 ÷ 可核实批次数」,比值不可算时不显示数字。
|
|
|
+ subtotal: {
|
|
|
+ text: (style ? style.text + ' · ' : '') + verifiableText,
|
|
|
+ title: '呆滞分级由后端按仓库配置的库龄阈值判定;可核实分母已剔除来源标记异常与日期缺失的批次'
|
|
|
+ + (batchRatioPercent == null
|
|
|
+ ? ';分子/分母不完整,比值不可计算'
|
|
|
+ : ';呆滞 ' + numerator + ' 批次 ÷ 可核实 ' + denominator + ' 批次 = ' + batchRatioPercent + '%')
|
|
|
+ + (unknown == null ? '' : ';日期未知 ' + unknown + ' 批次不计入分母')
|
|
|
+ },
|
|
|
+ // info 提示:口径固定说明 + 当前生效阈值 + 可核实分母。
|
|
|
+ info: '库龄 > ' + daysText + '且期间无任何出库/领用动态的批次数 ÷ 可核实批次数;' + verifiableText,
|
|
|
+ progress: rate == null ? null : Math.min(100, rate * 10)
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ // 质检通过率 KPI:通过率、较上月差值(单位 pp)、让步接收批次全部由后端下发,前端只做展示映射。
|
|
|
+ // 口径:通过率=合格批次÷已检批次(合格+不合格,让步接收进分母但不计合格),
|
|
|
+ // 时间口径固定为「检验完成日期落在本月」,由后端判定,前端不再自行改时间窗。
|
|
|
+ // qualityInspectedCount 可能为 0 而 qualityPassRate 为 null:此时显示「—」、中性色、状态留空,绝不把 0 当率。
|
|
|
+ qualityCard() {
|
|
|
+ const s = this.summary;
|
|
|
+ const rate = parseBackendNumber(s.qualityPassRate);
|
|
|
+ const inspected = parseBackendNumber(s.qualityInspectedCount);
|
|
|
+ const passed = parseBackendNumber(s.qualityPassedCount);
|
|
|
+ const concession = parseBackendNumber(s.qualityConcessionCount);
|
|
|
+ const change = parseBackendNumber(s.qualityChange);
|
|
|
+ const style = resolveQualityStyle(rate);
|
|
|
+ // 联调日志:只打印质检卡片真正用到的字段,禁止打印整包 summary。
|
|
|
+ homeLog('质检通过率字段已解析', { qualityPassRate: s.qualityPassRate, qualityInspectedCount: s.qualityInspectedCount, qualityPassedCount: s.qualityPassedCount, qualityConcessionCount: s.qualityConcessionCount, qualityChange: s.qualityChange, qualityChangeReason: s.qualityChangeReason });
|
|
|
+
|
|
|
+ // 较上月(单位 pp,复用 dashboard-utils 的 formatPpChange:带符号 + 2 位小数 + pp,0 只给「持平」)。
|
|
|
+ // 质检通过率上升是好转(good 绿 ▲)、下降是恶化(bad 红 ▼)、持平不带健康度(dim);
|
|
|
+ // good/bad/dim 与同屏「库存周转天数」「呆滞品占比」完全同一套取值,同一块屏不教两种视觉语言。
|
|
|
+ // qualityChange 为 null 表示上月不可比:只显示占位符「—」(与 formatPpChange(null) 同一语义),
|
|
|
+ // 不可比原因写进 title 与控制台日志,绝不渲染成 +0.00pp、0 或「持平」。
|
|
|
+ let delta = '';
|
|
|
+ let deltaTitle = '';
|
|
|
+ let deltaTone = 'dim';
|
|
|
+ if (change == null) {
|
|
|
+ delta = '—';
|
|
|
+ deltaTitle = s.qualityChangeReason ? String(s.qualityChangeReason) : '较上月不可比(上月无已检批次或口径不可比)';
|
|
|
+ homeWarn('质检通过率环比不可比', { qualityPassRate: s.qualityPassRate, qualityInspectedCount: s.qualityInspectedCount, qualityChange: s.qualityChange, qualityChangeReason: s.qualityChangeReason, text: deltaTitle });
|
|
|
+ } else {
|
|
|
+ const formatted = formatPpChange(change);
|
|
|
+ // ▲/▼ 是上升/下降的显式标识(颜色仍由 deltaTone 决定);持平不挂箭头。
|
|
|
+ const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '';
|
|
|
+ delta = (arrow ? arrow + ' ' : '') + '较上月' + (formatted === '持平' ? '持平' : ' ' + formatted);
|
|
|
+ deltaTone = change > 0 ? 'good' : change < 0 ? 'bad' : 'dim';
|
|
|
+ deltaTitle = '本月质检通过率 − 上月质检通过率(单位:百分点 pp,可为负);正值=通过率上升、负值=下降;口径:检验完成日期落在本月';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 第二行小字(与周转/呆滞同一写法):分级状态 + 让步接收批次 + 分子分母,每段缺值时不占位。
|
|
|
+ // 让步接收批次只在 > 0 时出现(0 或缺失=本月没有让步单,写「让步接收 0 批」只是噪音)。
|
|
|
+ const parts = [];
|
|
|
+ if (style) parts.push(style.text);
|
|
|
+ if (concession != null && concession > 0) parts.push('让步接收 ' + concession + ' 批 · 进分母不计合格');
|
|
|
+ if (inspected != null && inspected > 0) parts.push('已检 ' + inspected + ' 批 / 合格 ' + (passed == null ? '—' : passed) + ' 批');
|
|
|
+ const subtotal = parts.length ? {
|
|
|
+ text: parts.join(' · '),
|
|
|
+ title: '分级阈值来自业务确认:≥98 达标(绿)/ 95~98 接近阈值(橙)/ <95 低于目标(红);'
|
|
|
+ + '通过率=合格批次 ÷ 已检批次(合格+不合格,让步接收进分母但不计合格),未检批次不计入分母'
|
|
|
+ + (concession != null && concession > 0 ? ';本月让步接收 ' + concession + ' 批次已计入分母' : '')
|
|
|
+ } : null;
|
|
|
+
|
|
|
+ return {
|
|
|
+ color: style ? style.color : QUALITY_NEUTRAL_COLOR,
|
|
|
+ status: style ? style.text : '',
|
|
|
+ delta,
|
|
|
+ deltaTitle,
|
|
|
+ deltaTone,
|
|
|
+ subtotal,
|
|
|
+ // info 提示:口径固定说明,分子分母只在 subtotal 里出现一次,避免同一张卡两处数字打架。
|
|
|
+ info: '质检通过率=合格批次数 ÷ 已检批次数 × 100%(合格+不合格,让步接收进分母但不计合格,未检批次不计入分母);口径:检验完成日期落在本月;较上月单位 pp',
|
|
|
+ progress: rate
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
userName() { return (this.$store.state.user.info || {}).nickname || (this.$store.state.user.info || {}).name || ''; },
|
|
|
monthLabel() { return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit' }).format(new Date()); },
|
|
|
todayLabel() { return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long' }).format(new Date()); },
|
|
|
greeting() { const hour = new Date().getHours(); return hour < 11 ? '上午好' : hour < 18 ? '下午好' : '晚上好'; },
|
|
|
+ // 待办条口径与下钻目标(业务已确认,与后端 WmsHomeSummaryVO 逐条对齐):
|
|
|
+ // 1 待入库单=入库单状态 ∈ {草稿,待审批,待收货,待上架} 且本月创建、按数据权限过滤 → V13 出入库申请(入库页签)
|
|
|
+ // 2 待出库单=出库单状态 ∈ {草稿,待审批,待拣货} 且本月创建、按数据权限过滤 → V13 出入库申请(出库页签)
|
|
|
+ // 3 盘点任务进行中=盘点单状态=执行中 且未过计划完成日期(不限创建月份)→ V5 待办单据(盘点页签)
|
|
|
+ // 4 待我审批=当前用户为审批流当前节点处理人的单据数(入库+出库+调拨合计)→ 协同办公流程待办
|
|
|
+ // in/out 都显式写 direction,不依赖 dashboard.vue open() 里 !filters.direction 的兜底:
|
|
|
+ // 待出库单一旦漏写就会落到入库页签,卡片数字与下钻列表立刻对不上。
|
|
|
+ // 状态口径说明:t_wms_apply_storage.status 只有 0未处理/1已处理/2已撤销,"未完成(status=0)"
|
|
|
+ // 已等价覆盖需求里的 {草稿,待审批,待收货,待上架}(入库)与 {草稿,待审批,待拣货}(出库),
|
|
|
+ // 因此首页数字只按未完成合计展示、不能按细分状态拆开,下钻也只传 direction,
|
|
|
+ // status=0 与本月范围由 open() 统一补,避免两处各写一份范围导致数字与列表分叉。
|
|
|
todoCards() {
|
|
|
- return [
|
|
|
- { key: 'in', icon: 'in', symbol: '↓', label: '待入库单', value: this.summary.pendingInbound, target: 'ioapp', overdue: this.summary.overdueInbound },
|
|
|
- { key: 'out', icon: 'out', symbol: '↑', label: '待出库单', value: this.summary.pendingOutbound, target: 'inapp', overdue: this.summary.overdueOutbound },
|
|
|
- { key: 'check', icon: 'check', symbol: '✓', label: '盘点任务进行中', value: this.summary.pendingInventory, target: 'todo' },
|
|
|
- { key: 'approve', icon: 'approve', symbol: '▣', label: '待我审批', value: this.summary.pendingApproval, overdue: this.summary.overdueApproval, target: 'flow' }
|
|
|
+ const summary = this.summary || {};
|
|
|
+ const cards = [
|
|
|
+ { key: 'in', icon: 'in', symbol: '↓', label: '待入库单', value: summary.pendingInbound, target: 'ioapp', filters: { direction: 'in' }, overdue: summary.overdueInbound },
|
|
|
+ { key: 'out', icon: 'out', symbol: '↑', label: '待出库单', value: summary.pendingOutbound, target: 'ioapp', filters: { direction: 'out' }, overdue: summary.overdueOutbound },
|
|
|
+ { key: 'check', icon: 'check', symbol: '✓', label: '盘点任务进行中', value: summary.pendingInventory, target: 'todo' },
|
|
|
+ { key: 'approve', icon: 'approve', symbol: '▣', label: '待我审批', value: summary.pendingApproval, overdue: summary.overdueApproval, target: 'flow' }
|
|
|
];
|
|
|
+ // 只在 6 个数值任一变化时打一条,供排查「卡片数字与下钻列表对不上」;不含 query 全量与 token。
|
|
|
+ const snapshot = todoCardsSnapshot(cards);
|
|
|
+ if (snapshot !== todoCardsLoggedSnapshot) {
|
|
|
+ todoCardsLoggedSnapshot = snapshot;
|
|
|
+ homeLog('待办条计数已解析', {
|
|
|
+ pendingInbound: summary.pendingInbound, pendingOutbound: summary.pendingOutbound,
|
|
|
+ pendingInventory: summary.pendingInventory, pendingApproval: summary.pendingApproval,
|
|
|
+ overdueInbound: summary.overdueInbound, overdueOutbound: summary.overdueOutbound,
|
|
|
+ warehouseId: this.warehouseId || '', unit: this.unit || ''
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return cards;
|
|
|
},
|
|
|
kpis() {
|
|
|
const s = this.summary;
|
|
|
+ // 周转卡片先取一次,避免同一份解析重复执行(日志也只打一次)。
|
|
|
+ const turnover = this.turnoverCard;
|
|
|
+ // 呆滞卡片同理:一次解析同时喂颜色、状态、环比与口径提示,日志只打一次。
|
|
|
+ const stagnant = this.stagnantCard;
|
|
|
+ // 质检卡片同理:一次解析同时喂颜色、状态、较上月 pp 与让步接收小字,日志只打一次。
|
|
|
+ const quality = this.qualityCard;
|
|
|
return [
|
|
|
{ key: 'stock', code: '01', label: '库存总量', value: s.inventoryQuantity, unit: this.unit, reason: s.inventoryReason, delta: this.stockWeekOverWeek.text, deltaTitle: this.stockWeekOverWeek.title, subtotal: this.stockUnitSubtotal, locked: this.stockLocked, info: '当前在库数量含待检、剔除锁定;不同基本单位分别展示,不跨单位加总', color: '#38C8F5', target: 'stockLedger' },
|
|
|
- { key: 'value', code: '02', label: '库存金额', value: s.inventoryValue, unit: '元', reason: s.valuationReason, info: '按移动加权、不含税成本价计算;当前缺少可核实成本数据源', color: '#9B7DF7', target: 'stockLedger' },
|
|
|
- { key: 'turnover', code: '03', label: '库存周转天数', value: s.turnoverDays, unit: '天', reason: s.turnoverReason, info: '本月截至昨日的平均库存与实际消耗估算,历史不足时无法计算', color: s.turnoverDays == null ? '#9FB8D4' : s.turnoverDays <= 45 ? '#3BD48A' : s.turnoverDays <= 60 ? '#F0A03A' : '#F25E5E', status: s.turnoverDays == null ? '' : s.turnoverDays <= 45 ? '达标' : s.turnoverDays <= 60 ? '超目标' : '周转偏慢', target: 'turnover' },
|
|
|
- { key: 'stagnant', code: '04', label: '呆滞品占比', value: s.stagnantRate, unit: '%', reason: s.stagnantReason, info: '首次入库及最近实际消耗均超过 180 天的批次数占当前在库批次总数的比例;日期不完整时无法核实', color: s.stagnantRate == null ? '#9FB8D4' : s.stagnantRate >= 10 ? '#F25E5E' : s.stagnantRate >= 8 ? '#F0A03A' : '#3BD48A', status: s.stagnantRate == null ? '' : s.stagnantRate >= 10 ? '呆滞预警' : s.stagnantRate >= 8 ? '接近阈值' : '正常', progress: s.stagnantRate == null ? null : Math.min(100, Number(s.stagnantRate) * 10), target: 'dead' },
|
|
|
- { key: 'quality', code: '06', label: '质检通过率', value: s.qualityPassRate, unit: '%', reason: s.qualityReason, info: '本月已检批次中合格批次占比,让步计分母但不计合格', color: s.qualityPassRate == null ? '#9FB8D4' : s.qualityPassRate >= 98 ? '#3BD48A' : s.qualityPassRate >= 95 ? '#F0A03A' : '#F25E5E', status: s.qualityPassRate == null ? '' : s.qualityPassRate >= 98 ? '达标' : s.qualityPassRate >= 95 ? '接近阈值' : '低于目标', progress: s.qualityPassRate, target: 'qc' }
|
|
|
+ // 金额为空时优先展示成本覆盖原因(用户更需要知道为什么没有金额),只有拿到完整金额后才展示周环比。
|
|
|
+ { key: 'value', code: '02', label: '库存金额', value: s.inventoryValue, unit: '元', reason: s.valuationReason, delta: s.inventoryValue == null ? (s.valuationReason || '库存金额待成本数据核实') : this.stockValueWeekOverWeek.text, deltaTitle: s.inventoryValue == null ? (s.valuationReason || '') : this.stockValueWeekOverWeek.title, info: '库存金额=Σ(在库数量×出库成本单价),移动加权平均、不含税;与库存总量同源同一份快照,不扣减呆滞跌价准备', color: '#9B7DF7', target: 'stockLedger' },
|
|
|
+ // 周转天数:颜色与状态全部来自后端 turnoverStatus(null 走中性色、状态留空),
|
|
|
+ // 较上月差值按正负换绿/红,第二行小字展示「状态 · 截至 yyyy-MM-dd 日末」(T+1)。
|
|
|
+ { key: 'turnover', code: '03', label: '库存周转天数', value: s.turnoverDays, unit: '天', reason: s.turnoverReason, delta: turnover.delta, deltaTitle: turnover.deltaTitle, deltaTone: turnover.deltaTone, status: turnover.status, subtotal: turnover.subtotal, info: turnover.info, color: turnover.color, target: 'turnover' },
|
|
|
+ // 呆滞品占比:颜色与状态只认后端 stagnantLevel(null 走中性色、状态留空),
|
|
|
+ // 阈值/环比/可核实分母同样由后端下发,前端不再自行用 8%/10% 判级(判级依据在仓库配置与上月快照里)。
|
|
|
+ { key: 'stagnant', code: '04', label: '呆滞品占比', value: s.stagnantRate, unit: '%', reason: s.stagnantReason, delta: stagnant.delta, deltaTitle: stagnant.deltaTitle, deltaTone: stagnant.deltaTone, status: stagnant.status, subtotal: stagnant.subtotal, info: stagnant.info, color: stagnant.color, progress: stagnant.progress, target: 'dead' },
|
|
|
+ // 质检通过率:颜色与文案按业务确认的 ≥98/95 阈值分级(null 走中性色、状态留空,0 已检批次不显示 0%),
|
|
|
+ // 较上月复用 qualityChange(单位 pp,上升为好 → good 绿 ▲),让步接收批次走第二行小字。
|
|
|
+ { key: 'quality', code: '06', label: '质检通过率', value: s.qualityPassRate, unit: '%', reason: s.qualityReason, delta: quality.delta, deltaTitle: quality.deltaTitle, deltaTone: quality.deltaTone, status: quality.status, subtotal: quality.subtotal, info: quality.info, color: quality.color, progress: quality.progress, target: 'qc' }
|
|
|
];
|
|
|
}
|
|
|
},
|
|
|
+ watch: {
|
|
|
+ // E3 新增事件高亮:只换一批 id 让模板加 class,不移动 DOM 顺序、不调用任何滚动 API——
|
|
|
+ // 30 秒轮询里主动 scrollIntoView/scrollTop 会把用户正在看的位置顶走,属于明确禁止的打断。
|
|
|
+ // 首屏不整屏高亮:diffActivityIds 在 prevItems 为空(首次加载)时返回空数组,这里天然不触发。
|
|
|
+ activities(nextItems, prevItems) {
|
|
|
+ const added = diffActivityIds(prevItems, nextItems);
|
|
|
+ if (!added.length) return;
|
|
|
+ // 连续两批新增时重置定时器:只保留最后一轮的 3 秒,避免先到的一批提前被摘掉。
|
|
|
+ if (this.highlightTimer) clearTimeout(this.highlightTimer);
|
|
|
+ this.newActivityIds = added;
|
|
|
+ homeLog('E3 最近作业动态新增事件', { ids: added.map((id) => String(id)), count: added.length });
|
|
|
+ // 渐隐由 scss 的 feed-new-fade 动画完成(3 秒),JS 只负责到点摘掉 class。
|
|
|
+ this.highlightTimer = setTimeout(() => {
|
|
|
+ this.newActivityIds = [];
|
|
|
+ this.highlightTimer = null;
|
|
|
+ }, 3000);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ // 组件销毁时清掉高亮定时器:否则 3 秒后会在已销毁实例上写响应式数据(报错/内存泄漏)。
|
|
|
+ if (this.highlightTimer) clearTimeout(this.highlightTimer);
|
|
|
+ this.highlightTimer = null;
|
|
|
+ },
|
|
|
methods: {
|
|
|
display: displayNumber,
|
|
|
displayKpi(item) {
|
|
|
@@ -140,18 +694,108 @@ export default {
|
|
|
const value = Number(item.value);
|
|
|
return Number.isFinite(value) ? value.toLocaleString('zh-CN', item.unit === '%' ? { minimumFractionDigits: 1, maximumFractionDigits: 1 } : { maximumFractionDigits: 0 }) : String(item.value);
|
|
|
},
|
|
|
- activityTime(value) {
|
|
|
- if (!value) return '—';
|
|
|
- const date = new Date(value);
|
|
|
- if (Number.isNaN(date.getTime())) return String(value).slice(0, 16);
|
|
|
- const today = new Date();
|
|
|
- const day = date.toDateString();
|
|
|
- return day === today.toDateString() ? String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0')
|
|
|
- : day === new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1).toDateString() ? '昨日' : String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
|
|
|
- },
|
|
|
- trendSelect(row) { this.$emit('open', 'io', { startTime: row.date + ' 00:00:00', endTime: row.date + ' 23:59:59' }); },
|
|
|
- categorySelect() { this.$emit('open', 'stockLedger'); },
|
|
|
- ageSelect(row) { this.$emit('open', 'age', { age: row.key }); }
|
|
|
+ // E3 动态下钻(整行点击 / 键盘 Enter / 单号段单独点击都走这里):带原始行走 V4 出入库动态。
|
|
|
+ // 原始字段 activityType/orderId/orderNo/direction/warehouseId 都还在 item 上
|
|
|
+ // (buildActivityFeed 先浅拷贝展开后端字段,前端展示字段用 timeText/actionText 等新名字,不会覆盖),
|
|
|
+ // 所以 V4 能按类型正确落地;日志只记关键字段,不打印整行数据。
|
|
|
+ openActivity(item) {
|
|
|
+ if (!item) return;
|
|
|
+ homeLog('E3 最近作业动态下钻', { id: item.id, activityType: item.activityType, orderNo: item.orderNo, direction: item.direction });
|
|
|
+ this.$emit('business', 'io', item);
|
|
|
+ },
|
|
|
+ // 点击柱体(含无作业日)→ 打开 V4 出入库动态并带入该日 00:00:00~23:59:59;
|
|
|
+ // 无作业日同样带日期(列表为空态),不能因为当天没柱体就丢掉日期。
|
|
|
+ trendSelect(row) {
|
|
|
+ if (!row) return;
|
|
|
+ homeLog('D1 出入库动态下钻', { date: row.date, idle: !!row.idle, inbound: row.inbound, outbound: row.outbound, net: row.net });
|
|
|
+ this.$emit('open', 'io', { startTime: row.date + ' 00:00:00', endTime: row.date + ' 23:59:59' });
|
|
|
+ },
|
|
|
+ // D2 库存分类分布下钻:必须把「被点击的分类」带进 V2 库存台账,只跳转等于把用户的点击丢了。
|
|
|
+ // 传 categoryLevelId=后端 categories 行的 id(即 t_main_category_level.id,分类根层级 id):
|
|
|
+ // 台账左侧树按该 id 选中节点,右侧 item-list 再把它作为 /wms/outindetailtwo/batchPage 的
|
|
|
+ // categoryLevelId 参数;后端 getIdByParentId 会展开该层级的子层级,所以传根层级 id 就能过滤出整棵子树。
|
|
|
+ // 「其他」是前端 TOP8 的聚合兜底行(id 固定为字符串 'other'),它不对应任何真实分类层级:
|
|
|
+ // 一旦当成 categoryLevelId 传下去,台账只会按一个不存在的层级查出空列表,等于伪造了一个不存在的
|
|
|
+ // 物料分类。所以「其他」只打开台账、不带分类,交给用户在台账里自行选择。
|
|
|
+ // 日志只打排查需要的关键字段,不打印整行数据,便于在控制台按「D2」筛出下钻链路。
|
|
|
+ categorySelect(row) {
|
|
|
+ const other = !row || !row.id || row.id === 'other';
|
|
|
+ homeLog('D2 分类分布下钻', { id: row ? row.id : undefined, name: row ? row.name : undefined, quantity: row ? row.quantity : undefined, ratio: row ? row.ratio : undefined, unit: this.unit || '', other });
|
|
|
+ if (other) return this.$emit('open', 'stockLedger');
|
|
|
+ this.$emit('open', 'stockLedger', { categoryLevelId: row.id });
|
|
|
+ },
|
|
|
+ // E1 库龄结构下钻:带后端分段 key(0-30/31-90/91-180/180+)进 V6 明细;
|
|
|
+ // unknown 段同样能下钻(明细为空态),不能因为「算不出库龄」就把点击吞掉。
|
|
|
+ ageSelect(row) {
|
|
|
+ if (!row) return;
|
|
|
+ homeLog('E1 库龄结构下钻', { key: row.key, label: row.label, quantity: row.quantity, ratio: row.ratio, unknown: row.key === 'unknown' });
|
|
|
+ this.$emit('open', 'age', { age: row.key });
|
|
|
+ },
|
|
|
+ // F1 预警行下钻:带上「仓库+物料」过滤进清单,避免只跳转不过滤、把用户这次点击丢掉;
|
|
|
+ // 日志带状态与缺口金额,便于核对「点的是哪一行、它为什么排在这个位置」。
|
|
|
+ alertSelect(item) {
|
|
|
+ if (!item) return;
|
|
|
+ logAlertDrill('行下钻预警清单', { id: item.id, categoryId: item.categoryId, warehouseId: item.warehouseId, status: item.status, gapAmount: item.gapAmount, purchaseRequestCode: item.purchaseRequestCode });
|
|
|
+ this.$emit('open', 'alerts', { categoryId: item.categoryId, warehouseId: item.warehouseId });
|
|
|
+ },
|
|
|
+ // 卡片右上角「全部 N 项」:N 取后端 summary.alertCount(缺料+偏低+超储,待核实不计入),
|
|
|
+ // 因此下钻时不能带任何过滤,否则清单条数与 N 的统计范围就对不上。
|
|
|
+ openAlertAll() {
|
|
|
+ logAlertDrill('全部预警清单', { alertCount: this.alertSummary.count });
|
|
|
+ this.$emit('open', 'alerts');
|
|
|
+ },
|
|
|
+ // 汇总条「影响生产工单」:工单数只由 critical 行经 BOM 展开去重得出,所以下钻直接按 level=critical 过滤,
|
|
|
+ // 让清单与数字同源;工单数缺失(null)时依然允许下钻——清单本身仍然有效。
|
|
|
+ openAlertWorkOrders() {
|
|
|
+ logAlertDrill('影响工单下钻缺料清单', { alertWorkOrderCount: this.alertSummary.workOrderCount, level: 'critical' });
|
|
|
+ this.$emit('open', 'alerts', { level: 'critical' });
|
|
|
+ },
|
|
|
+ // 行悬浮提示:把可用量、阈值、缺口与缺价原因一次说清,免得用户为了一个「金额待补价」再点进清单。
|
|
|
+ // 缺口金额文本复用 alertGapAmountText(缺价时它自己会打一条去重日志)。
|
|
|
+ alertRowTitle(item) {
|
|
|
+ const parts = [];
|
|
|
+ const unit = item.unit ? ' ' + item.unit : '';
|
|
|
+ parts.push('可用量 ' + formatAlertQuantity(item.availableQuantity) + unit + '=在库量−锁定量(含待检、未放行批次)');
|
|
|
+ if (item.lockedQuantity != null) parts.push('锁定量 ' + formatAlertQuantity(item.lockedQuantity) + unit);
|
|
|
+ if (item.secureInventory != null) parts.push('安全库存 ' + formatAlertQuantity(item.secureInventory) + unit);
|
|
|
+ if (item.maxInventory != null) parts.push('最大库存 ' + formatAlertQuantity(item.maxInventory) + unit);
|
|
|
+ else parts.push('最大库存未配置(超储按 安全库存×超储兜底系数 判定)');
|
|
|
+ parts.push('缺口 ' + formatAlertQuantity(item.gapQuantity) + unit);
|
|
|
+ // 缺价行的原因(gapAmountReason)跟在这句后面,避免「金额待补价」变成一个查不到原因的死胡同。
|
|
|
+ parts.push('缺口金额 ' + alertGapAmountText(item) + (item.gapAmount == null ? '(' + alertGapAmountReason(item) + ')' : ''));
|
|
|
+ if (item.workOrderCount != null) parts.push('影响未完工工单 ' + item.workOrderCount + ' 单');
|
|
|
+ if (item.purchaseRequestCode) parts.push('已生成采购申请 ' + item.purchaseRequestCode);
|
|
|
+ if (item.reason) parts.push(item.reason);
|
|
|
+ return parts.join(';');
|
|
|
+ }
|
|
|
}
|
|
|
};
|
|
|
</script>
|
|
|
+<style scoped>
|
|
|
+/* 四段占比条:复用卡片既有的 .legend 布局,只补换行与不折行;色块颜色由分段色阶内联给出。 */
|
|
|
+.aging-ratio { flex-wrap: wrap; padding-top: 8px; }
|
|
|
+.aging-ratio span { white-space: nowrap; }
|
|
|
+/* >180 天与呆滞口径联动:四段里唯一用红色强调的一段(色阶见 dashboard-utils 的 agingColorOf)。 */
|
|
|
+.aging-ratio .aging-hot { color: #F25E5E; }
|
|
|
+/* unknown 段不属于四段色阶:中性灰,并把「不参与四段占比」写进文案。 */
|
|
|
+.aging-ratio .aging-unknown { color: #7E93AD; }
|
|
|
+.aging-delta { margin-left: 6px; }
|
|
|
+/* 较上月不可比时只显示后端原因,用中性小字,不带任何健康度色调。 */
|
|
|
+.aging-delta-note { margin-left: 6px; color: #9FB8D4; }
|
|
|
+.aging-note { margin-top: 8px; font-size: 11px; line-height: 1.6; color: #9FB8D4; }
|
|
|
+/* F1 安全库存预警卡:状态标签与缺口行在同一行内展示。
|
|
|
+ .alert-gap 用 margin-left:auto 顶到右侧,保持旧版「物料名 + 右侧说明」的视觉节奏(行高不变,仍是 5 行)。 */
|
|
|
+.alert-item .alert-state { flex-shrink: 0; font-size: 11px; font-weight: 600; }
|
|
|
+.alert-item .alert-gap { margin-left: auto; white-space: nowrap; color: var(--t2, #9FB8D4); font-variant-numeric: tabular-nums; }
|
|
|
+/* 影响工单数只在缺口行出现(0 或缺失不渲染),橙色小字提示「这一行真的挡住了生产」。 */
|
|
|
+.alert-item .alert-wo { margin-left: 8px; white-space: nowrap; color: #F0A03A; }
|
|
|
+/* 「已生成申请」=防重复下单的识别标记:中性描边标签,不表达健康度,也不与三色预警抢视觉。 */
|
|
|
+.alert-item .alert-requested { margin-left: 8px; padding: 1px 6px; border: 1px solid rgba(155,125,247,.55); border-radius: 9px; color: #C9B6FF; white-space: nowrap; }
|
|
|
+/* 汇总条:工单数可点击下钻,缺口金额合计与待核实说明为只读信息;上边框与卡片分隔线保持一致。 */
|
|
|
+.alert-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 9px 18px; border-top: 1px solid var(--grid, rgba(159,184,212,.18)); font-size: 12px; color: var(--t2, #9FB8D4); }
|
|
|
+.alert-sum-item { white-space: nowrap; }
|
|
|
+.alert-sum-item[role="button"] { cursor: pointer; }
|
|
|
+.alert-sum-item b { color: #EAF4FF; font-weight: 600; }
|
|
|
+/* 待核实行不计入预警条数:单独一条中性说明,避免用户以为「N 项」漏数。 */
|
|
|
+.alert-unknown-note { white-space: nowrap; color: #7E93AD; }
|
|
|
+</style>
|