Forráskód Böngészése

fix:970 仓储管理看板首先新需求 库存总量

mlchen 4 napja
szülő
commit
7c4ffa6859

+ 14 - 0
docs/仓储首页看板口径.md

@@ -0,0 +1,14 @@
+# 仓储首页看板口径
+
+依据 2026-10-22 的 V2.3/V2.4 变更记录实施;随附文档虽然标为 V2.2,正文中旧版的六卡、库位地图和五类动态说明不再适用。
+
+- 首页为四格单行待办、五张 KPI(编号 01、02、03、04、06)、近 30 天出入库柱状与净趋势、分类横条,以及库龄环图、预警 TOP5、四类最近作业动态。
+- 仓库筛选只用已授权仓库;默认显示全部基本单位,但数量混合单位时显示“—”并提示用户选择单位,不默认套用列表第一单位。库存数量、分类、库龄均扣锁。
+- 库存总量、库存金额及库存分类分布的“查看台账”和图表点击均打开现有 /warehouseManagement/stockLedger 列表;首页不再提供自建台账明细视图,分类点击不预选台账树节点。
+- 呆滞占比按批次数,不按金额;库存周转按数量快照计算并剔除调拨出库。C2 没有核实的移动加权不含税成本源时显示“—”,切勿把入库价估值当成账面金额。
+- 库存总量卡片的「较上周」=本期数量 − 7 天前**日末同刻**快照(后端 `weekOverWeekQuantity`/`weekOverWeekUnit`/`comparisonBaseAt`),仅单一基本单位且两侧快照齐全时才有值;缺基准、跨单位或后端未上线该字段时显示 `comparisonReason`(缺省“暂无上周同时点可比快照”),差值不得以 0 代替。
+- 多基本单位只在卡片内按单位平铺小计(如“个 12,000 · kg 3,400”),**不跨基本单位加总**;单位数超过 3 个折叠为前 3 个并注明其余单位数;`units` 缺省或只有 1 个单位时不渲染小计行。
+- 锁定数量单列展示:单一时显示“其中锁定 X 个”,多单位时逐个单位附锁定数;`lockedQuantity` 为 0 是真实零按“0”显示,为 null 表示口径缺失显示“—”。
+- 文档中 B2 计数指向出库单,但下钻指定 V14 入总库申请,两类单据数据源不同。代码暂按文档保留“待出库单”计数与 V14 跳转;待业务确认后再统一口径。
+
+当前只做 Vue 模板/脚本、MyBatis XML 与差异静态校验;实际数据对账和浏览器布局仍需在有授权数据的环境核验。

+ 1 - 0
src/views/warehouseManagement/inboundRequests/components/tgTable.vue

@@ -7,6 +7,7 @@
       height="calc(100vh-300px)"
       :pageSize="20"
       :datasource="datasource"
+      :emptyText="type === '1' ? '暂无入库申请记录;已生成的入库单请在「入库管理」查看' : '暂无出库申请记录;已生成的出库单请在「出库管理」查看'"
       :selection.sync="selection"
       cache-key="workOrderTable"
     >

+ 4 - 4
src/views/warehouseManagement/index/components/DashboardChart.vue

@@ -1,4 +1,4 @@
-<template><div ref="chart" class="dashboard-chart" role="img" :aria-label="chartLabel"></div></template>
+<template><div ref="chart" class="dashboard-chart" :class="'chart-' + kind" role="img" :aria-label="chartLabel"></div></template>
 <script>
 import * as echarts from 'echarts';
 export default {
@@ -34,13 +34,13 @@ export default {
           { name: '净趋势', type: 'line', smooth: true, symbol: 'none', lineStyle:{ type:'dashed' }, data: this.rows.map((r) => r.net) }
         ] };
       } else if (this.kind === 'category') {
-        option = { color, tooltip: { trigger: 'item' }, legend: { type: 'scroll', orient: 'vertical', right: 0, top: 'center', textStyle: { color: '#9FB8D4', fontSize: 11 } }, series: [{ type: 'pie', radius: ['48%', '72%'], center: ['35%', '50%'], label: { show: false }, itemStyle: { borderColor: '#0A1830', borderWidth: 3 }, data: this.rows.map((r) => ({ name: r.name, value: r.quantity })) }] };
+        option = { color, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { top: 12, right: 55, bottom: 22, left: 85 }, xAxis: { ...axis, type: 'value' }, yAxis: { ...axis, type: 'category', inverse: true, data: this.rows.map((r) => r.name) }, series: [{ name: '在库量', type: 'bar', barMaxWidth: 16, label: { show: true, position: 'right', color: '#9FB8D4', formatter: (p) => (this.rows[p.dataIndex].ratio == null ? '' : this.rows[p.dataIndex].ratio + '%') }, data: this.rows.map((r, i) => ({ value: r.quantity, itemStyle: { color: color[i % color.length], borderRadius: [0, 4, 4, 0] } })) }] };
       } else {
-        option = { color, tooltip: { trigger: 'axis' }, grid: { top: 12, right: 28, bottom: 25, left: 100 }, xAxis: { ...axis, type: 'value' }, yAxis: { ...axis, type: 'category', inverse: true, data: this.rows.map((r) => r.label || r.name) }, series: [{ type: 'bar', barMaxWidth: 18, data: this.rows.map((r, i) => ({ value: r.quantity, itemStyle: { color: color[i % color.length], borderRadius: [0, 4, 4, 0] } })) }] };
+        option = { color, tooltip: { trigger: 'item' }, title: { text: this.rows.reduce((sum, row) => sum + Number(row.quantity || 0), 0).toLocaleString('zh-CN'), subtext: '在库总量', left: 'center', top: '38%', textStyle: { color: '#EAF4FF', fontSize: 20 }, subtextStyle: { color: '#9FB8D4' } }, legend: { bottom: 0, textStyle: { color: '#9FB8D4' } }, series: [{ type: 'pie', radius: ['48%', '69%'], center: ['50%', '45%'], label: { show: false }, itemStyle: { borderColor: '#0A1830', borderWidth: 2 }, data: this.rows.map((r) => ({ name: r.label || r.name, value: r.quantity })) }] };
       }
       this.chart.setOption(option, true);
     }
   }
 };
 </script>
-<style scoped>.dashboard-chart { width:100%; height:220px; }</style>
+<style scoped>.dashboard-chart { width:100%; height:220px; }.chart-trend,.chart-category { height:330px; }</style>

+ 92 - 24
src/views/warehouseManagement/index/components/HomeOverview.vue

@@ -5,9 +5,8 @@
             <h1>仓储管理总览</h1>
             <div class="meta"><span>统计月份:<b>{{ monthLabel }}</b></span><span>服务端查询时间:<b>{{ summary.capturedAt || '尚未更新' }}</b></span></div>
           </div>
-          <div class="greet"><div><h2>{{ greeting }}{{ userName ? ',' + userName : '' }}</h2><p>今天是 {{ todayLabel }} · 数据更新于 {{ updatedAt || '尚未更新' }}(统计每 15 分钟、预警每 5 分钟、动态每 30 秒刷新)</p></div></div>
+          <div class="greet"><div><h2>{{ greeting }}{{ userName ? ',' + userName : '' }}</h2><p>今天是 {{ todayLabel }} · 数据更新于 {{ updatedAt || '尚未更新' }}(统计每 15 分钟、预警每 5 分钟、动态每 30 秒刷新)</p></div><div class="home-filters"><label>仓库 <select :value="warehouseId" @change="$emit('warehouse', $event.target.value)"><option value="">全部授权仓库</option><option v-for="item in options.warehouses || []" :key="item.id" :value="item.id">{{ item.name }}</option></select></label><label>基本单位 <select :value="unit" @change="$emit('unit', $event.target.value)"><option value="">全部单位</option><option v-for="item in options.units || []" :key="item.unit" :value="item.unit">{{ item.unit }}</option></select></label></div></div>
           <div v-if="error" class="data-note bad" role="alert">{{ error }} <button type="button" @click="$emit('refresh')">重试</button></div>
-          <div class="data-note">估值覆盖率 {{ display(summary.valuationCoverage) }}% · 库龄来源覆盖率 {{ display(summary.ageCoverage) }}% · {{ summary.comparisonReason || '日末采集历史待核实' }}。批次估值按可核实入库价计算。</div>
           <div class="todo-bar">
             <div v-for="(item, i) in todoCards" :key="item.key" class="todo-item" role="button" tabindex="0" @click="$emit('open', item.target, item.filters || {})" @keydown.enter="$emit('open', item.target, item.filters || {})">
               <div class="todo-icon" :class="'icon-' + item.icon"><span>{{ item.symbol }}</span></div>
@@ -16,35 +15,48 @@
             </div>
           </div>
           <div class="kpi-row">
-            <div v-for="(item, i) in kpis" :key="item.key" class="card kpi corner" role="button" tabindex="0" @click="$emit('open', item.target, item.filters || {})" @keydown.enter="$emit('open', item.target, item.filters || {})">
-              <span class="idx">{{ ('0' + (i + 1)).slice(-2) }}</span><span class="kpi-go">查看明细 ›</span>
+            <div v-for="item in kpis" :key="item.key" class="card kpi corner" role="button" tabindex="0" @click="$emit('open', item.target, item.filters || {})" @keydown.enter="$emit('open', item.target, item.filters || {})">
+              <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 }">{{ display(item.value) }}<small v-if="item.value != null">{{ item.unit }}</small></div>
-              <div class="delta dim" :title="item.reason">{{ item.reason || '数据待核实' }}</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 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><span class="link-group"><a class="link" href="#" @click.prevent="$emit('open', 'stockManagement')">进入入库管理 ›</a><a class="link" href="#" @click.prevent="$emit('open', 'outgoingManagement')">进入出库管理 ›</a></span></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><div class="sub">按物料分类 TOP8(前 7 + 其他兜底)· 按基本单位</div></div><a class="link" href="#" @click.prevent="$emit('open', 'ledger')">查看台账 ›</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>
+            <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><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">在库批次按库龄分段 · 与呆滞口径联动</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')">全部预警 ›</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">{{ item.time }}</span><span><b>{{ item.title }}</b> · {{ item.description }}</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">在库批次按库龄分段 · 与呆滞口径联动 · 平均库龄 {{ 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>
           </div>
+          <details class="data-note"><summary>统计口径与数据覆盖</summary>估值覆盖率 {{ display(summary.valuationCoverage) }}% · 库龄来源覆盖率 {{ display(summary.ageCoverage) }}% · {{ summary.comparisonReason || '日末采集历史待核实' }}。批次估值按可核实入库价计算。</details>
         </div>
 </template>
 <script>
 import DashboardChart from './DashboardChart.vue';
-import { displayNumber } from '../dashboard-utils';
+import { displayNumber, formatSignedNumber, formatComparisonBase, formatUnitSubtotals, homeLog } from '../dashboard-utils';
 
 export default {
   name: 'WarehouseHomeOverview',
   components: { DashboardChart },
-  props: { summary: { type: Object, default: () => ({}) }, unit: { type: String, default: '' }, updatedAt: { type: String, default: '' }, error: { type: String, default: '' } },
+  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: '' } },
   computed: {
-    trend() { return this.summary.trend || []; },
+    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 };
+      });
+    },
     categories() { return this.summary.categories || []; },
     aging() { return this.summary.aging || []; },
     alerts() { return this.summary.alerts || []; },
@@ -55,34 +67,90 @@ export default {
     alertReason() { return this.summary.alertReason || ''; },
     activityReason() { return this.summary.activityReason || ''; },
 
+    // 库存总量「较上周」:口径为本期数量 − 7 天前日末同刻快照,仅单一基本单位且两侧快照齐全时后端才给值。
+    // weekOverWeekQuantity 为 null/缺失/非数字一律按「不可比」处理,显示比较原因兜底,绝不显示 0。
+    stockWeekOverWeek() {
+      const s = this.summary;
+      const diff = s.weekOverWeekQuantity;
+      const base = formatComparisonBase(s.comparisonBaseAt);
+      const comparable = diff != null && diff !== '' && Number.isFinite(Number(diff));
+      if (!comparable) {
+        const reason = s.comparisonReason || '暂无上周同时点可比快照';
+        homeLog('库存总量周环比不可比', { weekOverWeekQuantity: diff, comparisonBaseAt: s.comparisonBaseAt, comparisonReason: s.comparisonReason });
+        return { text: reason, title: s.comparisonReason || reason };
+      }
+      const unit = String(s.weekOverWeekUnit || this.unit || '').trim();
+      const signed = formatSignedNumber(diff);
+      // 0 只显示「持平」,不再拼数值;基准时点缺失时省略括号。
+      const text = '较上周' + (signed === '持平' ? '持平' : ' ' + signed + (unit ? ' ' + unit : '')) + (base ? '(基准 ' + base + ')' : '');
+      homeLog('库存总量周环比已解析', { weekOverWeekQuantity: diff, weekOverWeekUnit: unit, comparisonBaseAt: s.comparisonBaseAt, text });
+      return { text, title: '本期数量 − 7 天前日末同刻快照' + (base ? '(基准 ' + base + ')' : '') + ';不跨基本单位加总' };
+    },
+    // 多基本单位小计:units 多于 1 个时按「单位 数量(锁定 X)」平铺,不同单位绝不加总;
+    // 超过 3 个单位折叠为前 3 个 + 其余 N 个单位;units 缺省或只有 1 个单位时不渲染小计行。
+    stockUnitSubtotal() {
+      const { items, rest, total } = formatUnitSubtotals(this.summary.units, 3);
+      if (total <= 1) {
+        if (total === 0) homeLog('库存总量无分单位数据,不渲染小计行', { unitsCount: 0 });
+        return null;
+      }
+      const text = items.map((item) => item.label).join(' · ') + (rest > 0 ? ' · 其余 ' + rest + ' 个单位' : '');
+      homeLog('库存总量分单位小计已解析', { unitsCount: total, shownUnits: items.map((item) => item.unit), collapsedUnits: rest, text });
+      return { text, title: items.map((item) => item.title).join(';') + (rest > 0 ? ';另有 ' + rest + ' 个单位未展开' : '') };
+    },
+    // 锁定数量单列:单一基本单位时显示「其中锁定 X 个单位名」;0 是真实零显示「0」,null/缺失显示「—」。
+    // 多基本单位时锁定数随小计行逐单位展示,这里返回 null 避免重复。
+    stockLocked() {
+      const rows = (Array.isArray(this.summary.units) ? this.summary.units : []).filter((row) => row && row.unit != null && String(row.unit).trim() !== '');
+      if (rows.length !== 1) return null;
+      const unit = String(rows[0].unit).trim();
+      const locked = displayNumber(rows[0].lockedQuantity);
+      const text = '其中锁定 ' + locked + (unit ? ' ' + unit : '');
+      homeLog('库存总量锁定数量已解析', { unit, lockedQuantity: rows[0].lockedQuantity, text });
+      return { text, title: unit + ':锁定数量 ' + locked + ';0 为真实零,缺失显示「—」' };
+    },
+
     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 ? '下午好' : '晚上好'; },
     todoCards() {
       return [
-        { key: 'in', icon: 'in', symbol: '↓', label: '待入库单', value: this.summary.pendingInbound, target: 'stockManagement', overdue: this.summary.overdueInbound },
-        { key: 'out', icon: 'out', symbol: '↑', label: '待出库单', value: this.summary.pendingOutbound, target: 'outgoingManagement', overdue: this.summary.overdueOutbound },
-        { key: 'check', icon: 'check', symbol: '✓', label: '盘点任务进行中', value: this.summary.pendingInventory, target: 'todo', overdue: this.summary.overdueInventory },
+        { 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' }
       ];
     },
     kpis() {
       const s = this.summary;
       return [
-        { key: 'stock', label: '库存总量', value: s.inventoryQuantity, unit: this.unit, reason: s.inventoryReason, info: '当前在库数量,含待检及锁定;不同基本单位分别展示', color: '#38C8F5', target: 'stockLedger' },
-        { key: 'value', label: '库存金额', value: s.inventoryValue, unit: '元', reason: s.valuationReason, info: '批次实际计量数量或重量乘可核实入库单价;未定价不作为零金额', color: '#9B7DF7', target: 'stockLedger' },
-        { key: 'turnover', label: '库存周转天数', value: s.turnoverDays, unit: '天', reason: s.turnoverReason, info: '本月截至昨日的平均库存与实际消耗估算,历史不足时无法计算', color: '#3BD48A', target: 'turnover' },
-        { key: 'stagnant', label: '呆滞品占比', value: s.stagnantRate, unit: '%', reason: s.stagnantReason, info: '首次入库及最近实际消耗均超过 180 天的批次估值除以全部库存估值;估值或日期不完整时无法核实', color: '#F0A03A', progress: s.stagnantRate, target: 'dead' },
-        { key: 'location', label: '库位利用率', value: s.locationUtilization, unit: '%', reason: s.locationReason, info: '已占用的有效库位数量除以启用的普通库位数量', color: '#38C8F5', progress: s.locationUtilization, target: 'locations' },
-        { key: 'quality', label: '质检通过率', value: s.qualityPassRate, unit: '%', reason: s.qualityReason, info: '本月已检批次中合格批次占比,让步计分母但不计合格', color: '#3BD48A', progress: s.qualityPassRate, target: 'qc' }
+        { 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' }
       ];
     }
   },
   methods: {
     display: displayNumber,
+    displayKpi(item) {
+      if (item.value == null) return '—';
+      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(row) { if (row.id === 'other' || !row.id && !row.categoryId) return this.$message.info('其他物料为合并分组,请在库存台账选择具体物料'); this.$emit('open', 'ledger', { categoryId: row.id || row.categoryId }); },
+    categorySelect() { this.$emit('open', 'stockLedger'); },
     ageSelect(row) { this.$emit('open', 'age', { age: row.key }); }
   }
 };

+ 0 - 4
src/views/warehouseManagement/index/dashboard-layout.scss

@@ -2,8 +2,6 @@
   width: 100%; max-width: 100%; overflow: auto;
   button:disabled { opacity: .45; cursor: not-allowed; }
   select option { background: #0A1830; color: #EAF4FF; }
-  .greet .quick-actions { display: flex; gap: 10px; }
-  .link-group { display: flex; gap: 12px; }
   .data-note,.empty { padding: 18px; color: var(--t2); font-size: 13px; }
   .data-note.bad,.overdue-row { color: var(--red); }
   .data-note button { color: var(--cyan); background: none; border: 0; cursor: pointer; }
@@ -16,8 +14,6 @@
   .filters label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--t2); }
   .filters select,.filters input { height: 32px; padding: 5px 8px; border: 1px solid var(--panel-line); border-radius: 5px; background: rgba(6,15,31,.7); color: var(--t1); max-width: 190px; }
   .filters input[type=month] { color-scheme: dark; }
-  .loc { border: 1px solid var(--panel-line); cursor: pointer; color: #04121F; }
-  .loc.disabled { background: #4B5D73 !important; color: var(--t2); }
   .dashboard-business .drill-head { margin-bottom: 16px; }
   .receive-progress { display: flex; gap: 16px; align-items: center; padding: 14px 18px; margin: 12px 0; border: 1px solid var(--panel-line); background: var(--panel); }
   .dashboard-business .ele-body { padding: 0; }

+ 4 - 6
src/views/warehouseManagement/index/dashboard-screens.js

@@ -1,17 +1,15 @@
 const columns = (text) => text.split('|').map((part) => { const [key, name] = part.split(':'); return { key, name, numeric: /量|金额|天数|天$|天期|利用率/.test(name) }; });
 export const screens = {
-  ledger: { title: '库存台账', subtitle: '按仓库、单位、物料及批次核对在库状态', definition: '在库量含待检及锁定;可用量扣锁并排除未放行。不同基本单位不相加。估值仅使用可核实的批次入库单价。', metrics: ['inventoryQuantity', 'inventoryValue'], columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|systemBatchNo:系统批次|batchNo:批次号|quantity:在库量|availableQuantity:可用量|lockedQuantity:锁定量|pendingReleaseQuantity:未放行量|unit:单位|ageDays:库龄天数|qualityStatus:质检状态|valuation:批次估值'), action: '批次流水' },
   dead: { title: '呆滞品分析', subtitle: '识别首次入库及最近实际消耗均超过 180 天的在库批次并推动真实处置', definition: '首次日期缺失单列“日期待核实”。处置申请及审批本身不改变库存;执行关联的调拨或出库才变动库存。', metrics: ['stagnantRate'], columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|systemBatchNo:系统批次|quantity:呆滞量|unit:单位|valuation:批次估值|stagnantDays:未消耗天数|ageDays:库龄天数'), action: '发起处置' },
   io: { title: '出入库动态', subtitle: '已完成收发货流水及原始单据', definition: '只统计已完成出入库流水;草稿、待审批及待执行计入待办。不同基本单位分别核对。', period: true, columns: columns('orderNo:单据号|direction:方向|businessType:业务类型|categoryName:物料名称|warehouseName:仓库|quantity:数量|unit:单位|amount:金额|operateTime:作业时间|operatorName:操作员'), action: '查看单据' },
-  todo: { title: '待办单据', subtitle: '本月仓储盘点及未执行单据', definition: '进行中的逾期盘点仍计入待办并标红;计划完成时间缺失时显示“日期待核实”。待我审批按当前用户当前节点查询。', metrics: ['pendingInventory', 'pendingApproval'], columns: columns('orderNo:单据号|type:单据类型|warehouseName:仓库|status:状态|createTime:创建时间|dueTime:计划完成|reason:说明'), action: '查看任务' },
+  todo: { title: '待办单据', subtitle: '本月仓储盘点及未执行单据', definition: '盘点仅计本月创建、执行中且未过计划完成时间的任务。待我审批按当前用户当前节点查询。', metrics: ['pendingInventory', 'pendingApproval'], columns: columns('orderNo:单据号|type:单据类型|warehouseName:仓库|status:状态|createTime:创建时间|dueTime:计划完成|reason:说明'), action: '查看任务' },
   age: { title: '批次库龄明细', subtitle: '真实来源日期与库龄结构', definition: '从首次入库日期起算;调拨、拆合包不重置。缺失来源日期单列“日期待核实”,不按新建时间补造。', metrics: ['ageCoverage'], columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|batchNo:批次号|systemBatchNo:系统批次|firstInboundTime:首次入库|lastActivityTime:最近实际消耗|ageDays:库龄天数|quantity:在库量|unit:单位|valuation:批次估值'), action: '批次流水' },
   alerts: { title: '安全库存预警清单', subtitle: '核对缺料、偏低及超储并发起补货', definition: '可用量扣锁并排除未放行。只按仓库、物料及其基本单位匹配有效阈值;未配置与未知不等于零。', columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|availableQuantity:可用量|minInventory:最低库存|secureInventory:安全库存|maxInventory:最高库存|gapQuantity:缺口数量|unit:单位|status:预警类型|reason:说明'), action: '生成采购申请' },
-  turnover: { title: '库存周转分析', subtitle: '按真实日末采集记录估算本月库存周转', definition: '使用截至昨日的真实日末采集与已完成实际消耗;未采集日期不补造,历史覆盖不足时说明无法计算。', metrics: ['turnoverDays'], columns: columns('categoryName:物料名称|warehouseName:仓库|unit:单位|averageQuantity:平均库存量|outboundQuantity:出库量|turnoverDays:周转天数|recordedDays:已采集天数|expectedDays:所需天数|reason:说明'), action: '查看库存' },
-  locations: { title: '库位利用率分析', subtitle: '有效普通库位占用情况及库位维护', definition: '统计分母仅为已启用普通库位,排除维修、暂存、虚拟及停用库位。仓库未维护库位时显示“未配置库位”。', metrics: ['locationUtilization'], columns: columns('code:库位编码|warehouseName:仓库|areaName:库区|shelfName:货架|status:占用状态|usage:用途|enabled:启用状态'), action: '库位明细' },
+  turnover: { title: '库存周转分析', subtitle: '按月初与期末数量快照估算本月库存周转', definition: '月平均库存=(上月末期初+昨日期末)/2;分母只含已完成的消耗性出库,不含调拨。历史覆盖不足时不补造。', metrics: ['turnoverDays'], columns: columns('categoryName:物料名称|warehouseName:仓库|unit:单位|averageQuantity:平均库存量|outboundQuantity:出库量|turnoverDays:周转天数|recordedDays:已采集天数|expectedDays:所需天数|reason:说明'), action: '查看库存' },
   qc: { title: '入库质检分析', subtitle: '本月质检批次与实际检验结论', definition: '合格率为合格批次除以已检批次;让步接收计入分母不计合格。无需质检与未知状态不冒充合格。', period: true, metrics: ['qualityPassRate', 'qualityCoverage'], columns: columns('categoryName:物料名称|warehouseName:仓库|unit:单位|systemBatchNo:系统批次|qualityStatus:质检状态|qualityResult:检验结论|qualityTime:检验时间|reason:说明'), action: '打开检验工作台' },
   flow: { title: '待我审批', subtitle: '当前用户当前节点的仓储审批任务', definition: '仅查询授权仓库、本月到达当前用户的仓储流程;批量审批逐单返回结果,复杂表单进入原审批页处理。', period: true, columns: columns('processInstance.processTypeName:流程分类|processInstance.name:流程名称|name:任务名称|businessCode:单据编码|businessName:单据名称|businessType:单据类型|processInstance.startUserNickname:发起人|createTime:到达时间'), action: '审批' }
 };
 export const metricLabels = {
-  inventoryQuantity: ['在库量', '', 'inventoryReason'], inventoryValue: ['可核实批次估值', '元', 'valuationReason'], stagnantRate: ['呆滞占比', '%', 'stagnantReason'], turnoverDays: ['本月周转天数', '天', 'turnoverReason'],
-  locationUtilization: ['库位利用率', '%', 'locationReason'], qualityPassRate: ['质检通过率', '%', 'qualityReason'], ageCoverage: ['库龄来源覆盖率', '%', 'stagnantReason'], qualityCoverage: ['质检记录覆盖率', '%', 'qualityReason'], pendingInventory: ['进行中盘点', '单', ''], pendingApproval: ['待我审批', '单', 'approvalReason']
+  stagnantRate: ['呆滞占比', '%', 'stagnantReason'], turnoverDays: ['本月周转天数', '天', 'turnoverReason'],
+  qualityPassRate: ['质检通过率', '%', 'qualityReason'], ageCoverage: ['库龄来源覆盖率', '%', 'stagnantReason'], qualityCoverage: ['质检记录覆盖率', '%', 'qualityReason'], pendingInventory: ['进行中盘点', '单', ''], pendingApproval: ['待我审批', '单', 'approvalReason']
 };

+ 8 - 2
src/views/warehouseManagement/index/dashboard-theme.scss

@@ -73,6 +73,9 @@
 .greet h2{font-size:18px;font-weight:700;color:#fff;}
 .greet p{font-size:12px;color:var(--t3);margin-top:4px;}
 .greet p b{color:var(--cyan);font-weight:600;}
+.home-filters{display:flex;gap:12px;align-items:center;}
+.home-filters label{display:flex;align-items:center;gap:6px;color:var(--t2);font-size:12px;}
+.home-filters select{max-width:180px;padding:6px 9px;background:#0A1830;border:1px solid var(--panel-line);border-radius:5px;color:var(--t1);}
 .btn{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(135deg,#38C8F5,#2E8BE8);color:#04121F;border:none;border-radius:7px;padding:8px 14px;font-size:13px;cursor:pointer;font-weight:600;box-shadow:0 0 12px rgba(56,200,245,.3);}
 .btn:hover{box-shadow:0 0 18px rgba(56,200,245,.6);}
 .btn.ghost{background:rgba(56,200,245,.06);color:var(--t1);border:1px solid var(--panel-line);box-shadow:none;font-weight:500;}
@@ -97,7 +100,7 @@
 .badge-overdue{font-size:11px;color:var(--red);background:rgba(242,94,94,.15);border:1px solid rgba(242,94,94,.5);border-radius:8px;padding:1px 7px;font-weight:400;text-shadow:none;}
 
 /* ========== KPI 卡 ========== */
-.kpi-row{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:14px;}
+.kpi-row{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:14px;}
 .kpi{padding:14px 16px;cursor:pointer;transition:transform .12s, box-shadow .12s, border-color .12s;position:relative;overflow:hidden;}
 .kpi:hover{transform:translateY(-2px);border-color:var(--cyan);box-shadow:0 0 22px rgba(56,200,245,.28), var(--inset);}
 .kpi:hover .kpi-go{opacity:1;}
@@ -136,6 +139,9 @@ table.tbl tr:hover td{background:rgba(56,200,245,.06);}
 .feed-item:last-child{border:none;}
 .feed-item .tm{color:var(--t3);white-space:nowrap;font-size:11.5px;padding-top:1px;}
 .feed-item b{color:#EAF4FF;font-weight:600;}
+.feed-status{margin-left:auto;color:var(--green);white-space:nowrap;}
+.feed-status.active{color:var(--cyan);}
+.feed-status.waiting{color:var(--orange);}
 
 /* ========== 下钻页 ========== */
 .crumb{display:flex;align-items:center;gap:8px;margin:16px 0 14px;color:var(--t3);font-size:13px;}
@@ -178,7 +184,6 @@ table.tbl tr:hover td{background:rgba(56,200,245,.06);}
 .modal-x:hover{color:#EAF4FF;}
 .modal-ft{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px 16px;}
 /* 库位热力图 */
-.loc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(76px,1fr));gap:8px;padding:10px 18px 6px;}
 .loc{height:52px;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;cursor:pointer;border:1px solid rgba(255,255,255,.10);transition:all .15s;color:#04121F;}
 .loc b{font-size:12.5px;font-weight:700;}
 .loc:hover{transform:translateY(-2px);border-color:var(--cyan);box-shadow:0 0 14px rgba(56,200,245,.45);}
@@ -221,6 +226,7 @@ table.tbl tr:hover td{background:rgba(56,200,245,.06);}
   opacity:0;transition:all .25s;pointer-events:none;z-index:99;box-shadow:0 0 26px rgba(56,200,245,.35);}
 .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
 @media (max-width:1400px){
+  .greet{flex-wrap:wrap;gap:12px;}
   .kpi-row{grid-template-columns:repeat(3,1fr);}
   .grid-2-1,.grid-3,.grid-2{grid-template-columns:1fr;}
   .kpi-strip{grid-template-columns:repeat(3,1fr);}

+ 48 - 0
src/views/warehouseManagement/index/dashboard-utils.js

@@ -34,3 +34,51 @@ export function findPortalEntry(roots, predicate) {
   };
   return visit(roots, '', '');
 }
+
+const HOME_LOG_PREFIX = '[wms-home]';
+
+// 首页排查日志:统一前缀 [wms-home],调用方只传关键字段与耗时,禁止直接打印整包响应。
+export function homeLog(message, payload) {
+  if (payload === undefined) console.log(HOME_LOG_PREFIX, message);
+  else console.log(HOME_LOG_PREFIX, message, payload);
+}
+
+// 首页异常/不可比路径日志:与 homeLog 同一前缀,走 warn 便于在控制台快速筛出。
+export function homeWarn(message, payload) {
+  if (payload === undefined) console.warn(HOME_LOG_PREFIX, message);
+  else console.warn(HOME_LOG_PREFIX, message, payload);
+}
+
+// 周环比差值的带符号文案:正数带「+」、负数带「-」,千分位、最多 2 位小数,0 视为「持平」。
+// 空值(null/undefined/空串/非数字)返回「—」,绝不把「没有可比快照」显示成 0。
+export function formatSignedNumber(value) {
+  if (value == null || value === '') return '—';
+  const number = Number(value);
+  if (!Number.isFinite(number)) return '—';
+  if (number === 0) return '持平';
+  return (number > 0 ? '+' : '-') + Math.abs(number).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
+}
+
+// 周环比基准时点:comparisonBaseAt 为「yyyy-MM-dd HH:mm:ss」,卡片只取日期并标注「日末」。
+// 缺失或格式不符返回空串,调用方据此省略「(基准 …)」括号。
+export function formatComparisonBase(value) {
+  const text = value == null ? '' : String(value).trim();
+  const match = text.match(/^(\d{4}-\d{2}-\d{2})(?:[ T]|$)/);
+  return match ? match[1] + ' 日末' : '';
+}
+
+// 多基本单位小计:按单位平铺「单位 数量(锁定 X)」,不同基本单位绝不跨单位加总。
+// 单位数超过 limit 时折叠为前 limit 个,其余个数由 rest 交给调用方提示。
+export function formatUnitSubtotals(units, limit = 3) {
+  const rows = (Array.isArray(units) ? units : []).filter((row) => row && row.unit != null && String(row.unit).trim() !== '');
+  const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 3;
+  const visible = rows.slice(0, max);
+  const items = visible.map((row) => {
+    const unit = String(row.unit).trim();
+    // traceableQuantity 已剔除锁定;displayNumber 会把 0 显示为「0」、null/undefined 显示为「—」。
+    const quantity = displayNumber(row.traceableQuantity);
+    const locked = displayNumber(row.lockedQuantity);
+    return { unit, quantity, locked, label: unit + ' ' + quantity + '(锁定 ' + locked + ')', title: unit + ':在库 ' + quantity + ',其中锁定 ' + locked };
+  });
+  return { items, rest: rows.length - visible.length, total: rows.length };
+}

+ 39 - 23
src/views/warehouseManagement/index/dashboard.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="wms-home">
     <main class="main">
-        <home-overview v-if="view === 'home'" :summary="summary" :unit="unit" :updated-at="updatedAt" :error="error" @open="open" @refresh="refresh" @business="business" />
+        <home-overview v-if="view === 'home'" :summary="summary" :options="options" :warehouse-id="warehouseId" :unit="unit" :updated-at="updatedAt" :error="error" @open="open" @refresh="refresh" @business="business" @warehouse="setWarehouse" @unit="setUnit" />
         <section v-else-if="businessComponent" class="view active dashboard-business"><nav class="crumb"><a href="#" @click.prevent="open('home')">‹ 返回首页</a><span>仓储管理系统</span> / <b>{{ businessTitle }}</b></nav><div class="drill-head"><h2>{{ businessTitle }}</h2></div><div v-if="receiveQueue.length" class="receive-progress">批量收货:正在核对第 {{ preparedReceipts.length + 1 }} / {{ receiveQueue.length }} 单。请核对仓库、产品明细及包装后确认。<el-button @click="cancelReceive">取消批量收货</el-button></div><div v-if="!receiveQueue.length && preparedReceipts.length" class="receive-progress">有 {{ preparedReceipts.length }} 单待重试。<el-button :loading="receiving" @click="submitReceipts">重试收货</el-button></div><component :is="businessComponent" :key="view + ($route.query.inId || '')" :batch-prepare="receiveQueue.length > 0" @batch-receive="startReceive" @prepared="prepareReceipt" @cancelled="cancelReceive" /></section>
         <dashboard-detail v-else :key="view" :view="view" :filters="filters" :options="options" :result="result" :summary="summary" :loading="loading" :error="error" :exporting="exporting" @back="open('home')" @navigate="open" @search="search" @retry="loadDetail" @export="exportRows" @business="business" @batch="batchApprove" />
     </main>
@@ -15,20 +15,14 @@ import DashboardActions from './components/DashboardActions.vue';
 import { homeOptions, homeSummary, homeDetail, warehouseTasks, dashboardAction } from '@/api/warehouseManagement/dashboard';
 import { getResourcesTree } from '@/api/layout';
 import { getCurrentUser } from '@/utils/token-util';
-import { monthRange, cleanQuery, findPortalEntry } from './dashboard-utils';
+import { monthRange, cleanQuery, findPortalEntry, homeLog, homeWarn } from './dashboard-utils';
 import { screens } from './dashboard-screens';
 
 const businessViews = {
   addin: () => import('../stockManagement/add.vue'), addout: () => import('../outgoingManagement/add.vue'),
   ioapp: () => import('../inboundRequests/index.vue'), inapp: () => import('../warehouseApplication/index.vue')
 };
-const legacyRoutes = {
-  ledger: '/warehouseManagement/stockLedger',
-  stockLedger: '/warehouseManagement/stockLedger',
-  stockManagement: '/warehouseManagement/stockManagement',
-  outgoingManagement: '/warehouseManagement/outgoingManagement'
-};
-const filterKeys = ['warehouseId','factoryId','unit','categoryId','startTime','endTime','keyword','type','direction','stockBatchId','allocationId','age','level','inspection','status','page'];
+const filterKeys = ['warehouseId','factoryId','unit','categoryId','startTime','endTime','keyword','type','direction','stockBatchId','age','level','inspection','status','page'];
 export default {
   name: 'WmsHome',
   components: { HomeOverview, DashboardDetail, DashboardActions },
@@ -45,7 +39,6 @@ export default {
     this.readRoute();
     try {
       this.options = await homeOptions({ warehouseId: this.warehouseId }); this.optionsLoaded = true;
-      if (!this.unit && !Object.prototype.hasOwnProperty.call(this.$route.query, 'unit')) this.unit = this.options.defaultUnit || '';
       this.filters = { ...this.filters, unit: this.unit };
       await this.refresh();
     } catch (e) { this.error = e.message || '筛选项加载失败,请刷新重试'; }
@@ -72,9 +65,9 @@ export default {
       this.$router[replace ? 'replace' : 'push']({ path: this.$route.path, query }).catch((e) => { if (e.name !== 'NavigationDuplicated') this.$message.error(e.message); });
     },
     open(view, extra = {}) {
-      if (legacyRoutes[view]) return this.$router.push({ path: legacyRoutes[view], query: cleanQuery({ ...this.filters, ...extra }) });
+      if (view === 'stockLedger') return this.$router.push({ path: '/warehouseManagement/stockLedger' });
       const filters = { ...this.filters, page: 1, ...extra };
-      if (view !== this.view) ['stockBatchId','allocationId','age','level','inspection','type','direction'].forEach((key) => { if (!(key in extra)) delete filters[key]; });
+      if (view !== this.view) ['stockBatchId','age','level','inspection','type','direction'].forEach((key) => { if (!(key in extra)) delete filters[key]; });
       if (view === 'todo' && !filters.type) filters.type = 'inventory';
       if (view === 'ioapp' && !filters.direction) filters.direction = 'in';
       if (['ioapp','inapp'].includes(view)) {
@@ -83,9 +76,13 @@ export default {
       }
       this.route(view, filters);
     },
+    setWarehouse(warehouseId) { this.route('home', { ...this.filters, warehouseId, unit: '', page: 1 }); },
+    setUnit(unit) { this.route('home', { ...this.filters, unit, page: 1 }); },
     search(filters) { this.route(this.view, filters); },
     parameters(extra = {}) { return cleanQuery({ ...this.filters, ...extra }); },
     async refresh() {
+      // 全量刷新入口:路由切换、手动重试、15 分钟定时器都会走到这里,先记录触发上下文。
+      homeLog('refresh 触发整页刷新', { view: this.view, warehouseId: this.warehouseId, unit: this.unit });
       if (!this.active) return;
       this.generation++; this.requests = {}; this.error = ''; this.loading = false;
       if (!this.optionsLoaded) {
@@ -96,11 +93,18 @@ export default {
     },
     async loadSummary() {
       const generation = this.generation; const seq = (this.requests.summary || 0) + 1; this.requests.summary = seq;
+      const params = this.parameters(); const startedAt = Date.now();
+      homeLog('loadSummary 请求开始', { params });
       try {
-        const data = await homeSummary(this.parameters());
-        if (!this.active || generation !== this.generation || seq !== this.requests.summary) return;
+        const data = await homeSummary(params);
+        // 只打印排查所需的关键字段,不打印整包响应,避免控制台被长列表淹没。
+        homeLog('loadSummary 请求成功', { costMs: Date.now() - startedAt, inventoryQuantity: data && data.inventoryQuantity, unitsCount: data && Array.isArray(data.units) ? data.units.length : 0, weekOverWeekQuantity: data && data.weekOverWeekQuantity, comparisonBaseAt: data && data.comparisonBaseAt });
+        if (!this.active || generation !== this.generation || seq !== this.requests.summary) { homeWarn('loadSummary 结果已过期被丢弃', { costMs: Date.now() - startedAt, seq, currentSeq: this.requests.summary, generation, currentGeneration: this.generation }); return; }
         this.summary = data || {}; this.updatedAt = new Date().toLocaleTimeString('zh-CN', { hour12: false });
-      } catch (e) { if (this.active && generation === this.generation) this.error = e.message || '首页数据加载失败,请重试'; }
+      } catch (e) {
+        homeWarn('loadSummary 请求失败', { costMs: Date.now() - startedAt, params, reason: e && e.message });
+        if (this.active && generation === this.generation) this.error = e.message || '首页数据加载失败,请重试';
+      }
     },
     async fetchDetail(filters = this.filters, page = filters.page || 1, limit = 20) {
       if (this.view === 'flow') {
@@ -119,16 +123,28 @@ export default {
     async refreshPart(part) {
       if (!this.active || this.view !== 'home') return;
       const generation = this.generation; const seq = (this.requests[part] || 0) + 1; this.requests[part] = seq;
+      const params = this.parameters({ section: part }); const startedAt = Date.now();
+      homeLog('refreshPart 请求开始', { part, params });
       try {
-        const data = await homeSummary(this.parameters({ section: part }));
+        const data = await homeSummary(params);
+        homeLog('refreshPart 请求成功', { part, costMs: Date.now() - startedAt, rows: data && Array.isArray(data[part]) ? data[part].length : 0 });
         if (!this.active || generation !== this.generation || seq !== this.requests[part]) return;
         this.$set(this.summary, part, data[part] || []);
+        if (part === 'alerts') this.$set(this.summary, 'alertCount', data.alertCount);
         this.$set(this.summary, part === 'alerts' ? 'alertReason' : 'activityReason', data[part === 'alerts' ? 'alertReason' : 'activityReason'] || '');
-      } catch (e) { if (this.active && generation === this.generation) this.$set(this.summary, part === 'alerts' ? 'alertReason' : 'activityReason', e.message || '刷新失败,请手动重试'); }
+      } catch (e) {
+        homeWarn('refreshPart 请求失败', { part, costMs: Date.now() - startedAt, reason: e && e.message });
+        if (this.active && generation === this.generation) this.$set(this.summary, part === 'alerts' ? 'alertReason' : 'activityReason', e.message || '刷新失败,请手动重试');
+      }
     },
     startTimers() {
       if (!this.active || this.timers && this.timers.length) return;
-      this.timers = [setInterval(() => this.refresh(), 15*60*1000),setInterval(() => this.refreshPart('alerts'),5*60*1000),setInterval(() => this.refreshPart('activities'),30*1000)];
+      homeLog('startTimers 启动定时刷新', { pageRefreshMs: 15*60*1000, alertsMs: 5*60*1000, activitiesMs: 30*1000 });
+      this.timers = [
+        setInterval(() => { homeLog('15 分钟整页刷新触发', { at: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }); this.refresh(); }, 15*60*1000),
+        setInterval(() => this.refreshPart('alerts'),5*60*1000),
+        setInterval(() => this.refreshPart('activities'),30*1000)
+      ];
     },
     stop() { this.active = false; this.generation++; (this.timers || []).forEach(clearInterval); this.timers = []; },
     async exportRows() {
@@ -153,16 +169,16 @@ export default {
     business(kind,row) {
       if (['purchase','threshold','disposal','disposal-list','finance'].includes(kind)) return this.$refs.actions.open(kind,row);
       if (kind === 'dead') return this.$refs.actions.open('disposal',row);
-      if (kind === 'alerts') return row.status === 'excess' ? this.open('ledger',{ categoryId:row.categoryId,warehouseId:row.warehouseId }) : this.$refs.actions.open('purchase',row);
+      if (kind === 'alerts') return row.status === 'excess' ? this.open('stockLedger') : this.$refs.actions.open('purchase',row);
       if (kind === 'flow') return this.portal((n) => /bpm\/todo/.test(n.component || '') || /^\/?todo$/.test(n.path || ''), { taskId:row.id,warehouseScope:'true',warehouseId:this.warehouseId,month:(this.filters.startTime || '').slice(0,7),dashboard:'1' });
       if (kind === 'purchase-document') return this.portal((n) => /purchasingManage\/purchaseNeedManage/.test((n.component || '') + (n.path || '')), { requirementId:row.id,dashboard:'1' });
       if (kind === 'qc') return this.portal((n) => /inspectionWork|检验工作台|质检报告/.test((n.component || '') + (n.title || n.name || '')), { stockBatchId:row.stockBatchId || row.id,systemBatchNo:row.systemBatchNo,warehouseId:row.warehouseId,dashboard:'1' });
+      if (kind === 'io' && row.activityType === 'inventory') return this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.orderId });
+      if (kind === 'io' && row.activityType === 'transfer') return this.open('io',{ warehouseId:row.warehouseId,keyword:row.orderNo });
       if (kind === 'io') return row.orderId ? this.navigate(String(row.direction) === '1' ? '/warehouseManagement/stockManagement/details' : '/warehouseManagement/outgoingManagement/details',{ id:row.orderId }) : this.$message.warning('该流水缺少来源单据身份,请先核实');
       if (kind === 'todo') return row.type === 'inventory' ? this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.id }) : this.open(row.type === 'central' ? 'inapp' : 'ioapp',{ direction:row.type === 'out' ? 'out' : 'in',keyword:row.orderNo });
-      if (kind === 'locations') return this.open('ledger',{ allocationId:row.id,warehouseId:row.warehouseId });
-      if (kind === 'location-maintain') return this.navigate('/warehouseManagement/warehouseDefinition');
-      if (kind === 'turnover') return this.open('ledger',{ categoryId:row.categoryId,warehouseId:row.warehouseId,unit:row.unit });
-      if (['ledger','age'].includes(kind)) return this.navigate('/warehouseManagement/stockLedger/batchDetails',{ id:row.categoryId,batchNo:row.batchNo,systemBatchNo:row.systemBatchNo,stockBatchId:row.stockBatchId || row.id,warehouseId:row.warehouseId });
+      if (kind === 'turnover') return this.open('stockLedger');
+      if (['batch','age'].includes(kind)) return this.navigate('/warehouseManagement/stockLedger/batchDetails',{ id:row.categoryId,batchNo:row.batchNo,systemBatchNo:row.systemBatchNo,stockBatchId:row.stockBatchId || row.id,warehouseId:row.warehouseId });
       if (kind === 'disposal-document') return this.navigate(row.documentType === 'ALLOT' ? '/warehouseManagement/inventoryAllocation/details' : '/warehouseManagement/outgoingManagement/details',{ id:row.documentId });
     },
     async startReceive(rows) {

+ 8 - 15
src/views/warehouseManagement/index/detail.vue

@@ -6,23 +6,18 @@
     <div v-if="current.metrics && current.metrics.length" class="kpi-strip">
       <div v-for="key in current.metrics" :key="key" class="card kpi"><div class="lb">{{ metricLabels[key][0] }}</div><div class="val">{{ show(summary[key]) }}<small v-if="summary[key] != null">{{ metricLabels[key][1] || filters.unit }}</small></div><div class="delta dim">{{ summary[metricLabels[key][2]] || '' }}</div></div>
     </div>
-    <div v-if="view === 'locations' && rows.length" class="card corner">
-      <div class="sec-hd"><h3>当前页库位分布</h3><button class="btn-add" @click="$emit('business', 'location-maintain', {})">维护库位</button></div>
-      <div class="loc-grid"><button v-for="row in rows" :key="row.id" class="loc" :class="{ disabled: row.enabled === false || row.enabled === 0 }" :style="{ background: row.occupied ? '#F0A03A' : '#3BD48A' }" @click="$emit('business', 'locations', row)"><b>{{ row.code || '未配置库位' }}</b><span>{{ row.occupied ? '在用' : '空闲' }}</span></button></div>
-      <div class="legend-box">显示当前页 {{ rows.length }} 个真实库位;全部库位可通过分页查看。</div>
-    </div>
     <div class="card corner" v-loading="loading">
       <div v-if="view === 'todo'" class="tabs"><button class="tab" :class="{ on: query.type === 'inventory' }" @click="changeType('inventory')">盘点任务</button><button class="tab" @click="$emit('navigate', 'flow')">待我审批</button></div>
       <div class="filters">
         <label><span>仓库</span><select v-model="query.warehouseId"><option value="">全部授权仓库</option><option v-for="item in options.warehouses || []" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
-        <label v-if="!['todo','flow','locations'].includes(view)"><span>单位</span><select v-model="query.unit"><option value="">按单位分别展示</option><option v-for="item in options.units || []" :key="item.unit" :value="item.unit">{{ item.unit }}</option></select></label>
-        <label v-if="!['flow', 'todo', 'locations'].includes(view)"><span>物料</span><select v-model="query.categoryId"><option value="">全部</option><option v-for="item in options.categories || []" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
+        <label v-if="!['todo','flow'].includes(view)"><span>单位</span><select v-model="query.unit"><option value="">按单位分别展示</option><option v-for="item in options.units || []" :key="item.unit" :value="item.unit">{{ item.unit }}</option></select></label>
+        <label v-if="!['flow', 'todo'].includes(view)"><span>物料</span><select v-model="query.categoryId"><option value="">全部</option><option v-for="item in options.categories || []" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
         <label v-if="current.period && view !== 'flow'"><span>开始日期</span><input v-model="startDate" type="date"></label><label v-if="current.period && view !== 'flow'"><span>结束日期</span><input v-model="endDate" type="date"></label>
         <label v-if="view === 'flow'"><span>任务到达月份</span><input v-model="flowMonth" type="month"></label>
         <label v-if="view === 'io'"><span>方向</span><select v-model="query.direction"><option value="">全部</option><option value="1">入库</option><option value="2">出库</option></select></label>
         <label v-if="view === 'age'"><span>库龄</span><select v-model="query.age"><option value="">全部</option><option value="0-30">0–30 天</option><option value="31-90">31–90 天</option><option value="91-180">91–180 天</option><option value="180+">180 天以上</option><option value="unknown">日期待核实</option></select></label>
         <label v-if="view === 'alerts'"><span>预警类型</span><select v-model="query.level"><option value="">全部</option><option value="critical">缺料</option><option value="warning">偏低</option><option value="excess">超储</option><option value="unknown">未配置</option></select></label>
-        <label v-if="['ledger','qc'].includes(view)"><span>质检</span><select v-model="query.inspection"><option value="">全部</option><option value="passed">合格</option><option value="failed">不合格</option><option value="concession">让步接收</option><option value="pending">待检</option><option value="none">无需质检</option><option value="unknown">待核实</option></select></label>
+        <label v-if="view === 'qc'"><span>质检</span><select v-model="query.inspection"><option value="">全部</option><option value="passed">合格</option><option value="failed">不合格</option><option value="concession">让步接收</option><option value="pending">待检</option><option value="none">无需质检</option><option value="unknown">待核实</option></select></label>
         <label><span>关键词</span><input v-model.trim="query.keyword" placeholder="物料 / 批次 / 单据" @keyup.enter="search"></label>
         <button class="btn" @click="search">查询</button><button class="btn ghost" :disabled="exporting || !result.total" @click="$emit('export')">{{ exporting ? '导出中…' : '↓ 导出 Excel' }}</button>
         <button v-if="view === 'io'" class="btn ghost" @click="$emit('navigate', 'addin')">新增入库单</button><button v-if="view === 'io'" class="btn ghost" @click="$emit('navigate', 'addout')">新增出库单</button>
@@ -32,8 +27,8 @@
       </div>
       <div v-if="error" class="data-note bad" role="alert">{{ error }} <button @click="$emit('retry')">重试</button></div>
       <div class="table-wrap"><table class="tbl"><thead><tr><th v-if="view === 'flow'"><input type="checkbox" :checked="rows.length > 0 && selected.length === rows.length" aria-label="选择当前页全部任务" @change="selectAll($event.target.checked)"></th><th v-for="column in current.columns" :key="column.key" :class="{ 'num-r': column.numeric }">{{ column.name }}</th><th>操作</th></tr></thead><tbody>
-        <tr v-for="(row, index) in rows" :key="row.id || index" :class="{ 'overdue-row': row.overdue }"><td v-if="view === 'flow'"><input v-model="selected" type="checkbox" :value="row" :aria-label="'选择任务' + (row.businessCode || row.name)"></td><td v-for="column in current.columns" :key="column.key" :class="{ 'num-r': column.numeric }">{{ cell(row, column.key) }}<span v-if="column.key === 'dueTime' && row.overdue" class="badge-overdue">逾期</span></td><td class="row-actions"><a class="link" href="#" @click.prevent="$emit('business', view, row)">{{ current.action }}</a><a v-if="view === 'alerts'" class="link" href="#" @click.prevent="$emit('business', 'threshold', row)">配置阈值</a><a v-if="view === 'dead'" class="link" href="#" @click.prevent="$emit('business', 'ledger', row)">批次流水</a></td></tr>
-        <tr v-if="!loading && !rows.length"><td class="empty" :colspan="current.columns.length + (view === 'flow' ? 2 : 1)">{{ error ? '数据加载失败,请重试' : result.reason || (view === 'locations' ? '未配置库位' : '当前筛选无明细数据') }}</td></tr>
+        <tr v-for="(row, index) in rows" :key="row.id || index" :class="{ 'overdue-row': row.overdue }"><td v-if="view === 'flow'"><input v-model="selected" type="checkbox" :value="row" :aria-label="'选择任务' + (row.businessCode || row.name)"></td><td v-for="column in current.columns" :key="column.key" :class="{ 'num-r': column.numeric }">{{ cell(row, column.key) }}<span v-if="column.key === 'dueTime' && row.overdue" class="badge-overdue">逾期</span></td><td class="row-actions"><a class="link" href="#" @click.prevent="$emit('business', view, row)">{{ current.action }}</a><a v-if="view === 'alerts'" class="link" href="#" @click.prevent="$emit('business', 'threshold', row)">配置阈值</a><a v-if="view === 'dead'" class="link" href="#" @click.prevent="$emit('business', 'batch', row)">批次流水</a></td></tr>
+        <tr v-if="!loading && !rows.length"><td class="empty" :colspan="current.columns.length + (view === 'flow' ? 2 : 1)">{{ error ? '数据加载失败,请重试' : result.reason || '当前筛选无明细数据' }}</td></tr>
       </tbody></table></div>
       <div class="pager">共 {{ show(result.total) }} 条 · 每页 20 条 <button class="pg" :disabled="Number(filters.page || 1) <= 1" @click="page(-1)">‹</button><strong>{{ filters.page || 1 }}</strong><button class="pg" :disabled="!result.total || Number(filters.page || 1) * 20 >= result.total" @click="page(1)">›</button></div>
     </div>
@@ -47,7 +42,7 @@ export default {
   props: { view: String, filters: { type: Object, default: () => ({}) }, options: { type: Object, default: () => ({}) }, result: { type: Object, default: () => ({}) }, summary: { type: Object, default: () => ({}) }, error: String, loading: Boolean, exporting: Boolean },
   data() { return { query: {}, selected: [], metricLabels }; },
   computed: {
-    current() { return screens[this.view] || screens.ledger; }, rows() { return this.result.records || this.result.list || []; },
+    current() { return screens[this.view]; }, rows() { return this.result.records || this.result.list || []; },
     flowMonth: { get() { return (this.query.startTime || '').slice(0,7); },set(v) { this.$set(this.query,'startTime',v ? v + '-01 00:00:00' : ''); this.$set(this.query,'endTime',v ? v + '-' + new Date(Number(v.slice(0,4)),Number(v.slice(5,7)),0).getDate() + ' 23:59:59' : ''); } },
     startDate: { get() { return (this.query.startTime || '').slice(0, 10); }, set(v) { this.$set(this.query, 'startTime', v ? v + ' 00:00:00' : ''); } },
     endDate: { get() { return (this.query.endTime || '').slice(0, 10); }, set(v) { this.$set(this.query, 'endTime', v ? v + ' 23:59:59' : ''); } }
@@ -63,12 +58,10 @@ export default {
       const value = key.split('.').reduce((v, k) => v == null ? null : v[k], row);
       if (key === 'direction') return String(value) === '1' ? '入库' : String(value) === '2' ? '出库' : '待核实';
       if (key === 'dueTime' && !value || key === 'firstInboundTime' && !value || key === 'ageDays' && value == null) return '日期待核实';
-      if (key === 'enabled') return value === true || value === 1 ? '启用' : value === false || value === 0 ? '停用' : '待核实';
       if (key === 'qualityStatus') return { 0:'无需质检',1:'待检',2:'已检' }[value] || '待核实';
       if (key === 'qualityResult') return { 1:'合格',2:'不合格',3:'让步接收' }[value] || '待核实';
-      if (key === 'status' && this.view === 'locations') return { 1:'空闲',2:'在用',3:'已满',4:'失效' }[value] || '待核实';
-      const labels = { ordinary:'普通',repair:'维修',temporary:'暂存',virtual:'虚拟',critical: '缺料', warning: '偏低', excess: '超储', passed: '合格', failed: '不合格', concession: '让步接收', pending: '待检', none: '无需质检', unknown: '待核实', in: '待入库', out: '待出库', central: '入总库', inventory: '盘点' };
-      if (['usage','qualityStatus','qualityResult','status','type'].includes(key) && labels[value]) return labels[value];
+      const labels = { critical: '缺料', warning: '偏低', excess: '超储', passed: '合格', failed: '不合格', concession: '让步接收', pending: '待检', none: '无需质检', unknown: '待核实', in: '待入库', out: '待出库', central: '入总库', inventory: '盘点' };
+      if (['qualityStatus','qualityResult','status','type'].includes(key) && labels[value]) return labels[value];
       return typeof value === 'number' ? displayNumber(value) : value == null || value === '' ? '—' : String(value);
     }
   }