Kaynağa Gözat

fix:970 仓储管理看板首先新需求 剩下的统计

mlchen 4 gün önce
ebeveyn
işleme
13d5647f27

+ 203 - 1
docs/仓储首页看板口径.md

@@ -4,11 +4,213 @@
 
 - 首页为四格单行待办、五张 KPI(编号 01、02、03、04、06)、近 30 天出入库柱状与净趋势、分类横条,以及库龄环图、预警 TOP5、四类最近作业动态。
 - 仓库筛选只用已授权仓库;默认显示全部基本单位,但数量混合单位时显示“—”并提示用户选择单位,不默认套用列表第一单位。库存数量、分类、库龄均扣锁。
-- 库存总量、库存金额及库存分类分布的“查看台账”和图表点击均打开现有 /warehouseManagement/stockLedger 列表;首页不再提供自建台账明细视图,分类点击不预选台账树节点。
+- 库存总量、库存金额及库存分类分布的“查看台账”和图表点击均打开现有 /warehouseManagement/stockLedger 列表;首页不再提供自建台账明细视图。分类横条点击会带 `categoryLevelId`(分类层级 id)打开台账并预选左侧树节点,口径见 D2。
 - 呆滞占比按批次数,不按金额;库存周转按数量快照计算并剔除调拨出库。C2 没有核实的移动加权不含税成本源时显示“—”,切勿把入库价估值当成账面金额。
 - 库存总量卡片的「较上周」=本期数量 − 7 天前**日末同刻**快照(后端 `weekOverWeekQuantity`/`weekOverWeekUnit`/`comparisonBaseAt`),仅单一基本单位且两侧快照齐全时才有值;缺基准、跨单位或后端未上线该字段时显示 `comparisonReason`(缺省“暂无上周同时点可比快照”),差值不得以 0 代替。
 - 多基本单位只在卡片内按单位平铺小计(如“个 12,000 · kg 3,400”),**不跨基本单位加总**;单位数超过 3 个折叠为前 3 个并注明其余单位数;`units` 缺省或只有 1 个单位时不渲染小计行。
 - 锁定数量单列展示:单一时显示“其中锁定 X 个”,多单位时逐个单位附锁定数;`lockedQuantity` 为 0 是真实零按“0”显示,为 null 表示口径缺失显示“—”。
 - 文档中 B2 计数指向出库单,但下钻指定 V14 入总库申请,两类单据数据源不同。代码暂按文档保留“待出库单”计数与 V14 跳转;待业务确认后再统一口径。
+- 安全库存预警的三色分级、缺口量/缺口金额、影响生产工单、防重复下单与超储调拨预填口径见 A1。**预警可用量与其它 KPI 不同源(含待检)**,共享片段 `batchRows`/`costRows` 不得改动,理由写在 A1。
 
 当前只做 Vue 模板/脚本、MyBatis XML 与差异静态校验;实际数据对账和浏览器布局仍需在有授权数据的环境核验。
+
+## D1 出入库动态图
+
+- 横轴窗口=**近 30 天滚动窗口(含今日,共 30 天)**,卡片副标必须写明窗口名与起止日期,不得显示成自然月或固定周期。
+- 柱体=数量,Y 轴取基本单位;后端 `/wms/index/home-summary` 的 `trend` 只返回有作业的日期,缺的日期前端补 `null`、**绝不用 0 冒充**。
+- 无作业日=当日入库与出库**都**无数据;只要任一方向有数据(含真实的 0)就不算无作业日。无作业日不画柱,给浅灰 `markArea` 底纹,hover 提示「该日无作业」,净趋势在该日断线。
+- 净趋势=当日前端重算的「入库量 − 出库量」,紫色虚线 `#9B7DF7`,绑右侧第二 Y 轴「单日净差」,与左侧数量轴分开,读数不混用刻度。
+- 连续同号风险:取窗口末尾最近连续同号段(末尾无作业日跳过),`>= 2` 天时提示——连续为正=积压风险(橙)、连续为负=缺货风险(红);0(持平)与中间的无作业日都打断连续段,没有连续段不提示。
+- 点击柱体与无作业日都带该日 `startTime=00:00:00`/`endTime=23:59:59` 打开 V4 出入库动态;无作业日列表为空态,但日期照带。
+- 口径实现集中在 `dashboard-utils.js`:`TREND_WINDOW_DAYS`、`trendWindowDates`、`trendQuantity`、`isIdleTrendDay`、`trendNetOf`、`buildTrendWindow`、`trendStreak`、`trendRisk`,由 `scripts/check-warehouse-dashboard.cjs` 静态断言守护。
+
+## D2 库存分类分布图
+
+- 卡片副标固定写明「按物料分类 TOP8(前 7 + 其他兜底)· 按基本单位」;只做数量口径,不做「数量/金额」切换。
+- 行数据来自后端 `categories`:`{ id, name, quantity, ratio }`。`id` 是物料分类根层级 `t_main_category_level.id`(字符串),可直接作为 V2 台账的 `categoryLevelId`;「其他」聚合行的 `id` 固定为字符串 `'other'`,不是任何真实分类层级。
+- 点击非「其他」条形:`HomeOverview.categorySelect(row)` 发 `open('stockLedger', { categoryLevelId: row.id })`,`dashboard.vue` 的 `open()` 把 `extra` 经 `cleanQuery` 合并进路由 query(path 仍是 `/warehouseManagement/stockLedger`),台账页据此选中左侧树并过滤右侧列表。
+- 点击「其他」:只打开台账、**不带分类**,绝不把聚合兜底行下钻成具体物料。
+- 台账接受 `query.categoryLevelId`:树加载完成后在 `treeList` 中**递归**查找 id 匹配的节点,`$nextTick` 后 `treeRef.setCurrentKey(id)` 并展开祖先;`current` 置为 `{ id, data }` 的形状,同时满足 item-list 的 `current.id`(首屏 datasource)与 `current.data?.id`(reload)两种读取。
+- 降级策略:树里找不到该 id(分类停用、无权限、树接口异常)时**不报错、不白屏**,清空选中回到台账默认的「不过滤」,并打印一条 `[wms-stock-ledger]` 说明日志;台账页日志前缀不得复用首页的 `[wms-home]`。
+- tooltip 口径:分类名 / 在库数量(单位取首页「基本单位」筛选 `props.unit`,为空时不加单位后缀)/ 占比%,并提示「点击进入台账」;占比或数量缺失显示「—」,不以 0 冒充。
+- 断言守护:`scripts/check-warehouse-dashboard.cjs` 覆盖「非 other 行带 `categoryLevelId`」「other 行不带」「`open('stockLedger', extra)` 把 query 传给 router」「台账页含 `categoryLevelId` 处理与递归查找」「tooltip 单位取 `props.unit`」。
+
+## A1 安全库存预警
+
+本节固化安全库存预警改造后的唯一口径。后端实现在 `WmsHomeMapper.xml` 的 `alertRows` 片段;前端只做归一、配色兜底与文案,**不复算任何阈值**。回归守卫见 `scripts/check-warehouse-dashboard.cjs` 的 F1 断言块。
+
+### 触发条件与短路顺序
+
+`alertRows` 对每个「(仓库,物料)」行按下列顺序判定,命中即停:
+
+1. `unknown`(待核实):没有唯一的有效阈值配置(缺精确仓库配置、同仓同物料存在多条、阈值或基本单位不可核实),或可用量为负(锁定量超过余额)。
+2. `critical`(缺料):可用量 < 安全库存 `secure_inventory`。
+3. `warning`(偏低):安全库存 ≤ 可用量 < 安全库存 × K1。
+4. `excess`(超储):可用量 > COALESCE(最大库存, 安全库存 × K2)。
+5. `normal`(正常):其余全部。
+
+- 系数:**K1=偏低系数** `wms.home.alert.warning-ratio`(默认 1.2)、**K2=超储兜底系数** `wms.home.alert.excess-ratio`(默认 3)。两者由 `WmsHomeAlertRatioConfig` 解析后以 Mapper 参数 `#{warningRatio}`/`#{excessRatio}` 下发,非法值回落默认并打 WARN。前端不得内置 1.2/3——写死会让改系数后前后端静默分叉。
+- 最低库存 `min_inventory` **只作展示列,不参与任何触发判定**。用最低库存判缺料是本次废弃的旧口径,不得回退。
+- **超储兜底只在最大库存为空时生效**:配了最大库存时超储线就是最大库存本身,`COALESCE` 的第二项不会被用到;把 K2 当成「最大库存之外再加一层」是错的。
+
+### 可用量口径(与 batchRows 不同源,必须长期并存)
+
+- 预警可用量在 `alertRows` 内一个**独立的 av 子查询**里算:`SUM(stock_count_base) − SUM(COALESCE(lock_count_base,0))`,按(仓库,物料)聚合,批次过滤条件与预警行集一致。**含待检、未放行批次**,不套 `pre_warehousing`/`quality_control` 过滤。
+- 其它 KPI(库存总量、分类分布、库龄、周转)走 `batchRows` 投影的 `availableQuantity`,定义是 `available_count_base − COALESCE(lock_count_base,0)`,会把未放行/待检批次减掉。
+- **为什么两套口径要并存**:预警判的是「这个物料还能不能发料」,锁定量才是真实占用,未放行批次仍在库里、后续会放行,把它当成不存在会漏报缺料;库存/库龄口径回答的是「现在可动用的量」,必须排除未放行。两者目的不同,强行统一只会让一边算错。
+- **护栏:`batchRows` 与 `costRows` 片段不得改动。** `batchRows` 的 `availableQuantity` 被库存总量、分类分布、库龄、周转等四个既有消费者同时读取,`costRows` 是库存金额(C2)的单价定义;为了预警去改这两处,会静默改掉四个 KPI 的数字且没有任何报错。预警需要不同口径时只能新增独立子查询,不能复用或改写共享片段。
+- 单价侧相反:`costUnitPriceRows`(从 `costRows` 抽出)与库存金额共享同一定义,缺价时保持 NULL,不兜底成 0。
+- 断言:F1-(5) 直接读 `WmsHomeMapper.xml`,校验 `batchRows` 的 `available_count_base-COALESCE(b.lock_count_base,0)` 与 `alertRows` 的 `SUM(...)−SUM(...)` 同时存在,且 `alertRows` 内不得出现 `available_count_base`(出现即意味着预警复用了排除未放行的口径)。
+
+### 缺口量与缺口金额
+
+- 缺口量 `gapQuantity = GREATEST(安全库存 − 可用量, 0)`,**只在 critical/warning 行有意义**,其余状态为 0。
+- 缺口金额 `gapAmount = gapQuantity × 移动加权不含税单价`(单价取 `costUnitPriceRows`,与库存金额同源)。
+- **缺价语义**:单价不可用时 `gapAmount` 为 **NULL** 并下发 `gapAmountReason`,前端显示「金额待补价」且打一条去重日志,**绝不用 0 冒充**——0 会被读成「缺口不值钱」,直接误导补货优先级。真实 0 照实显示 `0.00 元`;底层格式化层对缺失给「—」、对 0 给 `0.00`。
+- 行文案统一由 `alertGapText` 生成:`缺口 <数量><单位> / <金额>`,缺价/缺口量缺失分别落到「金额待补价」与「—」。
+
+### 排序与 TOP5
+
+- 预警列表与首页卡片共用同一条 `ORDER BY gapAmount IS NULL, gapAmount DESC, FIELD(status,'critical','warning','excess','normal','unknown'), id`:缺口金额倒序、缺价行(NULL)排最后、再按状态严重度、最后 id 稳定收尾(分页时同额同状态的行不会跳位)。显式写 `gapAmount IS NULL` 是为了让排序意图不依赖 MySQL DESC 的隐式 NULL 规则。
+- 首页卡片只展示前 `ALERT_TOP_LIMIT = 5` 条;超出部分由 `buildAlertRows` 返回 `overflow` 并在控制台 warn 一次,`total` 仍是后端原始条数,右上角「全部 N 项」取 `alertCount`。
+- **前端不重排**:`dashboard-utils` 的比较器只用于核对后端顺序、偏离时 warn,不改变渲染顺序——清单是分页的,页内重排会让「第 2 页第一条」错位。
+
+### 影响生产工单
+
+- 口径:预警物料作为 **BOM 子件**,经 BOM 主表/明细关联到成品,再关联 `t_mes_work_order`;未完工定义固定为 `deleted=0 AND status IN (4 待生产, 5 生产中, 7 已延期)`,已完工(6)/待下达(8)/暂停(9)/终止(10) 都不算影响生产,不得放宽。
+- 汇总条 `alertWorkOrderCount`=全部缺料(critical)行关联工单**跨行去重**后的计数(同一张工单被多个缺料物料影响只算一张);算不出时为 `null` + `alertWorkOrderCountReason`,前端显示「—」并给原因,**绝不当 0**。
+- 行级 `workOrderCount` 用 `CASE WHEN status='critical' THEN ... END`:非缺料行为 **NULL 而不是 0**(0 会被读成「确定没有工单受影响」)。
+- 下钻:点「影响生产工单」按 `level=critical` 打开缺料清单,保证数字与清单同源。
+
+### 已生成申请(防重复下单台账)
+
+- 行级 `purchaseRequestCode`/`purchaseRequestId` 来自 `t_wms_dashboard_command`(`command_type='PURCHASE'`,`command_key = warehouseId:categoryId`),`business_id` 用 `CAST AS CHAR`——大整数不得进 JS number。
+- 列表侧 `detail.vue` 的 `alertActionOf` 优先级:`unknown`→「核对阈值」(**不给补货入口**,阈值不成立时补货只会被后端拒绝);`excess`→「发起调拨」;有 `purchaseRequestCode`→「打开采购需求」,**绝不出现「生成采购申请」**;critical/warning 且无单号→「生成采购申请」;`normal`→不给动作。
+- 弹层侧 `DashboardActions.vue` 的 `purchaseLocked` 三重拦截:数量输入禁用、提交按钮禁用并改文案「已生成申请,禁止重复提交」、`submit` 内再拦一次并打 `[wms-home]` warn;后端 `hasReplenishmentAlert` 与新缺料口径同源,是最后一道。
+
+### 超储调拨预填
+
+- 超储行的闭环是把多的调给缺的:目标仓库=**缺口最大的缺料行**所在仓库(只认 critical/warning,按 `gapAmount` 降序、缺价行最后,再按 `gapQuantity` 降序),目标库区=该仓第一个启用库区,并显式标注「**默认库区,请核对**」。
+- 无缺料行时目标仓库与库区**留空**并给提示,不拿超储行自己的仓库/库区冒充目标。
+
+### 补货与阈值弹层
+
+- 补货默认数量=`positiveQuantity(row.gapQuantity)`:非正数留空,**不得用安全库存或最大库存冒充缺口量**。
+- 阈值弹层:最高库存**非必填**,留空提交 `maxInventory: null`;后端 insert/update 均 `jdbcType=DECIMAL` 无条件写,留空写 NULL(为空即走 ×K2 兜底)。
+
+### 汇总数字口径
+
+- `alertCount` 只含 critical + warning + excess;`alertUnknownCount` 单列且不计入 `alertCount`(卡片显示「待核实 X 项(不计入 N 项)」)。
+- `alertGapAmount` = Σ gapAmount;全缺价时后端给 `null` + `alertGapAmountReason`,前端显示「—」。
+- 前端**不用可见行数自算合计**:自己加总只能得到「当前可见行」的合计,与清单页条数、缺价排除规则立刻分叉。F1-(1) 用「alerts 给 3 行但 alertCount 为 null → 显示「—」而不是 3」守住这条。
+
+### 上线前必须核对的数据库前置项
+
+- `t_main_category_wms.max_inventory` **必须允许 NULL**。本口径依赖「最大库存为空 → 按安全库存 × K2 兜底」;列若是 `NOT NULL`,留空保存会直接失败,超储兜底这条分支永远触发不到。
+- 本会话无数据库连接,**该列的 NULL 属性未能在本环境验证**,上线前必须在目标库核对:
+
+```sql
+SHOW COLUMNS FROM t_main_category_wms LIKE 'max_inventory';
+```
+
+- 判读:`Null` 必须是 `YES`。若为 `NO`,先做列可空迁移——用 `SHOW CREATE TABLE t_main_category_wms` 取现有精度与注释后,再执行形如 `ALTER TABLE t_main_category_wms MODIFY COLUMN max_inventory DECIMAL(24,4) NULL COMMENT '最高库存,为空表示未配置(超储判定按安全库存×K2 兜底)';` 的变更,否则「留空保存」整条不可用。
+- 顺带核对:`secure_inventory`/`min_inventory` 的存在与可空性(阈值缺失会落到 `unknown`);`t_wms_dashboard_command` 上 `(tenant_id, command_type, command_key)` 是否有可用索引(防重复下单的查询走这条路)。
+
+### 断言守护清单
+
+F1 断言块覆盖:statusLabel/color 优先级与三色兜底(normal 不造色)、缺价「金额待补价」而 0→「0.00 元」、TOP5 截断与 `overflow`、状态不复算、汇总条全 null→「—」且不自算合计、`alertActionOf` 五分支(含「有单号绝不出现生成采购申请」「unknown 不给补货入口」)、卡片模板守卫(三色点、影响生产工单、已生成申请、金额待补价)、`screens.alerts` 文案守卫(含待检/偏低系数/超储兜底,且不得再出现「排除未放行」)、后端 SQL 静态守卫(critical 判定行不得引用 minInventory、必须用 secureInventory、K1/K2 走参数、`gapAmount DESC`、batchRows 与 av 子查询两条护栏)。后端仓库不在同级目录时该组打印 SKIP 并说明原因,不判失败。
+
+## E3 最近作业动态
+
+本节固化首页「最近作业动态」卡片的唯一口径。数据由 `/wms/index/home-summary` 的 `activities` 下发,取数在后端 `WmsHomeMapper.xml` 的 `recentActivities`(`WmsHomeMapper.xml:226-248`),前端归一在 `dashboard-utils.js` 的 E3 段(`dashboard-utils.js:597-925`),模板在 `HomeOverview.vue` 的 feed 块(`HomeOverview.vue:118-157`)。
+
+### 聚合范围与数据来源
+
+- 只聚合四类:入库单、出库单(同为 `activityType=io`,按 `direction` 1/2 区分,`dashboard-utils.js:767-773`)、调拨单(`activityType=transfer`)、盘点任务(`activityType=inventory`,取自 `t_wms_plan_order`,`WmsHomeMapper.xml:240-246`)。入/出/调拨行来自 `ioRows` 共享片段(`WmsHomeMapper.xml:177-202`)。后端没给的类型不许在前端补一条。
+- 「旧版五类动态说明不再适用」是本文件开头第 3 行的既有结论(「正文中旧版的六卡、库位地图和五类动态说明不再适用」)。含义有两层:不许按旧口径补第五类(质检、审批之类),也不许把旧五类的取数沿用过来;四类就是当前契约。
+- 调拨的识别写在 SQL 里(`WmsHomeMapper.xml:235-236`:出库方向 `biz_type=2` 或入库方向 `biz_type=15`),前端只认后端给的 `activityType`,不得自己按 `businessType` 再猜一遍——两套判据迟早分叉。
+- 时间窗:盘点分支只取近 30 天(`WmsHomeMapper.xml:246`),入/出/调拨沿用 `ioRows` 的 30 天滚动窗(`WmsHomeMapper.xml:196-197`),刷新时由服务层清空 `startTime`/`endTime`/`unit` 免得被首页筛选改写(`WmsIndexServiceImpl.java:1547-1550`)。
+
+### 已知限制:定义里的部分状态在当前契约下不可达
+
+- `ioRows` 把「已完成」硬编码成主单审核通过(`AND o.verify_status=2`,`WmsHomeMapper.xml:190`)。该片段同时被 D1 趋势(`WmsHomeMapper.xml:221`)、V4 明细 io 页签(`WmsHomeMapper.xml:817`)、E3(`WmsHomeMapper.xml:237`)共用,是「已完成」的**唯一定义点**(`WmsHomeMapper.xml:166-171`)。因此入/出/调拨行恒为「已完成/绿」(`dashboard-utils.js:748-749`),盘点只有 `status` 0/1/2 → 待执行/进行中/已完成(`dashboard-utils.js:740-744`)。
+- **结论:状态定义里的「待质检/待审批/已驳回」以及「超时角标」在当前契约下不可达。** 它们只存在于色表(`dashboard-utils.js:611-618`)与兜底分支(`dashboard-utils.js:751-756`),而兜底只在后端新增了未识别 `activityType` 且同时下发中文状态词时才生效。今天唯一可能被标超时的行是盘点 `status=0`「待执行」(`dashboard-utils.js:829-830`)。
+- 前端已做前向兼容:状态词表 + neutral 中性回落(`dashboard-utils.js:608-619`、`621-626`),后端一旦扩展取数即可直接生效;**但不得为了凑出橙色/红色在前端伪造状态**——伪造的「待审批」会让用户去催一个不存在的审批,比不显示更糟。
+- **未来若要覆盖这些状态,必须新增 E3 专用查询,绝不能改 `ioRows`。** 动共享片段等于静默改掉 D1 的柱子和 V4 明细的数字,且一条报错都不会有;同理调拨剔除只能留在 trend 语句(`WmsHomeMapper.xml:212-213`),不能下沉进片段。
+
+### 四段结构与对应纯函数
+
+- ①时间(`formatActivityTime`,`dashboard-utils.js:711-720`):今日 `今日 HH:mm`(`:717`)、昨日 `昨日`(`:718`)、更早 `MM-DD`(`:719`);时间非法或缺失一律 `—`(`:713`)并排到最后(`:872`),**绝不用「现在」兜底**——那会把一条陈年流水顶到列表最前面。hover 给完整时间(`activityTimeTitle`,`:724-730`)。
+- ②单据类型与单号(`activityDocTypeOf`,`dashboard-utils.js:762-775`;模板 `HomeOverview.vue:142-151`):类型名固定「入库单/出库单/调拨单/盘点任务」,未识别回落「作业单」而不是猜成四类之一(猜错会让用户点进错误的单据类型);单号缺失显示「单号待核实」(`HomeOverview.vue:150`)。单号段单独可点,用 `@click.prevent.stop`(`HomeOverview.vue:148`)——少了 `.stop` 会冒泡到整行的 `@click`(`HomeOverview.vue:138`),一次点击触发两次下钻。
+- ③动作描述(`activityActionTextOf`,`dashboard-utils.js:785-815`):按类型套模板,见下一节。
+- ④状态(`activityStatusOf`,`dashboard-utils.js:737-757`):渲染在 `HomeOverview.vue:153`,超时角标同格追加。
+
+### 动作描述模板的两条硬约束
+
+- **盘点行不得渲染数量。** 后端盘点分支把 `itemCount` 写成占位常量 `1`(`WmsHomeMapper.xml:241`,该行第 4 个投影值),它既不是盘点明细行数也不是盘点数量;当数量渲染会得到「盘点 1 项」这种伪造数字。前端对 `inventory` 直接返回 null(`dashboard-utils.js:691`),盘点只拼「盘点 · 库区(· 进度 xx%)」(`:798-814`)。盘点行的 `description` 是状态词(服务层按 `status` 拼「已完成/执行中/待处理」,`WmsIndexServiceImpl.java:1576-1578`),属于第四段,绝不能当动作文案(`dashboard-utils.js:793-796`),否则一行会读成「单号 → 已完成」。
+- **数量缺失不写「0 项」,进度为 0 或缺失不拼「0%」。** `activityItemCount` 只认有限且 >0 的数(`dashboard-utils.js:688-694`),`activityProgressOf` 对 0、负数、非数字一律返回空串(`:699-707`)。0 会被读成「这一单没有任何明细」「一点都没做」,与「不知道」是两件事;与 C2 缺价、E1 unknown 同一条铁律——不可核实就省略,绝不用 0 冒充。
+
+### 状态着色
+
+- 五档:done 绿 / waiting 橙 / doing 青 / danger 红 / neutral 灰(`dashboard-theme.scss:155-159`,基础色见 `:142-144`)。文案与颜色解耦:颜色只认 `ACTIVITY_STATUS_STYLES`(`dashboard-utils.js:608-619`),改配色不用改文案,模板里也不许判业务状态。
+- neutral 的用途:**未识别状态的兜底**,文案「状态待核实」(`dashboard-utils.js:625-626`)。为什么不能用 done 或 waiting:done 是绿,会被直接读成「已完成」这个业务结论;waiting 是橙,会被读成「在等谁处理」,还会被套上超时角标(`:829-830`),等于替后端凭空造出一个待办。灰只表达「这里有个状态,前端不认识」,与 E1 库龄 unknown、F1 预警「待核实」是同一种视觉语言;样式见 `dashboard-theme.scss:159`。
+- 推导规则(唯一来源,组件与模板不得再内联判断):盘点按 `status` 0/1/2 → 待执行/进行中/已完成(`dashboard-utils.js:740-744`);调拨恒「已执行」done(`:748`);出入库恒「已完成」done(`:749`)。
+- **必须先按 `activityType` 分流再判 `status`。** io/transfer 的 `status` 恒为 `NULL`(`WmsHomeMapper.xml:234`),而 `Number(null) === 0`,先判 status 会把「已完成」显示成「待执行」(`dashboard-utils.js:735-736`)。未识别的类型或状态值只 warn 一次并回落中性灰(`:745-746`、`:750`、`:756`),不得静默当成 done。
+
+### 超时角标
+
+- 只有两条同时成立才标:状态是橙色「待*」(`tone === 'waiting'`)且事件时间早于 now−24h(`buildActivityItem`,`dashboard-utils.js:827-830`);阈值 `ACTIVITY_OVERDUE_HOURS = 24`(`:603`)。
+- 24 小时与待办条同源:待办条的 overdue 就是 `DATE_ADD(a.create_time, INTERVAL 24 HOUR) < NOW()`(`WmsHomeMapper.xml:509`,入总库申请分支同式 `:532`;盘点行固定不标,`:558`)。**不许在 E3 另立 12h/48h**——同一屏上的两个「超时」不能是两个定义。
+- done / doing / danger 不标(`dashboard-utils.js:827`):绿/青/红套「超时」没有业务含义,别给已完成的单据戴超时帽子。
+- 纯函数计算、幂等:由 `operateTime` 与 `now` 现算(`:828-830`),重复求值不会因为「已经标过一次」而漂移;**不得做成「标一次就记住」的有状态标记**。
+
+### 排序、条数与去重
+
+- 去重:同一单据(`orderId`,缺失回落 `id`,两者都没有用行下标兜底)只留事件时间最新一条(`buildActivityFeed`,`dashboard-utils.js:853-867`);同刻不覆盖(`:865-866`)。后端已做 `GROUP BY orderId`(`WmsHomeMapper.xml:238`),但前端不能假设这个口径永远不变。
+- 排序:事件时间倒序;同刻按行 key 倒序(`dashboard-utils.js:868-879`),因此顺序确定、可复现——稳定排序才能让刷新后行不跳位。解析不出时间的行排最后(`:872`)。
+- 条数:默认 5 条(`ACTIVITY_LIMIT = 5`,`dashboard-utils.js:601`;前端截断 `:880`;后端 `LIMIT #{q.limit}` 取同一值,`WmsHomeMapper.xml:247` 与 `WmsIndexServiceImpl.java:1546`)。**双保险的理由**:前端还要按 `orderId` 再去重一次,去重后条数可能变少;后端口径若调整也可能变多,只靠后端 LIMIT 保不住「卡片正好 5 条」这个视觉契约(`:598-600`)。多出来的条数进 `overflow`(`:881`),不静默丢弃。
+- 「全部动态 ›」下钻 V4 出入库动态:`HomeOverview.vue:128` 发 `open('io')`,`dashboard.vue:84-101` 落到 io 屏(`dashboard-screens.js:8`)。整行与单号点击走 `openActivity`(`HomeOverview.vue:701-705`)→ `dashboard.vue:214-216` 按 `activityType` 分流:盘点→盘点工单详情、调拨→V4 带单号、io→入/出库单据详情;缺 `orderId` 时提示核实,不许硬跳到列表首页。
+
+### 刷新与高亮
+
+- 30 秒轮询:`dashboard.vue:170` 的 `setInterval(() => this.refreshPart('activities'), 30*1000)`,只刷 `activities` 这一片,不整页重算(`:164-172`)。
+- 新事件只加 `is-new` 类做 3 秒渐隐(`HomeOverview.vue:135` + `dashboard-theme.scss:162-163`),JS 只负责到点摘类(`HomeOverview.vue:679-682`)。**不移动 DOM、不调用任何滚动 API**:30 秒轮询里主动 `scrollIntoView`/`scrollTop` 会把用户正在看的位置顶走(`HomeOverview.vue:668-669`、`dashboard-theme.scss:160-161`)。
+- 首屏不整屏高亮:`diffActivityIds` 在 prev 为空(首次加载)时返回空数组(`dashboard-utils.js:919`),`HomeOverview.vue:670-673` 因此天然不触发——整屏闪一下等于什么都没强调。
+- 差分只按行 key 比较,绝不按数组引用(`dashboard-utils.js:920-924`):`activities` 是计算属性,每次求值都返回新数组,引用比较会让 30 秒轮询把同一批行全判成新增、整屏反复闪。
+- 连续两批新增重置定时器:`HomeOverview.vue:675` 先 `clearTimeout` 再起新的 3 秒,只保留最后一轮,避免先到的一批提前被摘掉;组件销毁时同一处清理(`:685-688`)。
+
+### 空态
+
+- 无数据时固定文案「今日暂无作业动态」(`HomeOverview.vue:156`),**禁止空白、禁止只画一个空列表**;有 `activityReason` 时追加「 · 原因」(同处),原因由数据侧给,前端不自造。
+- 注意:后端目前不下发 `activityReason`(`WmsHomeMapper.xml:226-248` 无此列,服务层也未拼),该字段实际只在刷新失败时被写入错误信息(`dashboard.vue:158`、`:161`)。所以今天的空态基本就是这一句话,不许为它编造「网络异常」之类的原因。
+
+### 排查日志清单
+
+- `[wms-home] E3 最近作业动态已解析`(`dashboard-utils.js:907`):模块级快照去重(`:642`、`:902-906`),只在「总条数/去重数/四类分布/待*数/超时数」任一变化时打一条;每次都打会把控制台刷满、反而看不见真实变化。
+- `[wms-home] E3 最近作业动态状态未识别`(`:636`):按「类别 + 值」去重,同一句 warn 只打一次(`:631-637`),是发现后端口径漂移的第一信号。
+- `[wms-home] E3 最近作业动态下钻`(`HomeOverview.vue:703`):只记 `id / activityType / orderNo / direction`。
+- `[wms-home] E3 最近作业动态新增事件`(`HomeOverview.vue:677`):只记新增 id 与条数。
+- **禁止打印整行数据或整包响应**(`dashboard-utils.js:641`):日志只放聚合数字与关键字段,不 `console.log(row)`、不打印 `summary`;长列表会淹掉控制台,单号也是敏感数据。日志前缀必须是 `[wms-home]`,其它页面不得复用。
+
+### 断言守护
+
+E3 的关键口径由 `scripts/check-warehouse-dashboard.cjs` 的 E3 断言块守护,写法与同文件里 D1、E1、F1 各块一致(`check-warehouse-dashboard.cjs:554`、`:804`、结尾 F1-(5) 段)。被守护的关键点:
+
+- 时间四段文案、「—」兜底、不得出现「现在」兜底;
+- 盘点不渲染数量、数量缺失不写「0 项」、进度 0 或缺失不拼「0%」;
+- 状态推导先按 `activityType` 分流再判 `status`,`Number(null)===0` 的错位必须锁死;
+- 五档 tone 与 neutral 兜底、未识别不得落成 done;
+- 超时只标 waiting 且超过 24h,done/doing/danger 不标;
+- 去重、事件时间倒序、同刻 key 倒序、5 条截断与 `overflow`;
+- `diffActivityIds` 首屏返回空、按 id 比较不按引用;
+- 模板守卫:`@click.prevent.stop`、`is-new` 类、空态文案;
+- 后端 SQL 静态守卫:`ioRows` 的 `verify_status=2` 唯一门槛、盘点分支 `itemCount` 常量、`LIMIT` 与 24h 口径。
+
+**不写死断言条数**:断言块会随口径增补继续加,条数写进文档立刻过期,以脚本实际执行为准。
+
+### 未验证边界
+
+- 浏览器实际渲染未核验:`.feed-doc` 是 `inline-flex` + `text-overflow:ellipsis`(`dashboard-theme.scss:148`),而 ellipsis 作用于块容器的行内内容,对 flex 容器里的裸文本节点(`{{ item.docType }}`,`HomeOverview.vue:143`)未必生效;超长单号(`.feed-no`,`dashboard-theme.scss:149-150`)在 `overflow:hidden` 下可能被硬切而没有省略号。当前只有静态断言,真实宽度下的表现必须在浏览器里看一眼。
+- 真实 `summary.activities` 数据未对账:本环境无数据库连接,四类分布、`itemCount`、`status` 的实际取值只能靠联调确认,上述推断全部来自 Mapper 与前端代码。
+- 已知次要不一致(防御性路径,当前后端不可达):`activityActionTextOf`(`dashboard-utils.js:785-814`)在 `activityType=io` 且 `direction` 既非 1 也非 2 时把动作词兜底成「入库」,而 `activityDocTypeOf`(`:762-774`)对同一情况兜底成「出入库单」并 warn 一次。`ioRows` 的 `direction` 取自 `t_wms_snapshot.out_type`,正常数据非空,只有历史脏值可能命中;命中时同一行会读成「出入库单 RK… · 入库 N 项」。若以后要覆盖,应让两处共用一个方向判定助手,不要各自兜底。
+- `progressText` 生产环境恒为空:后端 `recentActivities` 不下发进度字段(`WmsHomeMapper.xml:226-248`),前端是防御性读取(`dashboard-utils.js:696-707`)。因此「进度 xx%」今天不会出现,只有后端补了进度列才生效——**不得为了让它显示而在前端算一个进度**。

+ 1 - 1
docs/仓储首页重构实施说明.md

@@ -41,7 +41,7 @@
 ## 数据口径与刷新
 
 - 使用`/wms/index/home-options`、`home-summary`、`home-detail`及`home-alert-config`,不保留原型示例数字。
-- 不同基本单位不相加。库存含待检与锁定;可用量扣锁并排除未放行。金额按可核实入库价估值,缺价不显示为零。
+- 不同基本单位不相加。库存含待检与锁定;库存总量、分类、库龄、周转等 KPI 的可用量扣锁并排除未放行(`batchRows` 的`available_count_base−锁定量`)。**安全库存预警是唯一例外**:预警可用量=在库量−锁定量、含待检与未放行,走`alertRows` 的独立子查询,与上述 KPI 不同源;`batchRows`/`costRows` 两个共享片段因此不得改动,理由见《仓储首页看板口径.md》A1。金额按可核实入库价估值,缺价不显示为零。
 - 呆滞认定要求首次入库及最近实际消耗均超过180天,占比使用呆滞估值与全部库存估值。日期或估值不足时展示原因。
 - `summary.capturedAt`显示为“服务端查询时间”;7日前日末实际采集时间由`comparisonReason`说明,不能将本次接口时间当作日结时间。
 - 总统计15分钟、预警5分钟、动态30秒刷新;后两类通过`section=alerts/activities`更新对应区块。请求序号及生命周期世代阻止旧响应覆盖新筛选;页面停用、销毁后清理定时器。

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1100 - 17
scripts/check-warehouse-dashboard.cjs


+ 168 - 15
src/views/warehouseManagement/index/components/DashboardActions.vue

@@ -18,16 +18,28 @@
             <el-form-item label="基本单位">{{ form.unit || '请选择物料' }}</el-form-item>
           </template>
           <template v-if="kind === 'threshold'">
-            <el-form-item label="最低库存" required><el-input-number v-model="form.minInventory" :min="0" :precision="4" /></el-form-item><el-form-item label="安全库存" required><el-input-number v-model="form.secureInventory" :min="0" :precision="4" /></el-form-item><el-form-item label="最高库存" required><el-input-number v-model="form.maxInventory" :min="0" :precision="4" /></el-form-item>
-            <p class="dashboard-note">最低库存 ≤ 安全库存 ≤ 最高库存;保存后按仓库与物料基本单位生效。</p>
+            <el-form-item label="最低库存" required><el-input-number v-model="form.minInventory" :min="0" :precision="4" /></el-form-item><el-form-item label="安全库存" required><el-input-number v-model="form.secureInventory" :min="0" :precision="4" /></el-form-item><el-form-item label="最高库存"><el-input-number v-model="form.maxInventory" :min="0" :precision="4" placeholder="留空表示未配置" /></el-form-item>
+            <p class="dashboard-note">最低库存 ≤ 安全库存;最高库存可留空表示未配置,留空时按「安全库存 × 超储兜底系数(默认 3,服务端可配)」判定超储。保存后按仓库与物料基本单位生效。</p>
           </template>
           <template v-if="kind === 'purchase'">
+            <p v-if="purchaseLocked" class="dashboard-note dashboard-error">该行已生成采购申请 {{ source.purchaseRequestCode }},为防止重复下单,提交入口已锁定。<el-link type="primary" @click="$emit('business', 'purchase-document', source)">打开采购需求</el-link></p>
+            <p v-if="gapText" class="dashboard-note">{{ gapText }}</p>
             <el-form-item label="需求名称" required><el-input v-model.trim="form.requirementName" maxlength="100" /></el-form-item>
             <el-form-item label="需求部门" required><el-select popper-class="wms-dashboard-popper" v-model="form.requireDeptId" filterable><el-option v-for="item in departments" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
             <el-form-item label="需求类型" required><el-select popper-class="wms-dashboard-popper" v-model="form.sourceType"><el-option v-for="item in sources" :key="item.code" :label="item.label" :value="item.code" /></el-select></el-form-item>
-            <el-form-item label="采购数量" required><el-input-number v-model="form.quantity" :min="0.0001" :precision="4" /></el-form-item><el-form-item label="用途" required><el-input v-model.trim="form.useTo" maxlength="500" /></el-form-item>
+            <el-form-item label="采购数量" required><el-input-number v-model="form.quantity" :min="0.0001" :precision="4" :disabled="purchaseLocked" /><span class="dashboard-note">{{ quantityHint }}</span></el-form-item><el-form-item label="用途" required><el-input v-model.trim="form.useTo" maxlength="500" /></el-form-item>
             <p v-if="savedPurchase" class="dashboard-note">需求 {{ savedPurchase.code }} {{ savedPurchase.reused ? '已存在' : '已保存为草稿' }},请进入采购需求页核对并提交审批。<el-link type="primary" @click="$emit('business', 'purchase-document', savedPurchase)">打开采购需求</el-link></p>
           </template>
+          <template v-if="kind === 'transfer'">
+            <el-form-item label="超储行">{{ (source.warehouseName || '仓库待核实') + ' / ' + (source.categoryName || source.name || '物料待核实') }}</el-form-item>
+            <el-form-item label="可用量">{{ displayNumber(source.availableQuantity) + (source.unit ? ' ' + source.unit : '') }}</el-form-item>
+            <el-form-item label="超储判定">{{ transfer.thresholdText || '—' }}<span class="dashboard-note">(只展示后端口径,前端不重算阈值与系数)</span></el-form-item>
+            <el-form-item label="目标仓库">{{ transfer.targetWarehouseName || '未预填' }}</el-form-item>
+            <el-form-item label="目标库区">{{ transfer.areaName || '未预填' }}<span v-if="transfer.areaName" class="dashboard-default-area">默认库区,请核对</span></el-form-item>
+            <p v-if="transfer.note" class="dashboard-note dashboard-error">{{ transfer.note }}</p>
+            <p class="dashboard-note">调拨沿用库存台账既有的「仓库→库区→货架→货位」弹层;本弹层只把目标仓库与库区带过去,不会替你提交任何调拨单。</p>
+            <p v-if="!transfer.batchCount" class="dashboard-note">当前预警行按(仓库,物料)聚合,没有可调拨的批次身份:调拨弹层内只能先选定目标位置,具体批次请在库存台账勾选后再调拨。</p>
+          </template>
           <template v-if="kind === 'disposal'">
             <el-form-item label="物料 / 批次">{{ source.categoryName }} / {{ source.systemBatchNo || source.batchNo || '日期待核实' }}</el-form-item><el-form-item label="处置数量" required><el-input-number v-model="form.quantity" :min="0.0001" :max="Number(source.availableQuantity || source.quantity)" :precision="4" /> {{ source.unit }}</el-form-item>
             <el-form-item label="处置方式" required><el-radio-group v-model="form.method"><el-radio label="TRANSFER">调拨</el-radio><el-radio label="SCRAP">报废出库</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.method === 'TRANSFER'" label="目标仓库" required><el-select popper-class="wms-dashboard-popper" v-model="form.targetWarehouseId" filterable><el-option v-for="item in options.warehouses || []" :key="item.id" :label="item.name" :value="item.id" :disabled="String(item.id) === String(source.warehouseId)" /></el-select></el-form-item><el-form-item label="处置原因" required><el-input v-model.trim="form.reason" type="textarea" :rows="3" maxlength="1000" /></el-form-item>
@@ -37,8 +49,10 @@
           </template>
         </el-form>
       </div>
-      <span slot="footer"><el-button :disabled="loading" @click="close">关闭</el-button><el-button v-if="!['results','disposal-list'].includes(kind) && !(kind === 'finance' && source.financeStatus === 1) && !savedPurchase" type="primary" :loading="loading" @click="submit">{{ kind === 'purchase' ? '保存采购需求草稿' : kind === 'disposal' ? '保存处置草稿' : '保存' }}</el-button></span>
+      <span slot="footer"><el-button :disabled="loading" @click="close">关闭</el-button><el-button v-if="kind === 'transfer'" type="primary" :loading="loading" @click="openAllotWithPrefill">带入调拨弹层</el-button><el-button v-if="!['results','disposal-list','transfer'].includes(kind) && !(kind === 'finance' && source.financeStatus === 1) && !savedPurchase" type="primary" :disabled="purchaseLocked" :loading="loading" @click="submit">{{ purchaseLocked ? '已生成申请,禁止重复提交' : kind === 'purchase' ? '保存采购需求草稿' : kind === 'disposal' ? '保存处置草稿' : '保存' }}</el-button></span>
     </el-dialog>
+    <!-- 既有调拨弹层(stockLedger/components/allot.vue):与上面弹层同级挂载,避免嵌套进内容区。 -->
+    <allot ref="allotRef" dimension="2" />
   </div>
 </template>
 <script>
@@ -46,17 +60,40 @@ import { dashboardAction, disposalList, disposalDetail, alertConfig, saveAlertCo
 import warehouseDefinition from '@/api/warehouseManagement/warehouseDefinition';
 import { getByCode } from '@/api/system/dictionary-data';
 import FileUpload from '@/components/upload/fileUpload.vue';
+// 调拨沿用库存台账既有的「仓库→库区→货架→货位」弹层(allot.vue 原样复用,不复制不改写),这里只负责预填。
+import allot from '../../stockLedger/components/allot.vue';
+import { displayNumber, homeLog, homeWarn } from '../dashboard-utils';
 
 export default {
-  name: 'DashboardActions', components: { FileUpload },
-  props: { options: { type: Object, default: () => ({}) }, filters: { type: Object, default: () => ({}) } },
-  data() { return { visible:false,kind:'',loading:false,source:{},form:{},savedPurchase:null,departments:[],sources:[],records:[],total:0,page:1,results:[],resultsNote:'',configSequence:0,approvalLabels:{ 0:'草稿',1:'审批中',2:'已通过',3:'已驳回',4:'已撤销' } }; },
-  computed: { title() { return { purchase:'安全库存补货',threshold:'安全库存配置',disposal:'呆滞品处置申请','disposal-list':'处置申请与财务登记',finance:'财务登记',results:'逐单处理结果' }[this.kind]; } },
+  name: 'DashboardActions', components: { FileUpload, allot },
+  // alertRows:预警行全集(alerts 视图用明细列表,首页用 summary.alerts 的 TOP5)。
+  // 超储行发起调拨要按「缺口最大的缺料行」定目标仓库,只看 TOP5 会把第 6 名之后的缺料当成不存在。
+  props: { options: { type: Object, default: () => ({}) }, filters: { type: Object, default: () => ({}) }, alertRows: { type: Array, default: () => [] } },
+  data() { return { visible:false,kind:'',loading:false,source:{},form:{},savedPurchase:null,transfer:{},departments:[],sources:[],records:[],total:0,page:1,results:[],resultsNote:'',configSequence:0,approvalLabels:{ 0:'草稿',1:'审批中',2:'已通过',3:'已驳回',4:'已撤销' } }; },
+  computed: {
+    title() { return { purchase:'安全库存补货',threshold:'安全库存配置',transfer:'发起调拨(超储处理)',disposal:'呆滞品处置申请','disposal-list':'处置申请与财务登记',finance:'财务登记',results:'逐单处理结果' }[this.kind]; },
+    // 已生成采购申请的行(后端台账回传 purchaseRequestCode):提交入口锁定,防止重复下单。
+    // 只认后端字段,不用前端推断(比如「缺口金额很大」)冒充已下单。
+    purchaseLocked() { return !!(this.source && this.source.purchaseRequestCode); },
+    // 缺口金额只照抄后端:gapAmount 为 null 表示缺单价,绝不显示成 0 元,改由 gapAmountReason 说明原因。
+    gapText() {
+      if (this.kind !== 'purchase' || !this.source) return '';
+      const amount = this.source.gapAmount == null ? '缺口金额待核实(' + (this.source.gapAmountReason || '缺少移动加权不含税单价') + ')' : '缺口金额 ' + displayNumber(this.source.gapAmount) + ' 元';
+      return '缺口量 ' + displayNumber(this.source.gapQuantity) + (this.source.unit ? ' ' + this.source.unit : '') + ' · ' + amount;
+    },
+    quantityHint() { return this.purchaseLocked ? '该行已生成采购申请,数量不可再改' : '默认取该行缺口量(安全库存 − 可用量),可按实际需求修改'; }
+  },
   methods: {
     async open(kind, row = {}) {
       if (this.loading) return;
       this.kind = kind; this.source = row; this.savedPurchase = null; this.visible = true; this.configSequence++;
-      this.form = { warehouseId:row.warehouseId || this.filters.warehouseId || '',categoryId:row.categoryId || this.filters.categoryId || '',unit:row.unit || '',quantity:row.gapQuantity > 0 ? row.gapQuantity : undefined,requirementName:'安全库存补货',useTo:'安全库存补货',sourceType:'1',method:'TRANSFER',reason:'',requestKey:'wms-disposal-' + Date.now() + '-' + Math.random().toString(36).slice(2) };
+      // 关键动作日志:开了哪个弹层、带哪些缺口/申请字段,便于和首页其它 [wms-home] 日志串起来定位。
+      homeLog('打开业务弹层', { kind, warehouseId:row.warehouseId, categoryId:row.categoryId, status:row.status, gapQuantity:row.gapQuantity, gapAmount:row.gapAmount, gapAmountReason:row.gapAmountReason, purchaseRequestCode:row.purchaseRequestCode || null });
+      // 补货默认「采购数量」=该行缺口量 gapQuantity(契约第 5 条:GREATEST(安全库存 − 可用量, 0))。
+      // 不用安全库存当需求量(它是阈值不是需求),也不用最大库存(那是超储侧口径)。
+      // 缺口量缺失或非正数时留空让用户填:把「算不出来」显示成 0 或阈值,等于凭空造一个需求量。
+      this.form = { warehouseId:row.warehouseId || this.filters.warehouseId || '',categoryId:row.categoryId || this.filters.categoryId || '',unit:row.unit || '',quantity:this.positiveQuantity(row.gapQuantity),requirementName:'安全库存补货',useTo:'安全库存补货',sourceType:'1',method:'TRANSFER',reason:'',requestKey:'wms-disposal-' + Date.now() + '-' + Math.random().toString(36).slice(2) };
+      homeLog('补货默认数量取自缺口量', { kind, categoryId:row.categoryId, warehouseId:row.warehouseId, gapQuantity:row.gapQuantity, applied:this.form.quantity == null ? null : this.form.quantity, source:'gapQuantity', locked:!!row.purchaseRequestCode });
       try {
         if (kind === 'disposal-list') { this.page = 1; await this.loadDisposals(); }
         if (kind === 'threshold') await this.scopeChanged();
@@ -67,6 +104,7 @@ export default {
           const info = this.$store.state.user.info || {}; const ids = info.deptIds || info.deptId || [];
           this.$set(this.form,'requireDeptId',Array.isArray(ids) ? ids[ids.length - 1] : ids); this.syncUnit();
         }
+        if (kind === 'transfer') await this.prepareTransfer(row);
         if (kind === 'finance') {
           this.loading = true; this.source = await disposalDetail(row.id);
           this.form = { confirmed:this.source.financeStatus === 1,amount:this.source.financeAmount == null ? undefined : Number(this.source.financeAmount),voucherNo:this.source.voucherNo || '',files:this.source.files || [] };
@@ -75,6 +113,99 @@ export default {
       finally { this.loading = false; }
     },
     close(done) { if (this.loading) return; this.visible = false; this.configSequence++; if (typeof done === 'function') done(); },
+    // 模板里格式化数字:空值统一显示「—」,不把缺失显示成 0。
+    displayNumber(value) { return displayNumber(value); },
+    // 阈值数字解析:空值(null/undefined/'')必须当成「未配置」而不是 0。
+    // Number('') 和 Number(null) 都等于 0,直接拿它们换算会静默把「没填」变成「填了 0」,
+    // 后端按 max=0 会把每一行都判成超储。
+    thresholdNumber(value) {
+      if (value === null || value === undefined || value === '') return null;
+      const number = Number(value);
+      return Number.isFinite(number) ? number : null;
+    },
+    // 阈值校验(新口径):最低库存与安全库存必填且非负;最高库存允许留空表示未配置,
+    // 有值时必须合法且 ≥ 安全库存。返回空串表示通过,否则返回给用户的提示。
+    thresholdError(form) {
+      const min = this.thresholdNumber(form.minInventory);
+      const secure = this.thresholdNumber(form.secureInventory);
+      const max = this.thresholdNumber(form.maxInventory);
+      if (min == null || secure == null) return '请填写最低库存与安全库存(最高库存可留空表示未配置)';
+      if (min < 0 || secure < 0) return '库存阈值不能为负数';
+      if (min > secure) return '阈值需满足最低库存 ≤ 安全库存';
+      if (max == null && form.maxInventory !== null && form.maxInventory !== undefined && form.maxInventory !== '') return '最高库存必须是合法数字,或留空表示未配置';
+      if (max != null && max < secure) return '最高库存不能低于安全库存(留空表示未配置)';
+      return '';
+    },
+    // 缺口量只接受有限正数:字符串 '5' 归一成 5,null/''/0/NaN 一律留空(undefined)。
+    // 直接给 0 会被 el-input-number 的 :min 顶成 0.0001 之类的假值,等于替用户编了一个数量。
+    positiveQuantity(value) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : undefined; },
+    // 超储调拨的目标仓库取「缺口最大的缺料行」:只认 critical/warning(缺料/偏低)。
+    // normal 不是问题、excess 是超储本身、unknown 是没配置阈值——拿它们当调拨目标等于凭空指派一个仓库。
+    // 排序与后端一致:gapAmount 降序(缺价行 gapAmount=null 排最后,不能当 0),再按 gapQuantity 降序。
+    pickShortageRow(rows) {
+      const candidates = (rows || []).filter((row) => row && ['critical','warning'].includes(row.status));
+      if (!candidates.length) return null;
+      const amount = (row) => { const number = Number(row.gapAmount); return Number.isFinite(number) ? number : null; };
+      const quantity = (row) => { const number = Number(row.gapQuantity); return Number.isFinite(number) ? number : 0; };
+      return candidates.reduce((best, row) => {
+        if (!best) return row;
+        const rowAmount = amount(row); const bestAmount = amount(best);
+        if (rowAmount !== bestAmount) return rowAmount == null ? best : bestAmount == null ? row : (rowAmount > bestAmount ? row : best);
+        return quantity(row) > quantity(best) ? row : best;
+      }, null);
+    },
+    // 「第一个启用库区」:库区接口(WarehouseAreaVO)当前不下发启停字段(只有货位带 enabled),
+    // 所以按接口返回顺序取第一个未被显式停用的库区;后端将来补上 enabled 后这里自动按它过滤,调用方不用改。
+    firstEnabledArea(areas) { return (Array.isArray(areas) ? areas : []).find((area) => area && ![0, '0', false].includes(area.enabled)) || null; },
+    // 超储行「发起调拨」的预填:目标仓库=缺口最大的缺料行仓库,目标库区=该仓第一个启用库区。
+    // 库里没有任何缺料行时不猜仓库,留空并明确提示(降级路径不许静默失败)。
+    async prepareTransfer(row) {
+      this.transfer = { targetWarehouseId:'', targetWarehouseName:'', areaId:'', areaName:'', thresholdText:'', note:'', batchCount:0 };
+      // 超储阈值只展示不回算:maxInventory 为空时后端按 安全库存×兜底系数 判定,前端重算会和服务端口径打架。
+      const threshold = row.maxInventory != null && row.maxInventory !== '' && Number.isFinite(Number(row.maxInventory)) ? Number(row.maxInventory) : null;
+      this.$set(this.transfer, 'thresholdText', threshold == null ? '最高库存未配置(后端按安全库存×兜底系数判定)' : displayNumber(threshold) + (row.unit ? ' ' + row.unit : ''));
+      const worst = this.pickShortageRow(this.alertRows);
+      if (!worst) {
+        this.$set(this.transfer, 'note', '当前无缺料行,请手动选择目标仓库与库区');
+        homeWarn('超储行发起调拨:无缺料行,目标仓库留空待手动选择', { excessWarehouseId:row.warehouseId, excessCategoryId:row.categoryId, alertRows:(this.alertRows || []).length });
+        return this.$message.warning('当前无缺料行,请手动选择目标仓库与库区');
+      }
+      this.loading = true;
+      try {
+        const area = this.firstEnabledArea(await warehouseDefinition.getListByWarehouseId(worst.warehouseId));
+        // 仓库名优先取明细行,其次取弹层已加载的仓库选项,最后退回仓库 id:只显示「未预填」会让用户怀疑调拨目标是不是空的。
+        const warehouseOption = (this.options.warehouses || []).find((item) => String(item.id) === String(worst.warehouseId));
+        const target = { targetWarehouseId:worst.warehouseId, targetWarehouseName:worst.warehouseName || (warehouseOption && warehouseOption.name) || String(worst.warehouseId), areaId:area ? area.id : '', areaName:area ? area.name : '' };
+        Object.keys(target).forEach((key) => this.$set(this.transfer, key, target[key]));
+        if (!area) this.$set(this.transfer, 'note', '目标仓库下没有可用库区,请在调拨弹层中手动选择库区');
+        homeLog('超储行发起调拨:预填目标仓库与默认库区', { excessWarehouseId:row.warehouseId, excessCategoryId:row.categoryId, excessAvailable:row.availableQuantity, targetWarehouseId:target.targetWarehouseId, targetAreaId:target.areaId, defaultArea:!!area, shortageStatus:worst.status, shortageGapQuantity:worst.gapQuantity, shortageGapAmount:worst.gapAmount });
+      } catch (e) {
+        this.$set(this.transfer, 'note', '目标库区加载失败(' + ((e && e.message) || '未知原因') + '),请在调拨弹层中手动选择');
+        homeWarn('超储行发起调拨:目标库区加载失败', { warehouseId:worst.warehouseId, reason:e && e.message });
+      } finally { this.loading = false; }
+    },
+    // 一键带入:复用 stockLedger/components/allot.vue 的既有调拨弹层。它没有预填 props,open() 还会先清空选择,
+    // 所以先按它自己的流程 open,再把仓库/库区写进去——等价于用户手点,不新造调拨页面、不伪造调拨请求。
+    async openAllotWithPrefill() {
+      const ref = this.$refs.allotRef;
+      if (!ref) { homeWarn('调拨组件未挂载,无法带入预填', {}); return; }
+      const row = this.source || {};
+      // allot.vue 的批次调拨以 outInDetailId 为准;预警行是(仓库,物料)聚合行,通常没有批次身份,
+      // 这时传入空批次列表:弹层只用于选定目标位置,不会替用户提交空明细。
+      const checkList = Array.isArray(row.checkList) && row.checkList.length ? row.checkList : row.outInDetailId ? [row] : [];
+      this.$set(this.transfer, 'batchCount', checkList.length);
+      homeLog('超储行带入调拨弹层', { excessWarehouseId:row.warehouseId, targetWarehouseId:this.transfer.targetWarehouseId, targetAreaId:this.transfer.areaId, batchCount:checkList.length, defaultArea:!!this.transfer.areaName });
+      try {
+        await ref.open(checkList);
+        const warehouse = (ref.warehouseList || []).find((item) => String(item.id) === String(this.transfer.targetWarehouseId));
+        if (!warehouse) { homeWarn('调拨弹层未预填到目标仓库,需手动选择', { targetWarehouseId:this.transfer.targetWarehouseId }); return this.$message.warning('未预填到目标仓库,请在调拨弹层中手动选择仓库与库区'); }
+        ref.warehouse = warehouse.id;
+        await ref.changeWarehouse(warehouse);
+        const area = (ref.areaList || []).find((item) => String(item.id) === String(this.transfer.areaId));
+        if (area) { ref.area = area.id; ref.formData = { ...ref.formData, area }; }
+        if (!checkList.length) homeWarn('调拨弹层未带批次:只能选目标位置,需在库存台账勾选批次后再提交调拨', { targetWarehouseId:this.transfer.targetWarehouseId, targetAreaId:this.transfer.areaId });
+      } catch (e) { homeWarn('带入调拨弹层失败', { reason:e && e.message }); this.$message.error((e && e.message) || '调拨弹层打开失败,请重试'); }
+    },
     syncUnit() { const item = (this.options.categories || []).find((c) => String(c.id) === String(this.form.categoryId)); this.$set(this.form,'unit',item ? item.unit : this.source.unit || ''); return item; },
     async scopeChanged() {
       this.syncUnit(); if (this.kind !== 'threshold') return;
@@ -106,27 +237,49 @@ export default {
     },
     async submit() {
       if (this.loading) return; const f = this.form;
+      // 防重复下单:后端幂等台账会复用既有申请,但前端必须先拦住「已生成申请」的行,
+      // 否则用户会以为还能再开一张,看到「已存在」提示时反而怀疑没生效。
+      if (this.kind === 'purchase' && this.purchaseLocked) { homeWarn('补货提交被拦截:该行已生成采购申请', { warehouseId:f.warehouseId, categoryId:f.categoryId, purchaseRequestCode:this.source.purchaseRequestCode }); return this.$message.warning('该行已生成采购申请 ' + this.source.purchaseRequestCode + ',请直接到采购需求中处理,避免重复下单'); }
       if (['purchase','threshold'].includes(this.kind) && (!f.warehouseId || !f.categoryId || !f.unit)) return this.$message.warning('请选择仓库及具有基本单位的物料');
-      if (this.kind === 'threshold' && ![f.minInventory,f.secureInventory,f.maxInventory].every((v) => typeof v === 'number' && Number.isFinite(v) && v >= 0)) return this.$message.warning('请完整填写非负库存阈值');
-      if (this.kind === 'threshold' && (f.minInventory > f.secureInventory || f.secureInventory > f.maxInventory)) return this.$message.warning('阈值需满足最低库存 ≤ 安全库存 ≤ 最高库存');
-      if (this.kind === 'purchase' && (!f.requirementName || !f.requireDeptId || !f.sourceType || !(f.quantity > 0) || !f.useTo)) return this.$message.warning('请完整填写采购需求');
+      // 最高库存允许留空(未配置),因此不能再要求三项全填:留空走「安全库存 × 超储兜底系数」判定。
+      if (this.kind === 'threshold') { const error = this.thresholdError(f); if (error) return this.$message.warning(error); }
+      // 采购数量必须 >0:默认值是该行缺口量,用户可改,但不能改成 0 或留空。
+      if (this.kind === 'purchase' && !(Number(f.quantity) > 0)) return this.$message.warning('采购数量必须大于 0(默认取该行缺口量,可按实际需求修改)');
+      if (this.kind === 'purchase' && (!f.requirementName || !f.requireDeptId || !f.sourceType || !f.useTo)) return this.$message.warning('请完整填写采购需求');
       if (this.kind === 'disposal' && (!(f.quantity > 0) || !f.reason || (f.method === 'TRANSFER' && !f.targetWarehouseId))) return this.$message.warning('请填写处置数量、方式、原因及目标仓库');
       if (this.kind === 'finance' && (!f.confirmed || !(f.amount >= 0) || !f.voucherNo)) return this.$message.warning('请完成财务确认、金额及凭证号');
       this.loading = true;
       try {
-        if (this.kind === 'threshold') await saveAlertConfig({ warehouseId:f.warehouseId,categoryId:f.categoryId,unit:f.unit,minInventory:f.minInventory,secureInventory:f.secureInventory,maxInventory:f.maxInventory });
+        if (this.kind === 'threshold') {
+          // 留空必须传 null(显式「未配置」),绝不传 0:后端按 max=0 会立刻把每一行判成超储。
+          const minInventory = this.thresholdNumber(f.minInventory); const secureInventory = this.thresholdNumber(f.secureInventory); const maxInventory = this.thresholdNumber(f.maxInventory);
+          homeLog('保存安全库存阈值', { warehouseId:f.warehouseId, categoryId:f.categoryId, unit:f.unit, minInventory, secureInventory, maxInventory, maxProvided:maxInventory != null });
+          // 留空提交依赖后端去掉 maxInventory 的必填校验(WmsHomeAlertConfigPO.maxInventory 的 @NotNull 与 saveHomeAlertConfig 的 null 判断);
+          // 后端未放开时这里会被拒绝,日志前缀与原因能直接定位到该约束,而不是让用户以为「保存成功但没生效」。
+          if (maxInventory == null) homeWarn('最高库存留空提交:后端需允许 maxInventory 为空(null 表示未配置)', { warehouseId:f.warehouseId, categoryId:f.categoryId, minInventory, secureInventory });
+          await saveAlertConfig({ warehouseId:f.warehouseId,categoryId:f.categoryId,unit:f.unit,minInventory,secureInventory,maxInventory });
+        }
         if (this.kind === 'purchase') {
           const category = this.syncUnit(); const dept = this.departments.find((d) => String(d.id) === String(f.requireDeptId));
           this.savedPurchase = await dashboardAction('purchase',{ warehouseId:f.warehouseId,categoryId:f.categoryId,requirement:{ requirementName:f.requirementName,sourceType:f.sourceType,requireDeptId:f.requireDeptId,requireDeptName:dept && dept.name,useTo:f.useTo,detailList:[{ productId:f.categoryId,productCode:category && category.code || this.source.categoryCode,productName:category && category.name || this.source.categoryName,totalCount:f.quantity,measuringUnit:f.unit }] } });
+          // 幂等台账复用结果必须落日志:reused=true 表示后端复用了既有申请单,不能对用户说「又生成了一张」。
+          if (this.savedPurchase && this.savedPurchase.reused) homeWarn('补货请求被既有采购申请复用(未重复下单)', { code:this.savedPurchase.code, reused:true, warehouseId:f.warehouseId, categoryId:f.categoryId, quantity:f.quantity, gapQuantity:this.source.gapQuantity });
+          else homeLog('补货请求已生成采购需求草稿', { code:this.savedPurchase && this.savedPurchase.code, reused:false, warehouseId:f.warehouseId, categoryId:f.categoryId, quantity:f.quantity });
+          // 把后端回传的申请单号写回行上:弹层立刻锁定提交入口,不必等下次刷新。
+          if (this.savedPurchase && this.savedPurchase.code && this.source) this.$set(this.source, 'purchaseRequestCode', this.savedPurchase.code);
         }
         if (this.kind === 'disposal') await dashboardAction('disposal',{ requestKey:f.requestKey,stockBatchId:this.source.stockBatchId || this.source.id,quantity:f.quantity,method:f.method,targetWarehouseId:f.method === 'TRANSFER' ? f.targetWarehouseId : undefined,reason:f.reason });
         if (this.kind === 'finance') await dashboardAction('disposal/' + this.source.id + '/finance',f);
-        this.$message.success(this.kind === 'disposal' ? '处置草稿已保存,请在处置申请中提交审批' : this.kind === 'purchase' ? '采购需求已保存,请核对并提交审批' : '保存成功');
+        this.$message.success(this.kind === 'disposal' ? '处置草稿已保存,请在处置申请中提交审批' : this.kind === 'purchase' ? (this.savedPurchase && this.savedPurchase.reused ? '该仓库与物料已有采购申请,已复用既有单据,未重复下单' : '采购需求已保存,请核对并提交审批') : '保存成功');
         if (this.kind !== 'purchase') this.visible = false; this.$emit('success');
-      } catch (e) { this.$message.error(e.message || '保存失败,请重试'); }
+      } catch (e) { if (this.kind === 'purchase') homeWarn('补货提交失败', { reason:e && e.message, reused:null }); this.$message.error(e.message || '保存失败,请重试'); }
       finally { this.loading = false; }
     },
     showResults(results, note = '') { this.results = results || []; this.resultsNote = note; this.kind = 'results'; this.visible = true; }
   }
 };
 </script>
+<style scoped>
+/* 「默认库区,请核对」:库区是按仓库自动取第一个得来,必须显眼到用户不敢直接用默认值提交。 */
+.dashboard-default-area { margin-left: 8px; padding: 1px 8px; border-radius: 2px; font-size: 12px; color: #F0A03A; background: rgba(240, 160, 58, .16); border: 1px solid rgba(240, 160, 58, .5); }
+</style>

+ 148 - 10
src/views/warehouseManagement/index/components/DashboardChart.vue

@@ -1,14 +1,18 @@
 <template><div ref="chart" class="dashboard-chart" :class="'chart-' + kind" role="img" :aria-label="chartLabel"></div></template>
 <script>
 import * as echarts from 'echarts';
+import { isIdleTrendDay, trendQuantity, agingColorOf, formatAgingRatio, isAgingUnknown } from '../dashboard-utils';
 export default {
   name: 'WarehouseDashboardChart',
   props: { kind: String, rows: { type: Array, default: () => [] }, unit: { type: String, default: '' } },
-  computed: { chartLabel() { return { trend: '近三十天出入库趋势', category: '库存分类分布', aging: '批次库龄结构' }[this.kind]; } },
+  computed: { chartLabel() { return { trend: '近 30 天滚动窗口出入库趋势', category: '库存分类分布', aging: '批次库龄结构' }[this.kind]; } },
   watch: { rows: { deep: true, handler() { this.render(); } }, unit() { this.render(); } },
   mounted() {
     this.chart = echarts.init(this.$refs.chart);
-    this.chart.on('click', (event) => { if (this.rows[event.dataIndex]) this.$emit('select', this.rows[event.dataIndex]); });
+    // 分类/库龄图按数据项点击下钻;趋势图改走 zr 点击(见 bindTrendClick):
+    // 无作业日没有柱体、拿不到 dataIndex,却同样要按该日下钻。
+    this.bindItemClick();
+    this.bindTrendClick();
     this.resize = () => this.chart && this.chart.resize();
     window.addEventListener('resize', this.resize);
     if (window.ResizeObserver) { this.observer = new ResizeObserver(this.resize); this.observer.observe(this.$refs.chart); }
@@ -22,25 +26,159 @@ export default {
     this.chart = null;
   },
   methods: {
+    // 分类/库龄图的数据项点击:dataIndex 与 this.rows 一一对应(库龄 5 行原样渲染、不排序不丢弃),
+    // 所以被点的行连同它的 key 能原样交给下钻方;趋势图不在这一支(见 bindTrendClick)。
+    bindItemClick() {
+      this.chart.on('click', (event) => {
+        if (this.kind === 'trend') return;
+        const row = this.rows[event.dataIndex];
+        if (row) this.$emit('select', row);
+      });
+    },
+    // 趋势图点击:zr 层像素 → 反查类别下标,无作业日(无柱体)也能命中该日。
+    // 只认网格内点击,点图例/轴名不会误跳。
+    bindTrendClick() {
+      const zr = this.chart && this.chart.getZr ? this.chart.getZr() : null;
+      if (!zr || !zr.on) return;
+      zr.on('click', (event) => {
+        if (this.kind !== 'trend' || !this.chart) return;
+        const point = [event.offsetX, event.offsetY];
+        if (this.chart.containPixel && !this.chart.containPixel({ gridIndex: 0 }, point)) return;
+        const raw = this.chart.convertFromPixel({ gridIndex: 0 }, point);
+        const index = Math.round(Array.isArray(raw) ? Number(raw[0]) : Number(raw));
+        if (!Number.isFinite(index)) return;
+        const row = this.rows[index];
+        if (row) this.$emit('select', row);
+      });
+    },
+    // D1 出入库动态 option:左轴数量(基本单位)、右轴单日净差、无作业日浅灰底纹、净趋势紫色虚线。
+    trendOption(axis) {
+      const rows = this.rows;
+      const unitSuffix = this.unit ? '(' + this.unit + ')' : '';
+      // 无作业日(当日入库与出库都无数据):不画柱,只给整天浅灰底纹。
+      // markArea 用「类别下标 ±0.5」覆盖整天,不依赖柱体是否存在。
+      const idleAreas = rows.reduce((areas, row, index) => {
+        if (isIdleTrendDay(row)) areas.push([{ xAxis: index - 0.5 }, { xAxis: index + 0.5 }]);
+        return areas;
+      }, []);
+      // 缺数据一律给 null:ECharts 对 null 不画柱、并让净趋势线断开,绝不用 0 顶替。
+      const inboundOf = (row) => (isIdleTrendDay(row) ? null : trendQuantity(row.inbound));
+      const outboundOf = (row) => (isIdleTrendDay(row) ? null : trendQuantity(row.outbound));
+      const netOf = (row) => (isIdleTrendDay(row) ? null : trendQuantity(row.net));
+      // 无作业占位:轴触发 tooltip 会丢掉该 dataIndex 上取值为 null 的系列
+      // (echarts axisPointer/axisTrigger.js 的 buildPayloadsBySeries 对 null/NaN 直接 return),
+      // 空日三个系列全为 null 时 formatter 只能收到空数组,「该日无作业」就永远出不来。
+      // 给空日一个 0 占位(绑左轴,数量轴基线本就是 0,不改变刻度),有作业日必须给 null,
+      // 否则正常日的 tooltip 会多出一条占位行。
+      const placeholderOf = (row) => (isIdleTrendDay(row) ? 0 : null);
+      const signed = (value) => (value == null ? '—' : (value > 0 ? '+' : '') + value.toLocaleString('zh-CN'));
+      const tooltipFormat = (params) => {
+        const list = Array.isArray(params) ? params : [params];
+        const first = list.filter((item) => item && item.dataIndex != null)[0] || null;
+        // 无作业日的系列值全是 null,ECharts 可能不给 dataIndex;这时用轴上的类别值(日期)反查,
+        // 保证空日 hover 一定出得来「该日无作业」,而不是弹一个空框。
+        const index = first ? first.dataIndex : rows.findIndex((row) => row.date === (list[0] && list[0].axisValue));
+        const row = rows[index];
+        if (!row) return '';
+        // 无作业日只给一句「该日无作业」,不显示 0,也不显示空的入库/出库行。
+        if (isIdleTrendDay(row)) return row.date + '<br/>该日无作业';
+        return [row.date,
+          '入库量:' + (inboundOf(row) == null ? '—' : inboundOf(row).toLocaleString('zh-CN')) + unitSuffix,
+          '出库量:' + (outboundOf(row) == null ? '—' : outboundOf(row).toLocaleString('zh-CN')) + unitSuffix,
+          '单日净差:' + signed(netOf(row)) + unitSuffix].join('<br/>');
+      };
+      return {
+        color: ['#38C8F5', '#F0A03A', '#9B7DF7'],
+        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: tooltipFormat },
+        // 右侧留出净差轴名位置,别让「单日净差」压在图上。
+        grid: { top: 34, right: 62, bottom: 28, left: 62 },
+        xAxis: { ...axis, type: 'category', data: rows.map((row) => row.date) },
+        yAxis: [
+          { ...axis, type: 'value', name: '数量' + unitSuffix, nameTextStyle: { color: '#9FB8D4' } },
+          { ...axis, type: 'value', name: '单日净差' + unitSuffix, position: 'right', nameTextStyle: { color: '#9B7DF7' }, splitLine: { show: false }, axisLabel: { ...axis.axisLabel, formatter: (value) => (value > 0 ? '+' : '') + value } }
+        ],
+        series: [
+          { name: '入库量', type: 'bar', yAxisIndex: 0, barMaxWidth: 10, data: rows.map(inboundOf), itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#38C8F5' }, { offset: 1, color: 'rgba(46,139,232,.25)' }]) }, markArea: { silent: true, itemStyle: { color: 'rgba(159,184,212,.12)' }, label: { show: false }, data: idleAreas } },
+          { name: '出库量', type: 'bar', yAxisIndex: 0, barMaxWidth: 10, itemStyle: { color: '#F0A03A' }, data: rows.map(outboundOf) },
+          // 净趋势:紫色虚线 #9B7DF7,绑右轴「单日净差」,无作业日断线不留 0 点。
+          { name: '净趋势', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'none', connectNulls: false, itemStyle: { color: '#9B7DF7' }, lineStyle: { type: 'dashed', width: 2, color: '#9B7DF7' }, data: rows.map(netOf) },
+          // 无作业占位系列:只为让空日在轴触发 tooltip 里有可命中的 dataIndex。
+          // silent + opacity 0 = 看不见、也不拦截 zr 点击下钻;提示文案由 formatter 的空日分支接管,
+          // 占位值 0 绝不会出现在 tooltip 里。
+          { name: '无作业占位', type: 'bar', yAxisIndex: 0, silent: true, barMaxWidth: 10, itemStyle: { opacity: 0 }, data: rows.map(placeholderOf) }
+        ]
+      };
+    },
+    // E1 库龄结构:环形图按分段展示在库数量。分段与 label 全部取后端 summary.aging(含「>180天」),
+    // 占比只展示后端 ratio,前端不复算;颜色上 >180 段固定红 #F25E5E(与呆滞 alert 同一色阶),
+    // unknown 段用中性灰并明确「不参与四段占比」,避免把「日期待核实」读成第五段库龄。
+    agingOption() {
+      const rows = this.rows;
+      const unitSuffix = this.unit ? ' ' + this.unit : '';
+      const numberText = (value) => {
+        const number = Number(value);
+        return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '—';
+      };
+      const nameOf = (row) => row.label || row.name || String(row.key || '');
+      const agingTooltip = (params) => {
+        const row = rows[params.dataIndex];
+        if (!row) return '';
+        const unknown = isAgingUnknown(row);
+        return [
+          nameOf(row),
+          '在库数量:' + numberText(row.quantity) + unitSuffix,
+          unknown ? '占比:不参与 0-30 / 31-90 / 91-180 / >180 四段占比' : '占比:' + formatAgingRatio(row.ratio),
+          unknown ? '该段批次日期待核实,不计入四段占比合计' : '点击查看该段明细'
+        ].join('<br/>');
+      };
+      return {
+        tooltip: { trigger: 'item', formatter: agingTooltip },
+        // 图例用后端 label(含「>180天」),不在这里改写分段名。
+        legend: { bottom: 0, textStyle: { color: '#9FB8D4' }, data: rows.map(nameOf) },
+        title: { text: numberText(rows.reduce((sum, row) => sum + Number(row.quantity || 0), 0)), subtext: '在库总量', left: 'center', top: '38%', textStyle: { color: '#EAF4FF', fontSize: 20 }, subtextStyle: { color: '#9FB8D4' } },
+        series: [{
+          type: 'pie',
+          radius: ['48%', '69%'],
+          center: ['50%', '45%'],
+          label: { show: false },
+          itemStyle: { borderColor: '#0A1830', borderWidth: 2 },
+          // 颜色按分段 key 取:known 段顺序色、>180 段固定红、unknown 段中性灰。
+          // 点击下钻沿用 mounted 里的 chart.on('click'):dataIndex 与 this.rows 一一对应,行上的 key 原样带出。
+          data: rows.map((row) => ({ name: nameOf(row), value: row.quantity, itemStyle: { color: agingColorOf(row) } }))
+        }]
+      };
+    },
     render() {
       if (!this.chart) return;
       const color = ['#38C8F5', '#F0A03A', '#9B7DF7', '#3BD48A', '#F25E5E', '#7E93AD', '#2E8BE8', '#D9C96C'];
       const axis = { axisLine: { lineStyle: { color: '#345573' } }, axisLabel: { color: '#9FB8D4', fontSize: 11 }, splitLine: { lineStyle: { color: 'rgba(80,150,220,.12)' } } };
       let option;
       if (this.kind === 'trend') {
-        option = { color, tooltip: { trigger: 'axis' }, grid: { top: 22, right: 20, bottom: 28, left: 55 }, xAxis: { ...axis, type: 'category', data: this.rows.map((r) => r.date) }, yAxis: { ...axis, type: 'value', name: this.unit, nameTextStyle: { color: '#9FB8D4' } }, series: [
-          { name: '入库量', type: 'bar', data: this.rows.map((r) => r.inbound), barMaxWidth: 10, itemStyle:{ color:new echarts.graphic.LinearGradient(0,0,0,1,[{ offset:0,color:'#38C8F5' },{ offset:1,color:'rgba(46,139,232,.25)' }]) } },
-          { name: '出库量', type: 'bar', data: this.rows.map((r) => r.outbound), barMaxWidth: 10 },
-          { name: '净趋势', type: 'line', smooth: true, symbol: 'none', lineStyle:{ type:'dashed' }, data: this.rows.map((r) => r.net) }
-        ] };
+        option = this.trendOption(axis);
       } else if (this.kind === 'category') {
-        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] } })) }] };
+        // D2 分类分布 tooltip:分类名 / 在库数量(带基本单位)+ 占比 + 下钻提示。
+        // 单位取 props.unit(首页「基本单位」筛选):为空时只给数量、不硬塞一个单位后缀,
+        // 与趋势图的 unitSuffix 处理一致;占比与数量缺失时给「—」,绝不用 0 冒充
+        // (0% 会被读成「这个分类真的没有份额」,与「后端没给」是两件事)。
+        const unitSuffix = this.unit ? ' ' + this.unit : '';
+        const numberText = (value) => {
+          const number = Number(value);
+          return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '—';
+        };
+        const categoryTooltip = (params) => {
+          const item = Array.isArray(params) ? params[0] : params;
+          const row = item ? this.rows[item.dataIndex] : null;
+          if (!row) return '';
+          const ratio = row.ratio == null || row.ratio === '' ? '—' : String(row.ratio) + '%';
+          return [row.name, '在库数量:' + numberText(row.quantity) + unitSuffix, '占比:' + ratio, '点击进入台账'].join('<br/>');
+        };
+        option = { color, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: categoryTooltip }, 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: '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 })) }] };
+        option = this.agingOption();
       }
       this.chart.setOption(option, true);
     }
   }
 };
 </script>
-<style scoped>.dashboard-chart { width:100%; height:220px; }.chart-trend,.chart-category { height:330px; }</style>
+<style scoped>.dashboard-chart { width:100%; height:220px; }.chart-trend,.chart-category { height:330px; }/* 库龄环形图要放下图例与中心总量,比其他卡片略高 */.chart-aging { height:260px; }</style>

+ 681 - 37
src/views/warehouseManagement/index/components/HomeOverview.vue

@@ -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>

+ 31 - 9
src/views/warehouseManagement/index/dashboard-screens.js

@@ -1,15 +1,37 @@
-const columns = (text) => text.split('|').map((part) => { const [key, name] = part.split(':'); return { key, name, numeric: /量|金额|天数|天$|天期|利用率/.test(name) }; });
+// 列定义:'key:列名|…' 逐字对应后端行字段。numeric 只决定右对齐,不参与任何口径计算。
+// 「数$」用于「影响工单数」这类计数字段:已有列里另一处以「数」结尾的是「…天数」,本来就算数字列,判定不变。
+const columns = (text) => text.split('|').map((part) => { const [key, name] = part.split(':'); return { key, name, numeric: /量|金额|天数|天$|天期|利用率|数$/.test(name) }; });
 export const screens = {
-  dead: { title: '呆滞品分析', subtitle: '识别首次入库及最近实际消耗均超过 180 天的在库批次并推动真实处置', definition: '首次日期缺失单列“日期待核实”。处置申请及审批本身不改变库存;执行关联的调拨或出库才变动库存。', metrics: ['stagnantRate'], columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|systemBatchNo:系统批次|quantity:呆滞量|unit:单位|valuation:批次估值|stagnantDays:未消耗天数|ageDays:库龄天数'), action: '发起处置' },
+  // {stagnantDays} 是会替换的占位符:阈值来自 t_wms_warehouse.stagnant_days,与首页呆滞卡片同一配置项,
+  // 渲染时由 detail.vue 用 home-summary 的 stagnantDays 替换(缺失回落 180),这里不写死天数。
+  dead: { title: '呆滞品分析', subtitle: '识别库龄超过 {stagnantDays} 天且期间无出库/领用的在库批次并推动真实处置', definition: '判定口径与首页「呆滞品占比」共用仓库配置的同一库龄阈值({stagnantDays} 天):期间无任何出库/领用动态才算呆滞,分母只含有可核实来源标记与首次入库/最近消耗日期的批次。首次日期缺失单列“日期待核实”。处置申请及审批本身不改变库存;执行关联的调拨或出库才变动库存。', 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: '查看任务' },
-  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: '月平均库存=(上月末期初+昨日期末)/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: '审批' }
+  todo: { title: '待办单据', subtitle: '未完成出入库申请与进行中盘点', definition: '待入库/待出库只计本月创建、尚未完成的入库/出库申请(草稿、待审批、待收货、待上架/待拣货),并按数据权限过滤;盘点只计状态执行中且未过计划完成日期,不限创建月份;待我审批仅计入库、出库、调拨三类流程本月到达当前用户当前节点的任务。', metrics: ['pendingInventory', 'pendingApproval'], columns: columns('orderNo:单据号|type:单据类型|warehouseName:仓库|status:状态|createTime:创建时间|dueTime:计划完成|reason:说明'), action: '查看任务' },
+  // 库龄口径(与首页「库龄结构」卡片、后端 WmsHomeSummaryVO.aging 逐条对齐):
+  // 库龄=当前日期−首次入库日期,最后入库日期只展示、补库不刷新库龄;期初数据按仓库配置的上线日起算(ageSource=warehouse);
+  // 分段 0-30/31-90/91-180/>180 天由后端下发,四段占比合计 100%,“日期待核实”单列且不参与四段占比;
+  // >180 天与呆滞口径联动(行内可直接建呆滞处置单);负责人取仓库负责人,不是批次经手人。
+  age: { title: '批次库龄明细', subtitle: '按首次入库日期起算的批次库龄与最后入库日期', definition: '库龄=当前日期−首次入库日期,调拨、拆合包不重置;期初数据无真实入库日期时按仓库配置的上线日起算(ageSource=warehouse),日期缺失且无法归到上线日的单列“日期待核实”且不计入四段占比;最后入库日期仅展示,补库不刷新库龄;分段 0-30/31-90/91-180/>180 天,四段占比合计 100%;>180 天与呆滞口径联动,行内可直接建呆滞处置单;负责人取仓库负责人,不是批次经手人。', metrics: ['ageCoverage'], columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|batchNo:批次号|systemBatchNo:系统批次|firstInboundTime:首次入库|lastInboundTime:最后入库|lastActivityTime:最近实际消耗|ageDays:库龄天数|quantity:在库量|unit:单位|valuation:批次估值|ownerName:负责人'), action: '批次流水' },
+  // 安全库存预警口径(与后端 WmsHomeMapper 的 alertRows、WmsHomeSummaryVO 逐条对齐,改动必须两边同改):
+  // ① 可用量=Σ在库量−Σ锁定量,按(仓库+物料)聚合,含待检/未放行批次(不排除未放行);
+  // ② 阈值:安全库存是触发基准,最大库存允许为空(为空按 安全库存×超储兜底系数 判定),最低库存只展示不参与触发;
+  // ③ 状态短路判定(逐条命中即停):待核实(无唯一有效阈值配置或可用量为负)→ 缺料(可用<安全库存)
+  //    → 偏低(安全库存≤可用<安全库存×偏低系数,默认 1.2)→ 超储(可用>最大库存,或空则>安全库存×默认 3)→ 正常;
+  // ④ 缺口金额=缺口量×移动加权平均不含税单价,单价缺失给 null(显示「待补价」,绝不冒充 0,也不计入合计);
+  // ⑤ 排序=缺口金额倒序、缺价行排最后,其次 缺料→偏低→超储→待核实;首页只出前 5 条,「全部 N 项」进本清单,
+  //    N 只计缺料+偏低+超储,待核实单列不计入;影响工单数=缺料行经 BOM 展开关联的未完工工单(待生产/生产中/已延期)去重数。
+  alerts: { title: '安全库存预警清单', subtitle: '按缺口金额倒序核对缺料、偏低、超储与待核实配置', definition: '可用量=在库量−锁定量(按仓库+物料聚合,含待检、未放行批次);阈值取安全库存(触发基准)与最大库存(允许为空,为空时按 安全库存×超储兜底系数 判定),最低库存仅作展示列、不参与触发判定;状态逐条短路判定:待核实(无唯一有效阈值配置或可用量为负)→ 缺料(可用量<安全库存)→ 偏低(安全库存≤可用量<安全库存×偏低系数,默认 1.2)→ 超储(可用量>最大库存,或最大库存为空时>安全库存×超储兜底系数,默认 3)→ 正常;缺口量=安全库存−可用量(不小于 0,仅缺料/偏低有意义);缺口金额=缺口量×移动加权平均不含税单价,单价缺失显示「待补价」且不计入合计(绝不按 0 计);列表按缺口金额倒序、缺价行排最后,其次按 缺料→偏低→超储→待核实;首页预警卡只展示前 5 条并汇总「影响生产工单」(缺料行经 BOM 展开关联的未完工工单去重数),预警条数只含缺料+偏低+超储,待核实单列且不计入。', columns: columns('categoryCode:物料编码|categoryName:物料名称|warehouseName:仓库|quantity:在库量|lockedQuantity:锁定量|availableQuantity:可用量|minInventory:最低库存|secureInventory:安全库存|maxInventory:最大库存|gapQuantity:缺口量|gapAmount:缺口金额|workOrderCount:影响工单数|unit:单位|status:预警类型|purchaseRequestCode:采购申请|reason:说明'), action: '生成采购申请 / 发起调拨' },
+  turnover: { title: '库存周转分析', subtitle: '按月初与期末数量快照估算本月库存周转', definition: '月平均库存=(上月末期初+昨日期末)/2;天数按本月已过天数折算,T+1 展示截至昨日日末(首页 turnoverAsOf 标注日末快照日期)。分母只含已完成的消耗性出库(销售/领料/自选领用/配料/按物料需求清单领料),剔除调拨出库。目标天数与预警天数按仓库配置,默认 45 天达标、60 天预警,状态由后端判定(达标/超目标/偏慢)。历史覆盖不足时不补造。', metrics: ['turnoverDays'], columns: columns('categoryName:物料名称|warehouseName:仓库|unit:单位|averageQuantity:平均库存量|outboundQuantity:出库量|turnoverDays:周转天数|turnoverTargetDays:目标天数|turnoverStatus:状态|recordedDays:已采集天数|expectedDays:所需天数|reason:说明'), action: '查看库存' },
+  // 口径文案与后端 WmsHomeSummaryVO 逐条对齐:分母只含已检(合格+不合格,让步接收进分母不计合格),
+  // 未检、无需质检与来源不可追溯的批次都不进分母;较上月的差值单位是百分点(pp),不是百分比。
+  qc: { title: '入库质检分析', subtitle: '本月质检批次与实际检验结论', definition: '检验完成日期落在本月;通过率=合格批次÷已检批次(合格+不合格,让步接收进分母不计合格);无质检要求与来源不可追溯的批次不计入分母;较上月保留两位小数,单位 pp。', period: true, metrics: ['qualityPassRate', 'qualityChange', '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 = {
   stagnantRate: ['呆滞占比', '%', 'stagnantReason'], turnoverDays: ['本月周转天数', '天', 'turnoverReason'],
-  qualityPassRate: ['质检通过率', '%', 'qualityReason'], ageCoverage: ['库龄来源覆盖率', '%', 'stagnantReason'], qualityCoverage: ['质检记录覆盖率', '%', 'qualityReason'], pendingInventory: ['进行中盘点', '单', ''], pendingApproval: ['待我审批', '单', 'approvalReason']
+  qualityPassRate: ['质检通过率', '%', 'qualityReason'],
+  // 较上月差值:单位位留空,pp 与符号已由 formatPpChange 拼进数值文本(detail.vue 的 metricValue),
+  // 第三位是明细页 delta 行取的原因字段,不可比时正好展示 qualityChangeReason。
+  qualityChange: ['较上月', '', 'qualityChangeReason'],
+  ageCoverage: ['库龄来源覆盖率', '%', 'stagnantReason'], qualityCoverage: ['质检记录覆盖率', '%', 'qualityReason'], pendingInventory: ['进行中盘点', '单', ''], pendingApproval: ['待我审批', '单', 'approvalReason']
 };

+ 19 - 0
src/views/warehouseManagement/index/dashboard-theme.scss

@@ -142,6 +142,25 @@ table.tbl tr:hover td{background:rgba(56,200,245,.06);}
 .feed-status{margin-left:auto;color:var(--green);white-space:nowrap;}
 .feed-status.active{color:var(--cyan);}
 .feed-status.waiting{color:var(--orange);}
+/* E3 最近作业动态:四段结构(时间 / 单据类型+单号 / 动作 / 状态)。
+   状态四色与待办条同源;.neutral 是「状态未识别」的中性灰——既不当成功绿、也不当待办橙,
+   否则「前端不认识的状态」会被读成「已完成」或「在等谁处理」。 */
+.feed-item .feed-doc{flex:0 1 auto;min-width:0;display:inline-flex;align-items:baseline;gap:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--t2);}
+.feed-item .feed-no{color:var(--cyan);text-decoration:none;border-bottom:1px dashed rgba(56,200,245,.45);cursor:pointer;font-variant-numeric:tabular-nums;}
+.feed-item .feed-no:hover{color:#EAF4FF;border-bottom-color:var(--cyan);}
+.feed-item .feed-no:focus{outline:1px solid var(--cyan);outline-offset:2px;border-radius:2px;}
+/* 动作描述单行省略:卡片宽度固定,长文案必须自己截断,不能把状态段挤出卡片,也不许换行撑高行高。 */
+.feed-item .feed-desc{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--t3);}
+.feed-status{display:inline-flex;align-items:center;gap:6px;}
+.feed-status.done{color:var(--green);}
+.feed-status.waiting{color:var(--orange);}
+.feed-status.doing{color:var(--cyan);}
+.feed-status.danger{color:var(--red);}
+.feed-status.neutral{color:var(--t3);}
+/* 新增事件高亮:3 秒从青色底渐隐到透明。只改背景色,不动 pointer-events、不动 DOM 顺序,
+   也不触发任何滚动——30 秒轮询里主动滚动会把用户正在看的位置顶走。 */
+.feed-item.is-new{animation:feed-new-fade 3s ease-out 1;}
+@keyframes feed-new-fade{0%{background:rgba(56,200,245,.22);}55%{background:rgba(56,200,245,.12);}100%{background:transparent;}}
 
 /* ========== 下钻页 ========== */
 .crumb{display:flex;align-items:center;gap:8px;margin:16px 0 14px;color:var(--t3);font-size:13px;}

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

@@ -67,6 +67,36 @@ export function formatComparisonBase(value) {
   return match ? match[1] + ' 日末' : '';
 }
 
+// 呆滞判定阈值(t_wms_warehouse.stagnant_days)的默认回落值:单仓取该仓配置,
+// 多仓汇总或配置缺失时首页卡片与 V3 口径条都回落此值,两处共用一个常量避免口径分叉。
+export const STAGNANT_DEFAULT_DAYS = 180;
+
+// 口径条里呆滞阈值的占位符:dashboard-screens.js 的 dead 屏文案用它,
+// 渲染时由 fillStagnantDays 用后端下发的 stagnantDays 替换。
+export const STAGNANT_DAYS_PLACEHOLDER = '{stagnantDays}';
+
+// 替换口径条里的 {stagnantDays} 占位符:stagnantDays 来自首页同一份 home-summary(与卡片同一配置项),
+// 缺失/非正数时回落 STAGNANT_DEFAULT_DAYS;不含占位符的屏幕原样返回,
+// 保证其它屏文案一个字符都不变,也不会漏出字面量 {stagnantDays}。
+export function fillStagnantDays(text, stagnantDays) {
+  const source = text == null ? '' : String(text);
+  if (source.indexOf(STAGNANT_DAYS_PLACEHOLDER) === -1) return source;
+  const days = Number(stagnantDays);
+  const usable = stagnantDays != null && stagnantDays !== '' && Number.isFinite(days) && days > 0;
+  return source.split(STAGNANT_DAYS_PLACEHOLDER).join(String(usable ? days : STAGNANT_DEFAULT_DAYS));
+}
+
+// 百分点(pp)差值的带符号文案:正数带「+」、负数带「-」,固定 2 位小数带 pp 单位,0 视为「持平」。
+// 空值(null/undefined/空串/非数字)返回「—」,调用方必须先判空再展示,
+// 绝不把「上月无可比快照」渲染成 +0.00pp 或 0。
+export function formatPpChange(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).toFixed(2) + 'pp';
+}
+
 // 多基本单位小计:按单位平铺「单位 数量(锁定 X)」,不同基本单位绝不跨单位加总。
 // 单位数超过 limit 时折叠为前 limit 个,其余个数由 rest 交给调用方提示。
 export function formatUnitSubtotals(units, limit = 3) {
@@ -82,3 +112,814 @@ export function formatUnitSubtotals(units, limit = 3) {
   });
   return { items, rest: rows.length - visible.length, total: rows.length };
 }
+
+// ===== D1「出入库动态图」口径(近 30 天滚动窗口)=====
+// 窗口天数:含今日共 30 天,随日期滚动;口径见 docs/仓储首页看板口径.md 的 D1 小节。
+export const TREND_WINDOW_DAYS = 30;
+// 连续同号提示门槛:窗口末尾连续 >=2 天同号才提示积压/缺货风险,单日不提示。
+export const TREND_RISK_MIN_DAYS = 2;
+
+// 本地日期 → yyyy-MM-dd:按本地时区取年月日。不能用 toISOString(东八区 0 点会被算成前一天)。
+export function formatLocalDate(day) {
+  const date = day instanceof Date ? day : new Date(day);
+  if (Number.isNaN(date.getTime())) return '';
+  return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
+}
+
+// 近 30 天窗口的日期序列:升序、最后一项是今日,第 29 天前~今日共 30 个自然日。
+// days 只在断言/复用时改动;非法值回落 TREND_WINDOW_DAYS,避免窗口长度被 0 或 NaN 打穿。
+export function trendWindowDates(now = new Date(), days = TREND_WINDOW_DAYS) {
+  const base = now instanceof Date ? now : new Date(now);
+  const today = Number.isNaN(base.getTime()) ? new Date() : base;
+  const count = Number.isFinite(Number(days)) && Number(days) > 0 ? Math.floor(Number(days)) : TREND_WINDOW_DAYS;
+  const dates = [];
+  for (let index = count - 1; index >= 0; index--) {
+    dates.push(formatLocalDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() - index)));
+  }
+  return dates;
+}
+
+// 单个方向的数量归一:null/undefined/空串/非数字 → null,表示「当日该方向没有数据」。
+// 绝不把缺数据变成 0——0 是「有作业但当日净额为零」,两者在图上必须能区分。
+export function trendQuantity(value) {
+  if (value == null || value === '') return null;
+  const number = Number(value);
+  return Number.isFinite(number) ? number : null;
+}
+
+// 无作业日判定:当日入库与出库都无数据才算无作业日;只要有任一方向有数据就不算。
+// 边界:0 是「有数据」——「入库 0、出库缺数据」按有作业处理,不能当成无作业日。
+export function isIdleTrendDay(row) {
+  if (!row) return true;
+  return trendQuantity(row.inbound) == null && trendQuantity(row.outbound) == null;
+}
+
+// 单日净差 = 当日入库量 − 当日出库量(口径固定为「入库 − 出库」,不取绝对值、不换方向)。
+// 无作业日(两侧都无数据)返回 null,图上断线;只有单侧有数据时另一侧按 0 计——
+// 后端 trend 的 SUM(CASE ...) 对存在的交易日两侧都会给数值,缺一侧只可能是该方向当日没有作业。
+export function trendNetOf(inbound, outbound) {
+  const inValue = trendQuantity(inbound);
+  const outValue = trendQuantity(outbound);
+  if (inValue == null && outValue == null) return null;
+  return (inValue == null ? 0 : inValue) - (outValue == null ? 0 : outValue);
+}
+
+// 近 30 天滚动窗口行:按日期升序固定 30 行。后端只返回有作业的日期,缺的日期补 null 不补 0。
+// 每行 { date, inbound, outbound, net, idle };net 一律由前端按「入库−出库」重算,不直接信后端字段。
+export function buildTrendWindow(rows, now = new Date(), days = TREND_WINDOW_DAYS) {
+  const source = new Map();
+  for (const row of Array.isArray(rows) ? rows : []) {
+    const date = row && row.date != null ? String(row.date).slice(0, 10) : '';
+    if (date) source.set(date, row);
+  }
+  return trendWindowDates(now, days).map((date) => {
+    const hit = source.get(date);
+    const inbound = hit ? trendQuantity(hit.inbound) : null;
+    const outbound = hit ? trendQuantity(hit.outbound) : null;
+    return { date, inbound, outbound, net: trendNetOf(inbound, outbound), idle: inbound == null && outbound == null };
+  });
+}
+
+// 窗口末尾最近连续同号段:先跳过末尾的无作业日(净差为 null,没有方向信号),
+// 再从最近一个有净差的日子向前累计同号天数;遇到 0(持平,方向不明)、
+// 无作业日(连续被空日打断,不能跨空日断言)或符号翻转即终止。
+// 返回 { direction: 1 正 / -1 负 / 0 无, days, start, end },没有连续段时 days 为 0。
+export function trendStreak(rows) {
+  const list = Array.isArray(rows) ? rows : [];
+  let index = list.length - 1;
+  while (index >= 0 && (!list[index] || trendQuantity(list[index].net) == null)) index--;
+  if (index < 0) return { direction: 0, days: 0, start: '', end: '' };
+  const sign = Math.sign(trendQuantity(list[index].net));
+  if (!sign) return { direction: 0, days: 0, start: '', end: '' };
+  let days = 0;
+  let start = '';
+  let end = '';
+  for (; index >= 0; index--) {
+    const net = list[index] ? trendQuantity(list[index].net) : null;
+    if (net == null || Math.sign(net) !== sign) break;
+    if (!days) end = String(list[index].date || '');
+    start = String(list[index].date || '');
+    days++;
+  }
+  return { direction: sign, days, start, end };
+}
+
+// 连续同号风险文案:连续为正=积压风险(入库持续多于出库),连续为负=缺货风险(出库持续多于入库)。
+// 天数不足门槛或没有方向时返回 null,调用方不渲染提示——无连续段不提示,绝不硬凑。
+export function trendRisk(streak, minDays = TREND_RISK_MIN_DAYS) {
+  const days = streak && Number.isFinite(Number(streak.days)) ? Math.floor(Number(streak.days)) : 0;
+  const direction = streak ? Math.sign(Number(streak.direction) || 0) : 0;
+  const gate = Number.isFinite(Number(minDays)) && Number(minDays) > 0 ? Math.floor(Number(minDays)) : TREND_RISK_MIN_DAYS;
+  if (!direction || days < gate) return null;
+  const range = streak.start && streak.end ? '(' + streak.start + ' ~ ' + streak.end + ')' : '';
+  if (direction > 0) return { direction, days, tone: 'warn', text: '连续 ' + days + ' 天净入库 · 积压风险', title: '近 30 天滚动窗口末尾连续 ' + days + ' 天净入库' + range + ',入库持续多于出库,库存积压风险上升' };
+  return { direction, days, tone: 'danger', text: '连续 ' + days + ' 天净出库 · 缺货风险', title: '近 30 天滚动窗口末尾连续 ' + days + ' 天净出库' + range + ',出库持续多于入库,缺货风险上升' };
+}
+
+// ===== E1「库龄结构」(批次库龄 / 四段占比 / 平均库龄)=====
+// 分段 key、label、ratio 全部由后端下发(summary.aging 固定 5 行:0-30 / 31-90 / 91-180 / 180+ / unknown),
+// 前端只按 key 分拣、配色与格式化,绝不复算占比、不重算库龄:占比一旦在前端复算,
+// 后端剔除 unknown 或调整分母口径时,图上百分比会与后端 summary 立刻分叉。
+const AGING_SEGMENT_KEYS = ['0-30', '31-90', '91-180', '180+'];
+const AGING_UNKNOWN_KEY = 'unknown';
+const AGING_NEUTRAL_COLOR = '#7E93AD';
+// 分段色阶:前三段顺序色(库龄越久越警示),>180 段固定红 #F25E5E —— 与呆滞分级「呆滞预警(alert)」同一色,
+// 因为 >180 天正是呆滞口径的候选区间;两处同色,用户才不会把「库龄结构」和「呆滞品占比」读成两套标准。
+// unknown 用中性灰:它不属于四段色阶,也不参与四段占比。
+const AGING_SEGMENT_COLORS = { '0-30': '#38C8F5', '31-90': '#2E8BE8', '91-180': '#F0A03A', '180+': '#F25E5E', unknown: AGING_NEUTRAL_COLOR };
+// 后端 label 缺失时的兜底名(正常路径一律用后端 label,含「>180天」的写法)。
+const AGING_SEGMENT_LABELS = { '0-30': '0-30天', '31-90': '31-90天', '91-180': '91-180天', '180+': '>180天', unknown: '日期待核实' };
+// >180 天段的库龄边界:与呆滞默认阈值(180 天)同界,只用于明细行的红色强调与「建呆滞处置单」入口判定,
+// 不参与任何库龄/占比计算(分段本身由后端判定)。
+const AGING_STAGNANT_SEGMENT_DAYS = 180;
+const agingQuantity = (value) => {
+  if (value == null || value === '') return null;
+  const number = Number(value);
+  return Number.isFinite(number) ? number : null;
+};
+const agingRatio = (value) => {
+  if (value == null || value === '') return null;
+  const number = Number(value);
+  return Number.isFinite(number) ? number : null;
+};
+
+export function isAgingUnknown(row) {
+  return !!row && row.key != null && String(row.key) === AGING_UNKNOWN_KEY;
+}
+
+// 分段配色:known 段按 key 取色;未知 key 一律中性灰,不冒充四段中的任何一段。
+export function agingColorOf(row) {
+  const key = row && row.key != null ? String(row.key) : '';
+  return AGING_SEGMENT_COLORS[key] || AGING_NEUTRAL_COLOR;
+}
+
+// 占比文本:只格式化后端 ratio(最多 2 位小数)。null=后端判定「总量非正不可算」,
+// 显示占位符而不是 0%——0% 会被读成「这一段真的没有份额」,与「算不出来」是两件事。
+// 精度必须与后端一致(后端 ratio 是两位小数):四段各自四舍五入到 1 位后,页面上可见的
+// 百分比会合计成 99.9% 或 100.1%(例:33.33/33.33/33.34 → 33.3/33.3/33.3=99.9),
+// 与「四段占比合计 100%」的口径自相矛盾。这里只截到后端给出的位数,不做二次取整。
+export function formatAgingRatio(ratio) {
+  const number = agingRatio(ratio);
+  return number == null ? '—' : number.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) + '%';
+}
+
+// 平均库龄文本:后端给一位小数;null(日期不完整不可算)显示「—」,绝不显示 0 天。
+export function formatAverageAgeDays(value) {
+  const number = agingRatio(value);
+  return number == null ? '—' : number.toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + ' 天';
+}
+
+// 库龄是否已越过 >180 天段:ageDays 缺失/非数字一律 false(日期待核实不参与红色强调,也不给处置入口)。
+export function isStagnantAge(ageDays) {
+  const number = agingQuantity(ageDays);
+  return number != null && number > AGING_STAGNANT_SEGMENT_DAYS;
+}
+
+// 库龄结构分拣:把后端 5 行拆成「四段(按固定 key 顺序)」+「unknown 单独一行」。
+// 返回 { segments, unknown, rowCount, ratioSum, totalQuantity, hasUnknown, unexpectedKeys }。
+// segments 只含四段且按 key 顺序排列(后端返回顺序变化不影响展示);ratioSum 只用于控制台核对
+// 「四段占比合计是否 100%」,不参与渲染——页面上每一个百分比都必须是后端给的 ratio。
+export function buildAgingSegments(rows) {
+  const list = Array.isArray(rows) ? rows : [];
+  const byKey = new Map();
+  const unexpectedKeys = [];
+  for (const row of list) {
+    if (!row || row.key == null) continue;
+    const key = String(row.key);
+    if (AGING_SEGMENT_KEYS.indexOf(key) === -1 && key !== AGING_UNKNOWN_KEY) { unexpectedKeys.push(key); continue; }
+    if (!byKey.has(key)) byKey.set(key, row);
+  }
+  const toSegment = (row) => {
+    const key = String(row.key);
+    return {
+      key,
+      // label 用后端原文(含「>180天」),只有后端没给时才用兜底名,避免图例出现裸 key。
+      label: row.label || AGING_SEGMENT_LABELS[key] || key,
+      quantity: agingQuantity(row.quantity),
+      ratio: agingRatio(row.ratio),
+      ratioText: formatAgingRatio(row.ratio),
+      color: agingColorOf(row),
+      // >180 段红色强调:与呆滞口径联动,四段里只有它需要催用户去建处置单。
+      stagnant: key === '180+'
+    };
+  };
+  const segments = AGING_SEGMENT_KEYS.map((key) => byKey.get(key)).filter(Boolean).map(toSegment);
+  const unknownRow = byKey.get(AGING_UNKNOWN_KEY);
+  const unknown = unknownRow ? { ...toSegment(unknownRow), ratioText: '不参与四段占比' } : null;
+  const validRatios = segments.map((item) => item.ratio).filter((value) => value != null);
+  const quantities = list.map((row) => (row ? agingQuantity(row.quantity) : null)).filter((value) => value != null);
+  return {
+    segments,
+    unknown,
+    rowCount: list.length,
+    // 只在四段至少有一段给出 ratio 时才给合计,否则 null(不可算不冒充 0)。
+    ratioSum: validRatios.length ? Math.round(validRatios.reduce((sum, value) => sum + value, 0) * 100) / 100 : null,
+    totalQuantity: quantities.length ? quantities.reduce((sum, value) => sum + value, 0) : null,
+    hasUnknown: !!unknown,
+    unexpectedKeys
+  };
+}
+
+// ===== F1「安全库存预警」口径(三色阈值 / 缺口量与缺口金额 / TOP5)=====
+// 状态判定(unknown → critical → warning → excess → normal 短路)、缺口量、缺口金额、排序全部由后端算好下发
+// (见 WmsHomeMapper 的 alertRows 与 WmsHomeSummaryVO)。前端只做归一、格式化与配色兜底,绝不复算阈值:
+// 系数(wms.home.alert.warning-ratio / excess-ratio)改一次、或夜间日结换成同一段 SQL,前端一旦自算就会静默分叉。
+export const ALERT_TOP_LIMIT = 5;
+// 状态短路优先级,与后端 ORDER BY FIELD(status,'critical','warning','excess','normal','unknown') 逐字一致:
+// 只用于核对后端排序有没有被改坏,以及未识别状态值的降级落位。
+export const ALERT_STATUS_ORDER = ['critical', 'warning', 'excess', 'normal', 'unknown'];
+// 状态中文标签:契约要求后端下发 statusLabel,这张表只在后端未下发时兜底,文案与后端逐字一致
+// (critical=缺料 warning=偏低 excess=超储 normal=正常 unknown=待核实)。
+export const ALERT_STATUS_LABELS = { critical: '缺料', warning: '偏低', excess: '超储', normal: '正常', unknown: '待核实' };
+// 配色兜底:后端 row.color 缺失时才启用(critical 红 #F25E5E / warning 橙 #F0A03A / excess 紫 #9B7DF7 / unknown 灰 #7E93AD)。
+// normal 的 color 按契约就是 null(正常行不表达健康度),因此它不在这张表里,也不算「缺失」,不打日志。
+export const ALERT_FALLBACK_COLORS = { critical: '#F25E5E', warning: '#F0A03A', excess: '#9B7DF7', unknown: '#7E93AD' };
+
+// —— 日志去重状态(模块级)——
+// 预警数据是计算属性,父组件重渲染、5 分钟准实时轮询都会让它反复求值;只有「数据指纹真的变了」才打日志,
+// 否则控制台被同一份数据刷满,反而看不见变化本身(与本文件既有趋势/库龄日志同一做法)。
+let alertRowsLoggedSnapshot = null;
+let alertSummaryLoggedSnapshot = null;
+let alertOrderWarnLogged = null;
+let alertTopOverflowLogged = null;
+const alertFallbackLogged = new Set();
+const alertGapMissingLogged = new Set();
+const alertUnexpectedStatusLogged = new Set();
+const alertDrillLogged = new Set();
+
+// 数值容错:null/空串/非数字 → null(=后端没给值),0 保留为真实零。
+// 不能用 Number(null)===0:那会把「算不出来」写成 0,落到缺口金额上就是「缺口不值钱」的错误结论。
+export function alertNumber(value) {
+  if (value == null || value === '') return null;
+  const number = Number(value);
+  return Number.isFinite(number) ? number : null;
+}
+
+// 数量文本:缺失 → 「—」;0 是真实零,显示 0;其余千分位、最多 4 位小数(与 displayNumber 同精度)。
+export function formatAlertQuantity(value) {
+  const number = alertNumber(value);
+  return number == null ? '—' : number.toLocaleString('zh-CN', { maximumFractionDigits: 4 });
+}
+
+// 金额文本(元):缺失 → 「—」,绝不显示 0;固定 2 位小数 + 千分位,避免与数量的精度混淆。
+export function formatAlertAmount(value) {
+  const number = alertNumber(value);
+  return number == null ? '—' : number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+// 状态 key 归一:缺失返回空串(调用方按「待核实」降级)。不改写后端给出的值——
+// 状态是后端的短路判定结果,前端把未知值「猜」成某个已知状态,等于伪造一个并不存在的判定。
+export function alertStatusOf(row) {
+  return row && row.status != null ? String(row.status).trim() : '';
+}
+
+const logAlertUnexpectedStatus = (status) => {
+  if (alertUnexpectedStatusLogged.has(status)) return;
+  alertUnexpectedStatusLogged.add(status);
+  homeWarn('仓储首页安全库存预警出现未识别的状态值(按待核实展示)', { status });
+};
+
+// 状态中文标签:statusLabel(后端下发)优先;缺失时按 status 兜底;未识别状态 → 「待核实」并 warn 一次。
+export function alertStatusLabel(row) {
+  const provided = row && row.statusLabel != null ? String(row.statusLabel).trim() : '';
+  if (provided) return provided;
+  const status = alertStatusOf(row);
+  if (status && !Object.prototype.hasOwnProperty.call(ALERT_STATUS_LABELS, status)) logAlertUnexpectedStatus(status);
+  return ALERT_STATUS_LABELS[status] || ALERT_STATUS_LABELS.unknown;
+}
+
+// 配色:优先取后端 row.color(颜色跟着后端判定走,改系数后颜色自动跟着变);缺失时按 status 兜底,
+// 并打一条说明日志——静默兜底会把「后端没下发颜色」伪装成正常,排查时分不清走的是哪条分支。
+export function alertColorOf(row) {
+  const provided = row && row.color != null ? String(row.color).trim() : '';
+  if (provided) return provided;
+  const status = alertStatusOf(row);
+  const fallback = Object.prototype.hasOwnProperty.call(ALERT_FALLBACK_COLORS, status) ? ALERT_FALLBACK_COLORS[status] : null;
+  if (!fallback) {
+    if (status && !Object.prototype.hasOwnProperty.call(ALERT_STATUS_LABELS, status)) logAlertUnexpectedStatus(status);
+    return null;
+  }
+  if (!alertFallbackLogged.has(status)) {
+    alertFallbackLogged.add(status);
+    homeWarn('仓储首页安全库存预警配色回退(后端未下发 color,按状态兜底)', { status, statusLabel: ALERT_STATUS_LABELS[status], fallbackColor: fallback, sampleId: row && row.id != null ? String(row.id) : '' });
+  }
+  return fallback;
+}
+
+// 缺口金额缺失原因(tooltip 用):后端 gapAmountReason 缺失时给一句事实说明,不编造单价来源。
+export function alertGapAmountReason(row) {
+  const reason = row && row.gapAmountReason != null ? String(row.gapAmountReason).trim() : '';
+  return reason || '单价缺失:移动加权不含税单价不可用,缺口金额不可计算(未按 0 计)';
+}
+
+// 缺口金额缺失日志:按「行 id + 原因」各打一条。缺价行是补货优先级判断的关键信息,
+// 用户只看到「金额待补价」时,控制台必须能查到是哪一行、缺的是什么价。
+export function logAlertGapAmountMissing(row) {
+  const reason = alertGapAmountReason(row);
+  const key = (row && row.id != null ? String(row.id) : '') + '|' + reason;
+  if (alertGapMissingLogged.has(key)) return reason;
+  alertGapMissingLogged.add(key);
+  homeWarn('仓储首页安全库存预警缺口金额缺失(显示待补价,不按 0 计)', {
+    id: row ? row.id : undefined,
+    categoryName: row ? (row.categoryName || row.name) : undefined,
+    warehouseName: row ? row.warehouseName : undefined,
+    gapQuantity: row ? row.gapQuantity : undefined,
+    reason
+  });
+  return reason;
+}
+
+// 缺口金额文本:有值 → 「1,234.00 元」;缺失 → 「金额待补价」(同时打一次缺价日志)。
+// 缺失=单价算不出来(后端给 null + gapAmountReason);渲染成 0 会让用户以为「缺口不值钱」,直接误导补货排序。
+export function alertGapAmountText(row) {
+  const value = alertNumber(row && row.gapAmount);
+  if (value != null) return formatAlertAmount(value) + ' 元';
+  logAlertGapAmountMissing(row);
+  return '金额待补价';
+}
+
+// 预警行缺口文案(卡片与明细共用):数量缺失「—」、真实 0 显示 0,金额缺失显示「金额待补价」。
+export function alertGapText(row) {
+  const unit = row && row.unit != null && String(row.unit).trim() !== '' ? ' ' + String(row.unit).trim() : '';
+  return '缺口 ' + formatAlertQuantity(row && row.gapQuantity) + unit + ' / ' + alertGapAmountText(row);
+}
+
+// 排序比较器:与后端 SQL 的 ORDER BY 逐条对齐——缺口金额倒序、NULL(缺价行)排最后,
+// 再按 缺料→偏低→超储→正常→待核实,最后按 id。只用于核对,不用来改变后端给的顺序。
+const compareAlertRows = (left, right) => {
+  if (left.gapAmount == null && right.gapAmount != null) return 1;
+  if (left.gapAmount != null && right.gapAmount == null) return -1;
+  if (left.gapAmount != null && right.gapAmount != null && left.gapAmount !== right.gapAmount) return right.gapAmount - left.gapAmount;
+  const leftRank = ALERT_STATUS_ORDER.indexOf(left.status);
+  const rightRank = ALERT_STATUS_ORDER.indexOf(right.status);
+  const leftOrder = leftRank === -1 ? ALERT_STATUS_ORDER.length : leftRank;
+  const rightOrder = rightRank === -1 ? ALERT_STATUS_ORDER.length : rightRank;
+  if (leftOrder !== rightOrder) return leftOrder - rightOrder;
+  return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
+};
+
+// 排序核对:排序由后端 SQL 负责,前端不重排(清单是分页的,页内重排只会让「第 2 页第一条」错位),
+// 只核对一次并用 warn 暴露偏离——后端排序被改坏时能被发现,而不是被前端偷偷纠正后无声无息。
+const logAlertOrderDeviation = (rows) => {
+  let deviation = '';
+  for (let index = 1; index < rows.length; index++) {
+    if (compareAlertRows(rows[index - 1], rows[index]) > 0) {
+      deviation = rows[index - 1].id + ' 应排在 ' + rows[index].id + ' 之后';
+      break;
+    }
+  }
+  if (!deviation || deviation === alertOrderWarnLogged) return;
+  alertOrderWarnLogged = deviation;
+  homeWarn('仓储首页安全库存预警排序与契约不一致(缺口金额倒序,缺价行最后)', { deviation, rows: rows.length });
+};
+
+// 解析日志:行数 + 每行「id=状态/缺口金额」组成指纹,数据没变不重复打印;同时输出状态分拣计数,
+// 便于核对「卡片 TOP5」与「全部 N 项」是否同源,以及缺口金额缺失(待补价)行有几条。
+const logAlertRowsParsed = (rows, statusCounts, missingAmountCount, overflow) => {
+  const snapshot = rows.length + '#' + rows.map((row) => row.id + '=' + row.status + '/' + (row.gapAmount == null ? '' : row.gapAmount)).join('|');
+  if (snapshot !== alertRowsLoggedSnapshot) {
+    alertRowsLoggedSnapshot = snapshot;
+    homeLog('仓储首页安全库存预警已解析(状态分拣)', {
+      rows: rows.length,
+      critical: statusCounts.critical, warning: statusCounts.warning, excess: statusCounts.excess,
+      normal: statusCounts.normal, unknown: statusCounts.unknown,
+      missingAmountCount,
+      topGapAmount: rows.length && rows[0].gapAmount != null ? rows[0].gapAmount : null
+    });
+  }
+  if (overflow > 0 && overflow !== alertTopOverflowLogged) {
+    alertTopOverflowLogged = overflow;
+    homeWarn('仓储首页安全库存预警条数超过 TOP 上限,已截断展示(全部 N 项请进清单页)', { overflow, limit: ALERT_TOP_LIMIT, rows: rows.length });
+  }
+};
+
+// 预警行归一 + 状态分拣:只映射字段、不重算任何口径;limit>0 时截断到 TOP N(首页卡片用 5,清单页不走这里)。
+// 返回 rows(已截断)、total(后端给的原始条数)、overflow、statusCounts、unknownCount、missingAmountCount。
+export function buildAlertRows(rows, limit = ALERT_TOP_LIMIT) {
+  const list = Array.isArray(rows) ? rows : [];
+  const built = list.map((row) => {
+    const source = row || {};
+    const name = source.name != null && String(source.name).trim() !== '' ? String(source.name) : (source.categoryName != null ? String(source.categoryName) : '');
+    const code = source.purchaseRequestCode != null ? String(source.purchaseRequestCode).trim() : '';
+    const item = {
+      id: source.id != null ? String(source.id) : '',
+      categoryId: source.categoryId,
+      warehouseId: source.warehouseId,
+      warehouseName: source.warehouseName != null ? String(source.warehouseName) : '',
+      name: name || '物料待核实',
+      unit: source.unit != null ? String(source.unit).trim() : '',
+      quantity: alertNumber(source.quantity),
+      lockedQuantity: alertNumber(source.lockedQuantity),
+      availableQuantity: alertNumber(source.availableQuantity),
+      minInventory: alertNumber(source.minInventory),
+      secureInventory: alertNumber(source.secureInventory),
+      maxInventory: alertNumber(source.maxInventory),
+      gapQuantity: alertNumber(source.gapQuantity),
+      gapAmount: alertNumber(source.gapAmount),
+      gapAmountReason: source.gapAmountReason != null ? String(source.gapAmountReason).trim() : '',
+      status: alertStatusOf(source),
+      statusLabel: alertStatusLabel(source),
+      color: alertColorOf(source),
+      workOrderCount: alertNumber(source.workOrderCount),
+      // null 表示「未生成采购申请」(契约第 9 项);空串同样按未生成处理,绝不能当成一个单号展示。
+      purchaseRequestCode: code === '' ? null : code,
+      reason: source.reason != null ? String(source.reason) : (source.message != null ? String(source.message) : '')
+    };
+    // 缺口文案统一由 alertGapText 生成:缺价行走「金额待补价」并打一次去重日志,卡片与清单页文案保持一致。
+    item.gapText = alertGapText(item);
+    return item;
+  });
+  const statusCounts = ALERT_STATUS_ORDER.reduce((counts, key) => {
+    counts[key] = built.filter((row) => row.status === key).length;
+    return counts;
+  }, {});
+  const missingAmountCount = built.filter((row) => row.gapAmount == null).length;
+  const size = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 0;
+  const overflow = size && built.length > size ? built.length - size : 0;
+  logAlertRowsParsed(built, statusCounts, missingAmountCount, overflow);
+  logAlertOrderDeviation(built);
+  return {
+    rows: size ? built.slice(0, size) : built,
+    total: built.length,
+    overflow,
+    statusCounts,
+    unknownCount: statusCounts.unknown,
+    missingAmountCount
+  };
+}
+
+// 汇总条(summary 级):alertCount=缺料+偏低+超储(待核实不计入)、alertUnknownCount=待核实行数、
+// alertGapAmount=Σ缺口金额(缺价行不计入,存在缺价行时后端给 alertGapAmountReason)、
+// alertWorkOrderCount=全部缺料行经 BOM 展开关联的未完工工单去重数。
+// 全部直接消费后端 summary:前端自己加总只会得到「当前可见行」的合计,与清单页条数、缺价排除规则立刻分叉。
+export function buildAlertSummary(summary) {
+  const source = summary || {};
+  const count = alertNumber(source.alertCount);
+  const unknownCount = alertNumber(source.alertUnknownCount);
+  const gapAmount = alertNumber(source.alertGapAmount);
+  const workOrderCount = alertNumber(source.alertWorkOrderCount);
+  const gapAmountReason = source.alertGapAmountReason != null ? String(source.alertGapAmountReason).trim() : '';
+  const workOrderReason = source.alertWorkOrderCountReason != null ? String(source.alertWorkOrderCountReason).trim() : '';
+  const result = {
+    count,
+    countText: count == null ? '—' : String(count),
+    unknownCount,
+    unknownText: unknownCount == null ? '—' : String(unknownCount),
+    gapAmount,
+    gapAmountText: gapAmount == null ? '—' : formatAlertAmount(gapAmount) + ' 元',
+    gapAmountReason,
+    workOrderCount,
+    workOrderText: workOrderCount == null ? '—' : String(workOrderCount),
+    workOrderReason
+  };
+  const snapshot = count + '#' + unknownCount + '#' + gapAmount + '#' + workOrderCount + '#' + gapAmountReason + '#' + workOrderReason;
+  if (snapshot !== alertSummaryLoggedSnapshot) {
+    alertSummaryLoggedSnapshot = snapshot;
+    homeLog('仓储首页安全库存预警汇总已解析', {
+      alertCount: source.alertCount, alertUnknownCount: source.alertUnknownCount,
+      alertGapAmount: source.alertGapAmount, alertGapAmountReason: gapAmountReason,
+      alertWorkOrderCount: source.alertWorkOrderCount, alertWorkOrderCountReason: workOrderReason
+    });
+  }
+  return result;
+}
+
+// 下钻点击日志:同一目标只打一条(连点不刷屏),带上行关键字段,便于核对「点的是哪一行、带到哪个清单」。
+export function logAlertDrill(action, payload) {
+  const data = payload || {};
+  const key = action + '|' + (data.id == null ? '' : String(data.id)) + '|' + (data.level == null ? '' : String(data.level));
+  if (alertDrillLogged.has(key)) return;
+  alertDrillLogged.add(key);
+  homeLog('仓储首页安全库存预警下钻:' + action, data);
+}
+
+
+// ============================== E3「最近作业动态」 ==============================
+// 卡片固定展示 5 条。后端 home-summary 已做 operateTime 倒序 + 同单号 GROUP BY + LIMIT 5,
+// 前端仍再截断一次:因为前端还要按 orderId 再做一次「同一单据只留最新」的去重,去重后条数可能变少,
+// 也可能因后端口径调整而变多;只靠后端 LIMIT 无法保证「卡片正好显示 5 条」这个视觉契约。
+export const ACTIVITY_LIMIT = 5;
+// 橙色「待*」状态的超时阈值,与待办条(overdueInbound/overdueOutbound/overdueApproval)完全同口径:24 小时。
+export const ACTIVITY_OVERDUE_HOURS = 24;
+
+// 状态词 → tone。文案与颜色解耦:文案可以来自后端、也可以按类型规则推导,颜色只认这张表,
+// 因此改配色不用改文案,也不需要在模板里判业务状态。
+// 四种 tone 与卡片其它区域同源:done 绿=闭环、waiting 橙=在等谁处理、doing 青=正在跑、danger 红=出问题。
+export const ACTIVITY_STATUS_STYLES = {
+  '已完成': { tone: 'done' },
+  '已执行': { tone: 'done' },
+  '待质检': { tone: 'waiting' },
+  '待审批': { tone: 'waiting' },
+  '待执行': { tone: 'waiting' },
+  '待处理': { tone: 'waiting' },
+  '进行中': { tone: 'doing' },
+  '执行中': { tone: 'doing' },
+  '已驳回': { tone: 'danger' },
+  '异常': { tone: 'danger' }
+};
+
+// 未识别状态的中性 tone。为什么不用 done / waiting:
+// done 是绿,会被直接读成「已完成」这个业务结论;waiting 是橙,会被读成「在等谁处理」,
+// 还会被套上「超时」角标,等于替后端凭空造出一个待办。中性灰只表达「这里有个状态,前端不认识」,
+// 与 E1 库龄的 unknown 段、F1 预警的「待核实」是同一种视觉语言;灰色样式见 dashboard-theme.scss 的 .feed-status.neutral。
+const ACTIVITY_NEUTRAL_TONE = 'neutral';
+const ACTIVITY_NEUTRAL_TEXT = '状态待核实';
+const ACTIVITY_DAY_MS = 24 * 60 * 60 * 1000;
+const pad2 = (value) => String(value).padStart(2, '0');
+
+// 未识别值去重集合:同一句 warn 只打一次,避免「每行 × 每次计算属性求值」把控制台刷满。
+const activityUnrecognizedLogged = new Set();
+const logActivityUnrecognized = (kind, value) => {
+  const key = kind + '|' + (value == null ? '' : String(value));
+  if (activityUnrecognizedLogged.has(key)) return;
+  activityUnrecognizedLogged.add(key);
+  homeWarn('E3 最近作业动态状态未识别', { kind, value: value == null ? null : String(value) });
+};
+
+// E3 解析日志的去重快照。activities 是计算属性,父组件重渲染、30 秒轮询都会让它反复求值,
+// 每次都打会把控制台刷满、反而看不见真实变化。只有条数/四类分布/待*数/超时数任一变化时才打一条,
+// 且只打聚合数字,绝不打印整行数据或整包响应。
+let activityFeedLoggedSnapshot = null;
+
+// 事件时间解析:operateTime 为准(time 是它的别名)。
+// 易错点一:后端给的是 'YYYY-MM-DD HH:mm:ss'(空格分隔),部分内核 new Date() 直接解析失败,
+// 这里先把空格换成 T 再解析,避免「今天的数据全变成 —」。
+// 易错点二:解析失败一律返回 null,调用方显示「—」或把该行排到最后,绝不能用「现在」兜底——
+// 那会把一条陈年流水顶到列表最前面。
+const parseActivityTimestamp = (value) => {
+  if (value == null || value === '') return null;
+  // 用「有没有 getTime」判时间对象,而不是 instanceof Date:跨 realm/iframe 的 Date、
+  // 以及 dayjs/moment 这类包装对象 instanceof 都会落空,写成 instanceof 会静默走进字符串分支。
+  if (typeof value === 'object' && typeof value.getTime === 'function') return Number.isNaN(value.getTime()) ? null : value;
+  const text = String(value).trim();
+  if (text === '') return null;
+  const normalized = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(text) ? text.replace(' ', 'T') : text;
+  const date = new Date(normalized);
+  return Number.isNaN(date.getTime()) ? null : date;
+};
+
+// now 参数归一:只接受带 getTime 的时间对象(含跨 realm 的 Date),非法值回落「现在」,
+// 保证格式化函数永远不会因为一个坏参数抛错——但也绝不拿「现在」去补一条解析不出的事件时间。
+const asActivityNow = (value) => (value && typeof value.getTime === 'function' && !Number.isNaN(value.getTime()) ? value : new Date());
+
+// 事件时间取值:operateTime 优先,缺失或空串才回落 time 别名(两者都是后端同一时刻的两个名字)。
+const activityOperateTime = (row) => {
+  const source = row || {};
+  if (source.operateTime != null && source.operateTime !== '') return source.operateTime;
+  return source.time != null && source.time !== '' ? source.time : '';
+};
+
+// 去重/差分键:orderId 优先(同一单据多次操作只留最新一条),缺失时用 id;
+// 两者都没有返回空串,由调用方决定兜底方式(去重用行下标,差分组直接跳过)。
+const activityRowKey = (row) => {
+  const source = row || {};
+  if (source.orderId != null && source.orderId !== '') return String(source.orderId);
+  if (source.id != null && source.id !== '') return String(source.id);
+  return '';
+};
+
+// 明细项数:io/transfer 的 itemCount 是后端 COUNT(*) 真明细行数,只认「有限且 > 0」的数字;
+// 0/负数/非数字一律当「数量待核实」返回 null,绝不显示「0 项」——那会被读成「这一单没有任何明细」。
+// 易错点(盘点):inventory 行的 itemCount 是后端写死的占位常量 1(见
+// aiot-biz/wms-biz/wms-biz-provider/src/main/resources/mapper/WmsHomeMapper.xml 第 240-241 行的盘点分支投影列,
+// 第 9 个投影列 CAST 成 1),它既不是盘点明细行数也不是盘点数量,**不得当数量渲染**——否则每张盘点单
+// 都会显示「盘点 1 项」,等于编了一个业务数字。与 C4 呆滞分母、F1 缺价金额、E1 unknown 同一条铁律:
+// 不可核实的值宁可省略,也不拿常量或 0 冒充。这里直接返回 null,调用方据此不写数量。
+const activityItemCount = (row) => {
+  const source = row || {};
+  const type = source.activityType == null ? '' : String(source.activityType);
+  if (type === 'inventory') return null;
+  const value = Number(source.itemCount);
+  return Number.isFinite(value) && value > 0 ? value : null;
+};
+
+// 进度文案(如 '62%'):目前后端 activities 行并不下发进度字段,这里是防御性读取(非契约字段)。
+// 0、负数、非数字一律当「没有进度」返回空串——契约明确「绝不显示 0%」,
+// 0% 会被读成「一点都没做」,与「不知道进度」是两件事。>100 夹到 100(后端配平误差),最多 1 位小数。
+const activityProgressOf = (row) => {
+  const source = row || {};
+  const raw = source.progress != null ? source.progress : (source.progressPercent != null ? source.progressPercent : source.percent);
+  if (raw == null || raw === '') return '';
+  const value = Number(raw);
+  if (!Number.isFinite(value) || value <= 0) return '';
+  const rounded = Math.round(Math.min(value, 100) * 10) / 10;
+  return (Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)) + '%';
+};
+
+// 时间四段文案之一:今日 → '今日 HH:mm'(小时与分钟都补前导 0);昨日 → '昨日';更早 → 'MM-DD';
+// 空/非法 → '—'。now 由调用方传入(缺省当前时间),保证同一屏同一轮渲染只用一个「今天」。
+export function formatActivityTime(value, now = new Date()) {
+  const date = parseActivityTimestamp(value);
+  if (!date) return '—';
+  const current = asActivityNow(now);
+  const todayStart = new Date(current.getFullYear(), current.getMonth(), current.getDate()).getTime();
+  const stamp = date.getTime();
+  if (stamp >= todayStart && stamp < todayStart + ACTIVITY_DAY_MS) return '今日 ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes());
+  if (stamp >= todayStart - ACTIVITY_DAY_MS) return '昨日';
+  return pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
+}
+
+// hover 用的完整时间:'YYYY-MM-DD HH:mm:ss'(本地时间);解析失败回落后端原始串,
+// 原始串也为空时给「事件时间待核实」——不做任何猜测。
+const activityTimeTitle = (row) => {
+  const raw = activityOperateTime(row);
+  const date = parseActivityTimestamp(raw);
+  if (!date) return raw !== '' ? String(raw) : '事件时间待核实';
+  return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate())
+    + ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
+};
+
+// 状态推导的唯一来源(组件与模板都不许再内联判断):
+// 盘点:status 0→「待执行」(waiting) / 1→「进行中」(doing) / 2→「已完成」(done);
+// 调拨:恒为「已执行」(done);出入库:恒为「已完成」(done)——后端只把审核通过的流水放进 activities。
+// 易错点:io/transfer 的 status 恒为 null,而 Number(null) === 0,若先按 status 判就会把「已完成」
+// 显示成「待执行」,所以必须先按 activityType 分流。未识别类型/状态值只 warn 一次并回落中性灰。
+export function activityStatusOf(row) {
+  const source = row || {};
+  const type = source.activityType == null ? '' : String(source.activityType);
+  if (type === 'inventory') {
+    const status = source.status == null || source.status === '' ? null : Number(source.status);
+    if (status === 0) return { text: '待执行', tone: 'waiting' };
+    if (status === 1) return { text: '进行中', tone: 'doing' };
+    if (status === 2) return { text: '已完成', tone: 'done' };
+    logActivityUnrecognized('inventoryStatus', source.status);
+    return { text: ACTIVITY_NEUTRAL_TEXT, tone: ACTIVITY_NEUTRAL_TONE };
+  }
+  if (type === 'transfer') return { text: '已执行', tone: 'done' };
+  if (type === 'io') return { text: '已完成', tone: 'done' };
+  logActivityUnrecognized('activityType', source.activityType);
+  // 类型不认识时才尝试用后端下发的中文状态词兜底(例如后端新增了质检类单据):
+  // 认得就套同一张色表,「认识的状态词」比「不认识的状态」信息量大;不认得才退回中性灰。
+  const word = source.description == null ? '' : String(source.description).trim();
+  const style = ACTIVITY_STATUS_STYLES[word];
+  if (style) return { text: word, tone: style.tone };
+  return { text: ACTIVITY_NEUTRAL_TEXT, tone: ACTIVITY_NEUTRAL_TONE };
+}
+
+// 单据类型:activityType + direction 两要素决定中文类型名(io 必须再看方向,
+// 否则出库单会被叫成入库单)。未识别的类型不猜,回落中性名「作业单」并 warn 一次——
+// 猜成四类之一会让用户点进错误的单据类型。
+const activityDocTypeOf = (row) => {
+  const source = row || {};
+  const type = source.activityType == null ? '' : String(source.activityType);
+  if (type === 'inventory') return '盘点任务';
+  if (type === 'transfer') return '调拨单';
+  if (type === 'io') {
+    const direction = Number(source.direction);
+    if (direction === 1) return '入库单';
+    if (direction === 2) return '出库单';
+    logActivityUnrecognized('direction', source.direction);
+    return '出入库单';
+  }
+  return '作业单';
+};
+
+// 动作描述(第三段)。按类型套模板,io/transfer 的模板固定含「数量 + 库区」,盘点行只给「盘点 + 库区(+ 进度)」:
+// - 盘点:后端 description 里塞的是状态词(已完成/执行中/待处理),那是第四段「状态」,
+//   绝不能当动作描述显示,否则一行会出现「单号 → 已完成」的错位阅读;一律按模板重拼。
+//   **盘点不写数量**:后端盘点行的 itemCount 恒为占位常量 1(WmsHomeMapper.xml 第 240-241 行),
+//   拼成「盘点 1 项」就是伪造明细数;盘点该看的是进度,不是项数。
+// - io/transfer:按模板拼(数量+库区),口径统一、不受后端文案微调影响;
+//   仅当数量缺失时才复用后端 description,避免退化成光秃秃一个「入库」。
+// - 数量缺失不写「0 项」:宁缺数量,不造 0。
+const activityActionTextOf = (row) => {
+  const source = row || {};
+  const type = source.activityType == null ? '' : String(source.activityType);
+  const description = source.description == null ? '' : String(source.description).trim();
+  const quantity = activityItemCount(source);
+  const warehouse = source.warehouseName == null ? '' : String(source.warehouseName).trim();
+  const isStatusWord = Object.prototype.hasOwnProperty.call(ACTIVITY_STATUS_STYLES, description);
+  const parts = [];
+  if (type === 'inventory' && description !== '' && !isStatusWord) {
+    // 契约里盘点行只放状态词;出现色表外的词说明后端加了新状态,warn 一次便于第一时间发现口径漂移。
+    logActivityUnrecognized('statusWord', description);
+  }
+  const verb = type === 'inventory' ? '盘点' : type === 'transfer' ? '调拨' : Number(source.direction) === 2 ? '出库' : '入库';
+  if (type === 'inventory') {
+    // 盘点只给动作词:数量是占位常量(activityItemCount 已对 inventory 返回 null),进度单独拼在后面。
+    parts.push(verb);
+  } else if (quantity != null) {
+    parts.push(verb + ' ' + quantity + ' 项');
+  } else if (description !== '' && !isStatusWord) {
+    // 数量缺失时才复用后端动作文案(它通常已经带了数量,例如「入库 12 项」)。
+    parts.push(description);
+  } else {
+    parts.push(verb);
+  }
+  if (warehouse !== '') parts.push(warehouse);
+  if (type === 'inventory') {
+    const progress = activityProgressOf(source);
+    if (progress !== '') parts.push('进度 ' + progress);
+  }
+  return parts.join(' · ');
+};
+
+// 单行归一:先浅拷贝展开后端原始字段(保证 $emit('business','io', item) 仍能拿到
+// activityType/orderId/orderNo/direction/warehouseId),再附加前端展示字段。
+// 易错点:附加字段一律用后端不用的名字(timeText/actionText/statusText/...),
+// 一旦用 time/status 之类同名键,就会覆盖原始字段、让下钻参数悄悄丢失。
+const buildActivityItem = (source, now) => {
+  const row = source || {};
+  const status = activityStatusOf(row);
+  const rawTime = activityOperateTime(row);
+  const stamp = parseActivityTimestamp(rawTime);
+  const progressText = activityProgressOf(row);
+  // 超时:只有橙色的「待*」才谈得上超时(绿/青/红套「超时」没有业务含义)。
+  // 由 operateTime 与 now 纯计算得出,幂等——重复求值不会因为「已经标过一次」而漂移。
+  const overdue = status.tone === 'waiting' && stamp != null
+    && (now.getTime() - stamp.getTime()) > ACTIVITY_OVERDUE_HOURS * 3600 * 1000;
+  return {
+    ...row,
+    docType: activityDocTypeOf(row),
+    timeText: formatActivityTime(rawTime, now),
+    timeTitle: activityTimeTitle(row),
+    actionText: activityActionTextOf(row),
+    statusText: status.text,
+    tone: status.tone,
+    overdue,
+    progressText
+  };
+};
+
+// 动态流归一(E3 唯一入口):
+// 1) 去重:同一单据(orderId,缺失时用 id)多次操作只留 operateTime 最新一条。后端虽然已按单号
+//    GROUP BY,但前端不能假设这个口径永远不变;两个键都缺失时用行下标兜底,避免无主键行全塌成一条。
+// 2) 排序:operateTime 倒序;时间相同按 activityRowKey 倒序(表内每行 key 唯一,因此是确定的稳定排序);解析不出时间的行排最后。
+// 3) 截断:只显示 ACTIVITY_LIMIT 条;overflow=去重后超出条数(用于「全部动态 ›」的余量提示)。
+// 返回 total=入参条数、deduped=被去掉的重复条数,供日志与断言核对口径。
+export function buildActivityFeed(rows, now = new Date()) {
+  const list = Array.isArray(rows) ? rows : [];
+  const current = asActivityNow(now);
+  const latest = new Map();
+  let deduped = 0;
+  list.forEach((row, index) => {
+    const source = row || {};
+    const key = activityRowKey(source) || '#' + index;
+    const stamp = parseActivityTimestamp(activityOperateTime(source));
+    const previous = latest.get(key);
+    if (!previous) {
+      latest.set(key, { source, stamp });
+      return;
+    }
+    deduped += 1;
+    // 同刻不覆盖:后端已是 operateTime 倒序,先到的同刻行保留,去重结果才与后端顺序一致。
+    if (stamp && (!previous.stamp || stamp.getTime() > previous.stamp.getTime())) latest.set(key, { source, stamp });
+  });
+  const ordered = Array.from(latest.values()).sort((left, right) => {
+    const leftTime = left.stamp ? left.stamp.getTime() : null;
+    const rightTime = right.stamp ? right.stamp.getTime() : null;
+    if (leftTime !== rightTime) {
+      if (leftTime == null) return 1;
+      if (rightTime == null) return -1;
+      return rightTime - leftTime;
+    }
+    const leftKey = activityRowKey(left.source);
+    const rightKey = activityRowKey(right.source);
+    return rightKey < leftKey ? -1 : rightKey > leftKey ? 1 : 0;
+  });
+  const items = ordered.slice(0, ACTIVITY_LIMIT).map((entry) => buildActivityItem(entry.source, current));
+  const overflow = ordered.length > ACTIVITY_LIMIT ? ordered.length - ACTIVITY_LIMIT : 0;
+  logActivityFeedParsed(list.length, deduped, ordered, items, overflow);
+  return { items, total: list.length, deduped, overflow };
+}
+
+// E3 解析日志(模块级快照去重,见活动最上方的 activityFeedLoggedSnapshot)。
+// 口径说明:四类分布按「去重后的全部行」统计,便于对照后端四类单据是否都回来了;
+// 待*数与超时数按「实际展示的 5 行」统计——这两个值只对用户看得见的行有意义。
+const logActivityFeedParsed = (total, deduped, ordered, items, overflow) => {
+  const distribution = { inbound: 0, outbound: 0, transfer: 0, inventory: 0, other: 0 };
+  ordered.forEach((entry) => {
+    const source = entry.source || {};
+    const type = source.activityType == null ? '' : String(source.activityType);
+    if (type === 'inventory') distribution.inventory += 1;
+    else if (type === 'transfer') distribution.transfer += 1;
+    else if (type === 'io') { if (Number(source.direction) === 2) distribution.outbound += 1; else distribution.inbound += 1; }
+    else distribution.other += 1;
+  });
+  const waiting = items.filter((item) => item.tone === 'waiting').length;
+  const overdue = items.filter((item) => item.overdue).length;
+  const unique = ordered.length;
+  const snapshot = [total, deduped, unique, items.length, overflow,
+    distribution.inbound, distribution.outbound, distribution.transfer, distribution.inventory, distribution.other,
+    waiting, overdue].join('|');
+  if (snapshot === activityFeedLoggedSnapshot) return;
+  activityFeedLoggedSnapshot = snapshot;
+  homeLog('E3 最近作业动态已解析', { rows: total, deduped, unique, shown: items.length, overflow, distribution, waiting, overdue });
+};
+
+// 新增事件差分(高亮用):返回 nextItems 中不在 prevItems 里的行 id,顺序与 nextItems 一致。
+// 易错点一:prevItems 为空(首次加载)返回空数组——首屏整屏高亮等于什么都没强调,只是让卡片闪一下。
+// 易错点二:只按 id 比较,绝不按数组引用比较:activities 是计算属性,每次求值都返回新数组,
+//            引用比较会让 30 秒轮询把同一批行全部判成「新增」,整屏反复闪。
+// 易错点三:返回的是 nextItems 各行「原始的 id 值」(不做 String 归一),组件模板用同一个 item.id
+//            做 indexOf 比较,两侧天然一致;id 缺失的行直接跳过(退化为不闪,绝不会误闪)。
+export function diffActivityIds(prevItems, nextItems) {
+  const previous = Array.isArray(prevItems) ? prevItems : [];
+  const next = Array.isArray(nextItems) ? nextItems : [];
+  if (!previous.length) return [];
+  const seen = new Set(previous.map((item) => activityRowKey(item)));
+  return next
+    .filter((item) => item && item.id != null && item.id !== '')
+    .filter((item) => !seen.has(activityRowKey(item)))
+    .map((item) => item.id);
+}

+ 45 - 7
src/views/warehouseManagement/index/dashboard.vue

@@ -3,9 +3,9 @@
     <main class="main">
         <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" />
+        <dashboard-detail v-else :key="view" :view="view" :filters="filters" :options="options" :result="result" :summary="summary" :loading="loading" :error="error" :exporting="exporting" :stagnant-days="stagnantDays" @back="open('home')" @navigate="open" @search="search" @retry="loadDetail" @export="exportRows" @business="business" @batch="batchApprove" />
     </main>
-    <dashboard-actions ref="actions" :options="options" :filters="filters" @success="refresh" @business="business" />
+    <dashboard-actions ref="actions" :options="options" :filters="filters" :alert-rows="alertRows" @success="refresh" @business="business" />
   </div>
 </template>
 <script>
@@ -30,6 +30,23 @@ export default {
     view: 'home', warehouseId: '', unit: '', filters: {}, options: {}, summary: {}, result: {}, updatedAt: '', error: '', loading: false, exporting: false, portalRoots: [], optionsLoaded: false, receiveQueue: [], preparedReceipts: [], receiving: false
   }; },
   computed: {
+    // 呆滞阈值向下透传:优先用首页已请求到的同一份 home-summary.stagnantDays(不额外发请求),
+    // 缺失/非正数时传 null,由 detail.vue 回落默认 180 天并在口径条标明是默认值,绝不把「后端没给」显示成仓库配置。
+    // 字段名与后端契约保持一致(stagnantDays),避免排查时被误读成另一个概念。
+    stagnantDays() {
+      const days = this.summary && this.summary.stagnantDays;
+      if (days == null || days === '') return null;
+      const number = Number(days);
+      return Number.isFinite(number) && number > 0 ? number : null;
+    },
+    // 预警行全集:超储行发起调拨时要从「缺口最大的缺料行」推目标仓库。
+    // summary.alerts 只有 TOP5(口径契约第 6 条),拿它推会把第 6 名之后的缺料当成不存在;
+    // 已经在 alerts 视图里时明细列表才是全集,优先用它,缺数据时再退回首页 TOP5。
+    alertRows() {
+      const records = this.view === 'alerts' && this.result && Array.isArray(this.result.records) ? this.result.records : null;
+      if (records && records.length) return records;
+      return Array.isArray(this.summary && this.summary.alerts) ? this.summary.alerts : [];
+    },
     businessComponent() { return businessViews[this.view] || null; },
     businessTitle() { return { addin:'新增入库单',addout:'新增出库单',ioapp:'出入库申请',inapp:'入总库申请' }[this.view]; }
   },
@@ -65,7 +82,14 @@ 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 (view === 'stockLedger') return this.$router.push({ path: '/warehouseManagement/stockLedger' });
+      // D2 分类分布下钻:extra 里的 categoryLevelId 必须随跳转一起进台账页的 query,
+      // 否则台账拿不到「要过滤哪个分类」,用户点完分类只会看到全量列表,等于把这次点击丢掉。
+      // 只合并 extra,不把首页当前筛选整包带过去:V2 台账的 item-list 只认 categoryLevelId,
+      // warehouseId/unit 之类塞进 query 会被它静默忽略,反而让人误以为台账已按仓库/单位过滤过。
+      // path 保持 /warehouseManagement/stockLedger 不变(既有入口断言依赖它);cleanQuery 丢弃 null/'',
+      // 没有 extra 时 query 是 {},与改动前的「只跳转不过滤」完全等价。
+      // 顺手吞掉 NavigationDuplicated:重复点击「查看台账」不应该弹错误提示。
+      if (view === 'stockLedger') return this.$router.push({ path: '/warehouseManagement/stockLedger', query: cleanQuery(extra) }).catch((e) => { if (e.name !== 'NavigationDuplicated') this.$message.error(e.message); });
       const filters = { ...this.filters, page: 1, ...extra };
       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';
@@ -98,7 +122,7 @@ export default {
       try {
         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 });
+        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, inventoryValue: data && data.inventoryValue, pricedValuation: data && data.pricedValuation, unpricedBatchCount: data && data.unpricedBatchCount, valuationCoverage: data && data.valuationCoverage, inventoryValueChangeRate: data && data.inventoryValueChangeRate, inventoryValueComparisonReason: data && data.inventoryValueComparisonReason });
         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) {
@@ -167,11 +191,25 @@ export default {
       } catch (e) { this.$message.error(e.message || '授权入口加载失败'); }
     },
     business(kind,row) {
-      if (['purchase','threshold','disposal','disposal-list','finance'].includes(kind)) return this.$refs.actions.open(kind,row);
+      if (['purchase','threshold','disposal','disposal-list','finance','transfer'].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('stockLedger') : this.$refs.actions.open('purchase',row);
+      // 预警行动作按状态分流:超储行的闭环是「发起调拨」(把超储量调到缺料仓库),不是补货也不是跳库存台账;
+      // 缺料/偏低/未知行才走补货弹层,默认采购数量取该行缺口量 gapQuantity。
+      // 原来超储行直接 open('stockLedger') 会把这一行的 gapQuantity/gapAmount/status 全丢掉,用户还得在台账里重新找行。
+      if (kind === 'alerts') {
+        const target = row && row.status === 'excess' ? 'transfer' : 'purchase';
+        homeLog('alerts 行动作分流', { status:row && row.status, action:target, warehouseId:row && row.warehouseId, categoryId:row && row.categoryId, gapQuantity:row && row.gapQuantity, purchaseRequestCode:(row && row.purchaseRequestCode) || null });
+        return this.$refs.actions.open(target,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' });
+      // 打开采购需求:savedPurchase 是刚存下的需求(id 就是需求主键),而预警行只有后端台账回传的
+      // purchaseRequestCode(business_code)。只带单号时不能拿 row.id(那是预警行主键)冒充 requirementId,
+      // 否则会打开另一张需求单;此时只透传单号,让采购需求页按单号自行定位。
+      if (kind === 'purchase-document') {
+        const requirementId = row.purchaseRequestId || row.requirementId || (row.purchaseRequestCode ? '' : row.id);
+        homeLog('打开采购需求', { requirementId:requirementId || null, purchaseRequestCode:row.purchaseRequestCode || null, hasId:!!requirementId });
+        return this.portal((n) => /purchasingManage\/purchaseNeedManage/.test((n.component || '') + (n.path || '')), { requirementId, requirementCode:row.purchaseRequestCode || row.requirementCode, code:row.purchaseRequestCode, 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 });

+ 188 - 9
src/views/warehouseManagement/index/detail.vue

@@ -4,7 +4,8 @@
     <div class="drill-head"><h2>{{ current.title }}</h2><p>{{ current.subtitle }}</p></div>
     <div class="def-strip"><b>口径说明:</b><span>{{ current.definition }}<br v-if="result.reason"><span v-if="result.reason">{{ result.reason }}</span></span></div>
     <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>
+      <!-- 数值走 metricValue:百分点差值(qualityChange)自带符号与 pp,模板不再追加单位小字(避免 +1.20pppp)。 -->
+      <div v-for="key in current.metrics" :key="key" class="card kpi"><div class="lb">{{ metricLabels[key][0] }}</div><div class="val">{{ metricValue(key) }}<small v-if="summary[key] != null && metricLabels[key][1]">{{ metricLabels[key][1] }}</small></div><div class="delta dim">{{ summary[metricLabels[key][2]] || '' }}</div></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>
@@ -12,11 +13,18 @@
         <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'].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>
+        <!-- qc 明细页不渲染可编辑的开始/结束日期:后端 queryHomeDetail 对 qc 强制固定本月(YearMonth.now()),
+             传 startTime/endTime 也不会生效——静默失效的筛选比没有筛选更糟,因此改为下面的只读口径提示。
+             其它 period 视图(io/age/turnover/dead)条件不变,仍是可编辑日期区间。 -->
+        <label v-if="current.period && !['flow', 'qc'].includes(view)"><span>开始日期</span><input v-model="startDate" type="date"></label><label v-if="current.period && !['flow', 'qc'].includes(view)"><span>结束日期</span><input v-model="endDate" type="date"></label>
+        <!-- 只读提示占用原日期筛选位置:复用 <label>/<span> 现有结构,不新增样式;时间窗由后端固定,不给输入控件。 -->
+        <label v-if="view === 'qc'" title="较上月为同口径的百分点差值;本页时间窗由后端固定,不接受自定义区间"><span>统计区间</span>本月(检验完成日期,1 号至今)</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="view === 'age'"><span>库龄</span><select v-model="query.age"><option value="">全部</option><option value="0-30">0-30 天(≤30 天)</option><option value="31-90">31-90 天</option><option value="91-180">91-180 天(≤180 天)</option><option value="180+">&gt;180 天(与呆滞口径同界)</option><option value="unknown">日期待核实(不参与四段占比)</option></select></label>
+                <!-- 预警类型与新口径 statusLabel 逐字对齐:unknown=待核实(无唯一有效阈值配置或可用量为负),
+             不再是旧口径的「未配置」——它同时覆盖「同仓同物料重复阈值」「基本单位不一致」「锁定量超过余额」等几种情形。 -->
+        <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="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>
@@ -27,22 +35,84 @@
       </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', 'batch', row)">批次流水</a></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, 'aging-overdue': column.key === 'ageDays' && agingStagnant(row), 'alert-state': view === 'alerts' && column.key === 'status' }" :style="alertCellStyle(row, column.key)" :title="cellTitle(row, column.key)">{{ cell(row, column.key) }}<span v-if="column.key === 'dueTime' && row.overdue" class="badge-overdue">逾期</span></td><td class="row-actions">
+          <!-- 非预警视图沿用原来的单一动作入口(io/dead/age/... 行为一字不变)。 -->
+          <a v-if="view !== 'alerts'" class="link" href="#" @click.prevent="$emit('business', view, row)">{{ current.action }}</a>
+          <!-- 预警行的闭环动作按状态分流(优先级与理由见脚本里的 alertActionOf):超储=发起调拨、待核实=核对阈值、
+               已生成申请=只给「打开采购需求」(不再出现「生成采购申请」,防重复下单的第一道防线在列表侧)、
+               缺料/偏低=生成采购申请。配置阈值只在当前行还有阈值可改时出现,避免与「核对阈值」两个入口并存。 -->
+          <template v-if="view === 'alerts'">
+            <span v-if="row.alertAction.code" class="alert-requested" :title="'已生成采购申请(t_wms_dashboard_command,command_type=PURCHASE):' + row.alertAction.code">已生成申请 {{ row.alertAction.code }}</span>
+            <a v-if="row.alertAction.primary" class="link" href="#" @click.prevent="alertBusiness(row, row.alertAction.primary)">{{ row.alertAction.primaryLabel }}</a>
+            <a v-if="row.alertAction.document" class="link" href="#" @click.prevent="alertBusiness(row, 'purchase-document')">打开采购需求</a>
+            <span v-if="!row.alertAction.primary && !row.alertAction.code" class="alert-no-action">—</span>
+            <a v-if="row.alertAction.primary !== 'threshold'" class="link" href="#" @click.prevent="alertBusiness(row, 'threshold')">配置阈值</a>
+          </template>
+          <a v-if="view === 'dead'" class="link" href="#" @click.prevent="$emit('business', 'batch', row)">批次流水</a><a v-if="view === 'age' && agingStagnant(row)" class="link aging-dispose" href="#" title="与呆滞口径联动:库龄越过 >180 天段即可建呆滞处置单" @click.prevent="disposeAge(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>
+      <!-- 预警口径条:新口径必须写在列表页上,否则「可用量为什么小于在库量」「缺口金额为什么是待补价」只能靠猜。
+           首页卡片与这里同一口径,文案与后端 alertRows 逐条对齐。 -->
+      <div v-if="view === 'alerts'" class="alert-scope">口径:可用量=在库量−锁定量(含待检、未放行批次)· 缺口量=安全库存−可用量(不小于 0,仅缺料/偏低有意义)· 缺口金额=缺口量×移动加权不含税单价,单价缺失显示「待补价」且不计入合计(绝不按 0 计)· 按缺口金额倒序,缺价行排最后,其次 缺料→偏低→超储→待核实</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>
   </section>
 </template>
 <script>
-import { displayNumber } from './dashboard-utils';
+import { displayNumber, fillStagnantDays, formatPpChange, isStagnantAge, alertColorOf, alertNumber, alertGapAmountReason, formatAlertAmount, formatAlertQuantity, logAlertGapAmountMissing, logAlertDrill, homeLog } from './dashboard-utils';
 import { screens, metricLabels } from './dashboard-screens';
+
+// 预警行闭环动作分流(纯函数,便于断言):不同状态的闭环动作完全不同,按钮必须跟着状态走,
+// 否则会出现「超储行点补货把库存继续推高」「待核实行点补货被后端 hasReplenishmentAlert 直接拒绝」这类死路。
+// 优先级逐条命中即停,理由与后端可补货判定保持一致:
+// ① status='unknown'(无唯一有效阈值配置或可用量为负)→「核对阈值」:阈值根本不成立,先修口径,不给补货/调拨入口;
+// ② status='excess' →「发起调拨」:超储的闭环是把多的调给缺的(目标仓库由 dashboard.vue 按缺口最大的缺料行推),不是继续采购;
+// ③ purchaseRequestCode 非空(已生成申请)→ 只给「打开采购需求」:这是防重复下单的第一道防线,
+//    列表侧不能再出现第二个下单入口,单号只做标记透传(不动 requirementId,行里没有需求主键);
+// ④ critical/warning →「生成采购申请」;
+// ⑤ 兜底(normal 或未识别状态)→ 不给任何动作,绝不凭空造一个点了就失败或重复下单的按钮。
+const alertActionOf = (row) => {
+  const status = row && row.status != null ? String(row.status).trim() : '';
+  const code = row && row.purchaseRequestCode != null ? String(row.purchaseRequestCode).trim() : '';
+  // code 只用于行尾「已生成申请 单号」标记:只要后端回传了单号就展示(含超储/待核实行),
+  // document 决定是否额外给「打开采购需求」入口(已作为主按钮时不重复渲染)。
+  if (status === 'unknown') return { code, primary: 'threshold', primaryLabel: '核对阈值', document: code !== '' };
+  if (status === 'excess') return { code, primary: 'transfer', primaryLabel: '发起调拨', document: code !== '' };
+  if (code) return { code, primary: 'purchase-document', primaryLabel: '打开采购需求', document: false };
+  if (status === 'critical' || status === 'warning') return { code, primary: 'purchase', primaryLabel: '生成采购申请', document: false };
+  return { code, primary: '', primaryLabel: '', document: false };
+};
+
 export default {
   name: 'WarehouseDashboardDetail',
-  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 },
+  // 字段名与后端契约一致(stagnantDays):由 dashboard.vue 从同一份 home-summary 透传,
+  // 用于替换口径条里的 {stagnantDays} 占位符,不额外请求接口。
+  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, stagnantDays: { type: Number, default: null } },
   data() { return { query: {}, selected: [], metricLabels }; },
   computed: {
-    current() { return screens[this.view]; }, rows() { return this.result.records || this.result.list || []; },
+    // 口径条(副标题 + 口径说明):把 {stagnantDays} 换成与首页卡片同一配置项的实际阈值。
+    // 阈值取自 props.stagnantDays(首页已请求到的 home-summary.stagnantDays),缺失时回落默认 180 天;
+    // 不含占位符的屏幕原样返回,文案与改动前完全一致,也不会漏出字面量 {stagnantDays}。
+    current() {
+      const screen = screens[this.view];
+      if (!screen) return screen;
+      const subtitle = fillStagnantDays(screen.subtitle, this.stagnantDays);
+      const definition = fillStagnantDays(screen.definition, this.stagnantDays);
+      // 只有真的发生过替换才记日志,避免每次切屏都刷一条无信息量的记录。
+      if (subtitle !== screen.subtitle || definition !== screen.definition) {
+        const usable = typeof this.stagnantDays === 'number' && this.stagnantDays > 0;
+        homeLog('口径条呆滞阈值已替换', { view: this.view, stagnantDays: this.stagnantDays, usedDays: fillStagnantDays('{stagnantDays}', this.stagnantDays), fallbackDefault: !usable });
+      }
+      return { ...screen, subtitle, definition };
+    },
+    // 明细行:预警视图给每行预计算一次闭环动作(模板要读 primary/primaryLabel/code/document 四个字段,
+    // 逐个调 alertActionOf 会让同一行在一次渲染里被算四遍);其它视图原样返回,行为与改动前完全一致。
+    rows() {
+      const list = this.result.records || this.result.list || [];
+      // 非预警视图或数据形态异常时原样返回:新增的 .map 不能把「后端返回了非数组」变成一次渲染崩溃。
+      if (this.view !== 'alerts' || !Array.isArray(list)) return list;
+      return list.map((row) => ({ ...row, alertAction: alertActionOf(row) }));
+    },
     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' : ''); } }
@@ -50,20 +120,129 @@ export default {
   watch: { filters: { immediate: true, deep: true, handler(v) { this.query = { ...v }; } }, result() { this.selected = []; } },
   methods: {
     show: displayNumber,
+    // 明细 KPI 条数值:百分点差值(qualityChange)必须带显式符号与 pp 单位,直接复用公共的 formatPpChange
+    // (+1.20pp / -1.20pp / 0 → 持平 / null → —),单位已含在文本里,模板因此不再追加单位小字;
+    // 其余指标仍走 displayNumber,行为与改动前逐位一致。
+    metricValue(key) {
+      if (key === 'qualityChange') return formatPpChange(this.summary[key]);
+      return displayNumber(this.summary[key]);
+    },
     search() { if (this.query.startTime && this.query.endTime && this.query.startTime > this.query.endTime) return this.$message.warning('开始日期不能晚于结束日期'); this.$emit('search', { ...this.query, page: 1 }); },
     page(offset) { this.$emit('search', { ...this.filters, page: Number(this.filters.page || 1) + offset }); },
     changeType(type) { this.$emit('search', { ...this.filters, type, page: 1 }); },
     selectAll(checked) { this.selected = checked ? this.rows.slice() : []; },
+    // >180 天段的行(ageDays 越过后端固定分段 180+ 的红线):库龄单元格红色 + 给「建呆滞处置单」入口。
+    // 阈值与呆滞默认阈值同界,只做视觉强调,不在前端重算库龄;ageDays 缺失一律 false(日期待核实不染红)。
+    agingStagnant(row) { return isStagnantAge(row && row.ageDays); },
+    // 预警动作点击:事件名严格按 dashboard.vue 的契约分流——
+    // transfer→调拨弹层、purchase→补货弹层(默认采购数量取该行 gapQuantity)、
+    // purchase-document→既有采购需求页(只透传 purchaseRequestCode)、threshold→阈值弹层。
+    // 日志带行关键字段且同一「动作+行」只打一次,避免连点把控制台刷满。
+    alertBusiness(row, action) {
+      if (!row || !action) return;
+      logAlertDrill('预警动作 ' + action, {
+        id: row.id, categoryId: row.categoryId, warehouseId: row.warehouseId,
+        status: row.status, gapQuantity: row.gapQuantity, gapAmount: row.gapAmount,
+        purchaseRequestCode: row.purchaseRequestCode || null, action
+      });
+      this.$emit('business', action, row);
+    },
+    // 预警类型列配色:优先取后端下发的 row.color(三色阈值只由后端判定,改系数后颜色自动跟着变);
+    // 缺失时走 dashboard-utils 的统一兜底(同时打一条首页前缀的说明日志,不静默兜底)。
+    alertCellStyle(row, key) {
+      if (this.view !== 'alerts' || key !== 'status') return null;
+      const color = alertColorOf(row);
+      return color ? { color } : null;
+    },
+    // 单元格提示:只给库龄相关列补 title,其余列返回空串,不制造无信息的悬浮框。
+    cellTitle(row, key) {
+      if (key === 'firstInboundTime') {
+        // ageSource='warehouse' 而首次入库日期又有值,后端 batchRows 里只有两种真实来源:
+        // ① provenance_status<>1(来源不可追溯):即使日期是过去的也不认它;
+        // ② provenance_status=1 但 first_inbound_time>NOW()(未来日期):未来日期不参与起算。
+        // 两种情况列里都照实显示原始日期,但提示必须同时说明这两种可能,并指明库龄实际按仓库配置的
+        // 库龄起算日起算——只写「晚于今天」会把「来源不可追溯」误说成日期在未来,因果说窄了。
+        if (row.firstInboundTime) {
+          return row.ageSource === 'warehouse'
+            ? '批次首次入库日期不参与起算(来源不可追溯或晚于今天),库龄按仓库配置的上线日(库龄起算日)起算(后端 ageSource=warehouse)'
+            : '库龄按首次入库日期起算;调拨、拆合包不重置库龄';
+        }
+        return row.ageSource === 'warehouse'
+          ? '期初数据无真实入库日期,按仓库配置的上线日起算库龄(后端 ageSource=warehouse)'
+          : '首次入库日期缺失,库龄日期待核实';
+      }
+      // 最后入库日期只做展示:补库会刷新它,但库龄永远按首次入库日期起算,不因补库变年轻。
+      if (key === 'lastInboundTime') return '最后入库日期仅展示,补库不刷新库龄(库龄始终按首次入库日期起算)';
+      if (key === 'ageDays' && isStagnantAge(row.ageDays)) return '库龄已越过 >180 天段,与呆滞口径联动,可从右侧建呆滞处置单';
+      if (key === 'ownerName') return '负责人取仓库负责人(后端 ownerName),不是批次经手人';
+      // F1 预警列的口径提示:每一列为什么这么算、null 是什么含义都写在这里,避免用户对着数字反推口径。
+      if (key === 'availableQuantity') return '可用量=在库量−锁定量(按仓库+物料聚合,含待检、未放行批次,不排除未放行)';
+      if (key === 'lockedQuantity') return '锁定量=已占用未出库数量;可用量=在库量−锁定量';
+      if (key === 'gapQuantity') return '缺口量=安全库存−可用量(不小于 0);仅缺料、偏低行有意义,其余为 0';
+      if (key === 'gapAmount') return row.gapAmount == null
+        ? alertGapAmountReason(row)
+        : '缺口量 × 移动加权平均不含税单价;单价缺失时显示「待补价」,绝不按 0 计';
+      if (key === 'workOrderCount') return '该行缺料经 BOM 展开关联的未完工工单(待生产/生产中/已延期)去重数';
+      if (key === 'purchaseRequestCode') return row.purchaseRequestCode
+        ? '已生成采购申请(t_wms_dashboard_command,command_type=PURCHASE,command_key=仓库:物料)'
+        : '未生成采购申请(purchaseRequestCode 为空,不代表无需补货)';
+      if (key === 'secureInventory') return '安全库存=触发基准:可用量<安全库存为缺料,安全库存≤可用量<安全库存×偏低系数为偏低';
+      if (key === 'maxInventory') return '最大库存允许为空:为空时按 安全库存×超储兜底系数(默认 3)判定超储;非空时必须≥安全库存';
+      if (key === 'minInventory') return '最低库存仅作展示列,不参与触发判定';
+      if (key === 'status') return '状态由后端短路判定:待核实(无唯一有效阈值配置或可用量为负)→ 缺料 → 偏低 → 超储 → 正常;配色取后端 color';
+      return '';
+    },
+    // >180 天行的「建呆滞处置单」:复用 dead 视图既有的处置动作——dashboard.vue 的 business('dead', row)
+    // 直接打开 DashboardActions 的 disposal 弹层(同一套表单、路由参数与保存链路),不新造页面、不另写提交逻辑。
+    disposeAge(row) {
+      homeLog('E1 库龄明细建呆滞处置单', { categoryId: row.categoryId, warehouseId: row.warehouseId, batchNo: row.batchNo, systemBatchNo: row.systemBatchNo, ageDays: row.ageDays });
+      this.$emit('business', 'dead', row);
+    },
     cell(row, key) {
       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 === 'dueTime' && !value || key === 'ageDays' && value == null) return '日期待核实';
+      // 首次入库日期缺失必须区分两种口径:ageSource='warehouse' 是「期初数据按仓库上线日起算」,
+      // 这是后端给出的真实起算日,不是补造;ageSource 为空才是「日期待核实」。
+      // 两者都写成「日期待核实」会让用户以为上线日口径也失效,进而怀疑整张库龄表的可信度。
+      if (key === 'firstInboundTime') return value ? String(value) : row.ageSource === 'warehouse' ? '按上线日起算' : '日期待核实';
       if (key === 'qualityStatus') return { 0:'无需质检',1:'待检',2:'已检' }[value] || '待核实';
       if (key === 'qualityResult') return { 1:'合格',2:'不合格',3:'让步接收' }[value] || '待核实';
+      // F1 预警列:缺口金额缺失显示「待补价」而不是 0(单价缺失≠缺口不值钱),并打一次去重日志便于定位是哪一行缺价;
+      // 影响工单数、采购申请一律「有值才展示」,缺失给「—」,绝不拿 0 或「未生成」冒充后端没给的数据。
+      if (key === 'gapAmount') {
+        logAlertGapAmountMissing(row);
+        const amount = alertNumber(row.gapAmount);
+        return amount == null ? '待补价' : formatAlertAmount(amount) + ' 元';
+      }
+      if (key === 'workOrderCount') return row.workOrderCount == null ? '—' : formatAlertQuantity(row.workOrderCount) + ' 单';
+      // 采购申请列只显示单号:「已生成申请 单号」这个状态标记统一放在操作列(见模板的 row.alertAction),
+      // 同一行不重复两遍同样文案;空值仍是「—」(null 表示未生成申请,不代表无需补货)。
+      if (key === 'purchaseRequestCode') return row.purchaseRequestCode ? String(row.purchaseRequestCode) : '—';
       const labels = { critical: '缺料', warning: '偏低', excess: '超储', passed: '合格', failed: '不合格', concession: '让步接收', pending: '待检', none: '无需质检', unknown: '待核实', in: '待入库', out: '待出库', central: '入总库', inventory: '盘点' };
+      // 预警类型优先用后端下发的 statusLabel(契约第 8 项):状态是后端短路判定的结果,
+      // 前端只做展示,未识别值一律落到「待核实」,绝不猜成某个已知状态。
+      if (key === 'status') return (row.statusLabel != null && String(row.statusLabel).trim()) || labels[value] || '待核实';
       if (['qualityStatus','qualityResult','status','type'].includes(key) && labels[value]) return labels[value];
       return typeof value === 'number' ? displayNumber(value) : value == null || value === '' ? '—' : String(value);
     }
   }
 };
 </script>
+<style scoped>
+/* >180 天的库龄单元格红色:与首页「库龄结构」的 >180 段、呆滞卡片的 alert 同一色阶(#F25E5E),
+   扫表时一眼能挑出呆滞候选,不必逐行读数字。 */
+table.tbl td.aging-overdue { color: #F25E5E; font-weight: 600; }
+/* 处置入口沿用行内 .link 的既有样式,只补左间距(row-actions 的 a + a 规则同样生效)。 */
+.aging-dispose { color: #F25E5E; }
+/* 预警口径条:中性小字,与表头同宽,不抢数据视觉;表下说明而不是弹层,扫表时随手可核对。 */
+.alert-scope { padding: 8px 18px 0; font-size: 11px; line-height: 1.7; color: #9FB8D4; }
+/* 预警类型列加粗:三色(缺料红/偏低橙/超储紫/待核实灰)由 alertCellStyle 内联下发,这里只管字重。 */
+table.tbl td.alert-state { font-weight: 600; }
+/* 行尾「已生成申请」标记:与首页预警卡同一视觉(紫色描边小标签),提示该行已经下过单、不要再重复发起;
+   标记只做识别,不承担禁用逻辑(禁用视觉态在动作弹层里)。 */
+.row-actions .alert-requested { display: inline-block; margin-right: 8px; padding: 0 6px; border: 1px solid rgba(155,125,247,.55); border-radius: 9px; color: #C9B6FF; font-size: 11px; white-space: nowrap; }
+/* 兜底占位:既没有缺料也没有已生成申请的行(normal/未识别状态)显示「—」,
+   避免操作列空白被读成「这一行还没渲染完」。 */
+.row-actions .alert-no-action { color: #9FB8D4; }
+</style>

+ 75 - 7
src/views/warehouseManagement/stockLedger/index.vue

@@ -80,9 +80,20 @@
         currentId: '',
         treeList: [],
         treeLoading: false,
+        // 路由下钻参数(首页 D2 分类分布点击后带的 categoryLevelId=t_main_category_level.id)。
+        // 只消费一次:进入页面时用于选中左侧树并过滤首屏列表,之后切维度/手动点树都按用户自己的选择走。
+        pendingCategoryLevelId: '',
       };
     },
     async created() {
+      this.pendingCategoryLevelId = this.readCategoryLevelId();
+      // 预置选中态:item-list 的表格在 mounted 时就用 this.$parent.$parent.$parent.current.id 发首屏请求,
+      // 而分类树是异步接口,等树回来再选中就已经错过首屏。这里先只放 id,
+      // 等树回来由 applyCategoryLevelId() 补成 { id, data } 并同步左侧树高亮
+      // (补 data 而不是整体替换 current,避免 item-list 的监听多发一次相同请求)。
+      if (this.pendingCategoryLevelId && this.currentTab === 'products') {
+        this.current = { id: this.pendingCategoryLevelId };
+      }
       this.getTreeData();
     },
     methods: {
@@ -111,20 +122,77 @@
           this.treeLoading = false;
           if (res?.code === '0') {
             this.treeList = res.data;
-            // this.$nextTick(() => {
-            //   // 默认高亮第一级树节点
-            //   if (this.treeList[0]) {
-            //     this.$refs.treeRef.setCurrentKey(this.treeList[0].id);
-            //     this.current = this.treeList[0];
-            //   }
-            // });
+            // 分类树就绪后再收口路由下钻参数:树没回来时无法判断该 id 是否存在,
+            // 提前选中只会选中一个不存在的层级(详见 applyCategoryLevelId)。
+            this.applyCategoryLevelId();
             return this.treeList;
           }
+          // 树接口没给成功码:同样按「找不到节点」降级,别让下钻参数把列表锁死在空结果上。
+          this.degradeCategoryLevelId(this.pendingCategoryLevelId, '分类树接口未返回成功码');
         } catch (error) {
           console.log(error);
+          this.degradeCategoryLevelId(this.pendingCategoryLevelId, '分类树请求失败');
         }
         this.treeLoading = false;
       },
+      // 只认字符串 id:query 里可能是数组(同参数多值),取第一个;空值一律当作没有下钻参数。
+      readCategoryLevelId() {
+        const raw = this.$route && this.$route.query ? this.$route.query.categoryLevelId : '';
+        const value = Array.isArray(raw) ? raw[0] : raw;
+        return value == null || value === '' ? '' : String(value);
+      },
+      // 递归查找分类树节点:t_main_category_level 是多层自关联结构,根层级 id 不一定落在第一层,
+      // 必须整棵查找;找不到返回 null 交给调用方降级。id 统一转字符串比较:
+      // query 里拿到的是字符串,树里可能是数字,不转类型会永远对不上。
+      findTreeNode(nodes, id) {
+        for (const node of nodes || []) {
+          if (!node) continue;
+          if (node.id != null && String(node.id) === String(id)) return node;
+          const found = this.findTreeNode(node.children, id);
+          if (found) return found;
+        }
+        return null;
+      },
+      // D2 下钻收口:首页分类分布点击后带 ?categoryLevelId=xxx 进来,在树里找到该节点后
+      // 1) 左侧树选中并展开到可见;2) 让 item-list 的 batchPage 以该分类层级过滤。
+      applyCategoryLevelId() {
+        const id = this.pendingCategoryLevelId;
+        if (!id) return;
+        // 一次性消费:下钻参数只在进入页面时生效,之后切维度/刷新都按用户自己的选择走,
+        // 否则用户手动改选后的分类会被旧 query 反复拉回。
+        this.pendingCategoryLevelId = '';
+        const node = this.currentTab === 'products' ? this.findTreeNode(this.treeList, id) : null;
+        if (!node) return this.degradeCategoryLevelId(id, '分类树中未找到该分类层级');
+        console.log('[wms-stock-ledger] 分类下钻已选中', { categoryLevelId: node.id, name: node.name, source: 'route.query.categoryLevelId' });
+        this.currentId = node.id;
+        // current 在 created 里已用 { id } 预置(首屏请求据此过滤),这里补 data 而不是整体替换:
+        // 整体替换会再触发 item-list 的 current 监听,多发一次参数完全相同的 batchPage 请求。
+        // 形状 { id, data } 是硬要求——item-list 的 datasource() 读 current.id、
+        // reload() 读 current.data?.id,少一个都会退化成不带分类过滤的请求。
+        this.$set(this.current, 'data', node);
+        this.$nextTick(() => {
+          const tree = this.$refs.treeRef;
+          if (!tree) return;
+          tree.setCurrentKey(node.id);
+          // setCurrentKey 只记「当前节点」,节点若落在折叠的父节点下并不会显示高亮:
+          // 沿 el-tree 节点的 parent 链把祖先逐个展开,保证下钻进来第一眼就能看到被选中的分类。
+          const store = tree.store || {};
+          let parent = store.nodesMap && store.nodesMap[node.id] ? store.nodesMap[node.id].parent : null;
+          while (parent) {
+            parent.expanded = true;
+            parent = parent.parent;
+          }
+        });
+      },
+      // 降级:分类被停用/无权限/树接口异常导致找不到节点时,既不报错也不白屏,也不能继续按一个
+      // 用户看不见的分类过滤列表。清空选中即回到 V2 台账默认的「不过滤」行为,并留一条说明日志。
+      degradeCategoryLevelId(id, reason) {
+        if (!id) return;
+        this.pendingCategoryLevelId = '';
+        this.current = {};
+        this.currentId = '';
+        console.warn('[wms-stock-ledger] 分类下钻降级为不过滤', { categoryLevelId: id, reason, treeNodes: (this.treeList || []).length });
+      },
       handleNodeClick(data, node) {
         if (data.id == this.currentId) {
           this.$refs.treeRef.setCurrentKey(null);

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor