Kaynağa Gözat

docs(s5): define inventory source convergence plan

Link IQC UAT evidence to the implementation plan and capture the required decisions, migration stages, and rollback boundaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 1 ay önce
ebeveyn
işleme
bfaa119b0b

+ 319 - 0
doc/plan/S5库存口径收敛(现存量与出入库)方案与执行任务书.md

@@ -0,0 +1,319 @@
+# S5 · 库存口径收敛(现存量与出入库)方案与执行任务书
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 让「同一个库存数字在 Ai-DOP 全站只有一个来源、一个公式、一个时点」:现存量与出入库流水收敛到 **165 为唯一权威事实源 + MDP 分层(stg→std)为唯一读模型**,下线本库三张冻结镜像表(`LocationDetail`/`InvTransHist`/`InvMaster`) |
+| 编写日期 | 2026-08-12 |
+| 状态 | **待决策**(第 10 节 6 个待定项未确认前,不得开工 WP-INV-1 之后的任何一项) |
+| 直接触发 | 2026-08-11 夜 IQC 端到端跑通后,165 上 500 已从待检库位 `1000` 上架到 `1001`,但 S5「库存查询」按物料 `81HC0854` / 批 `260811014` 查不到、「进出存查询」也看不到那两笔 `iss-tr-ins`。见 [`UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md`](./UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md) 的「缺口」小节 |
+| 关联 | [`旧DOP-MES-WMS/对接任务书/WP1-入站抽数补齐.md`](./旧DOP-MES-WMS/对接任务书/WP1-入站抽数补齐.md)、[`WP8-实时回读热链路.md`](./旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md)、[`WP6-对账与运维.md`](./旧DOP-MES-WMS/对接任务书/WP6-对账与运维.md)、[`00B-执行须知(模型输入契约).md`](./旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md)、[`MDP贴源层租户映射缺失修复任务书.md`](./MDP贴源层租户映射缺失修复任务书.md)、[`租户隔离收口与造数前置任务书.md`](./租户隔离收口与造数前置任务书.md) |
+| 环境 | 165:SQL Server `123.60.180.165` / `dopdemorq`(源系统码 `DOPDEMORQ_SQLSERVER`);Ai-DOP:MySQL `123.60.180.165` / `aidopdev`;租户 `797403760988229`;Domain `8010` |
+| 端到端断言样本 | 物料 `81HC0854` / 批 `260811014` / 库位 `1001` / 500 / 箱码 `10003232-20260811-00010020001`;流水两笔 `iss-tr-ins`(`1001` +500、`1000` −500,2026-08-11 23:07) |
+| 红线 | 旧 DOP 已停运,**只做移植参考,不得在旧 DOP 上做任何操作**;本书不改 165 的表结构与存储过程 |
+
+---
+
+## 1. 结论摘要
+
+Ai-DOP 现在有 **三套并行的"库存数量"**,互不同步,且对外的两个库存页面读的是**最旧的那一套**:
+
+| # | 数据面 | 物理位置 | 现况 | 谁在用 |
+|---|--------|----------|------|--------|
+| ① | legacy 影子镜像 | `aidopdev.LocationDetail` / `InvTransHist` / `InvMaster` | **冻结快照**,运行时无任何增量写入 | S5 库存查询、S5 进出存查询、齐套、采购合并、工单齐套、发货批次量 |
+| ② | 165 实时 | `dopdemorq.LocationDetail` / `InvTransHist` | 唯一真实账,每次 WMS 过账即变 | 齐套在 `MesTenantId` 命中时走实时读(`Mes165StockReader`);IQC 上架经存储过程写入 |
+| ③ | Ai-DOP 自有 L3 影子 | `ado_inventory_master` / `ado_inventory_location_detail` / `ado_inventory_transaction` | **0 行**,仅测试路径写过,无页面以它为准 | 无 |
+
+而 MDP 的正规通道(贴源层 `mdp_stg_inventory` / `mdp_stg_inv_trans` → 标准层)**是空的**:四个 S5 库存类实体虽然 `status=1`,但 `job_id` 为 `NULL`、`last_sync_to` 为 `NULL`,从来没有任何定时任务拉过它们。
+
+**收敛方向(本书主张)**:
+
+1. **权威源唯一**:库存余额与流水的权威永远是 165,Ai-DOP 不再自建"第二本账";
+2. **读模型唯一**:Ai-DOP 侧一切库存读取只允许两个入口——**标准层 `mdp_std_inventory` / `mdp_std_inv_trans`**(带 as-of 时点),或**经统一接口的 165 实时读**;
+3. **legacy 镜像下线**:`LocationDetail`/`InvTransHist`/`InvMaster` 三表从"被查询的表"降级为"历史归档",最终改名/归档,代码引用清零;
+4. **`ado_*` 归位**:只作 IQC/收货过账的**留痕与对账**,明确写死"不得作为任何库存查询口径"。
+
+---
+
+## 2. 现状基线(实测 2026-08-12 00:40–00:50,可复现)
+
+### 2.1 数据面漂移(同一张表,两个库)
+
+| 表 | 165(权威) | aidopdev(镜像) | 漂移 |
+|----|-------------|------------------|------|
+| `LocationDetail` | 56,379 行;`MAX(UpdateTime)` = **2026-08-11 23:07:10** | 56,373 行;`MAX(UpdateTime)` = **2025-07-15 11:45:08** | 少 6 行、**滞后约 13 个月** |
+| `InvTransHist` | 1,001,723 行;`MAX(CreateTime)` = **2026-08-11 23:07:10** | 1,001,712 行;`MAX(EffDate)` = 2025-12-30、`MAX(CreateTime)` = **2025-12-31 09:57** | 少 11 行、**滞后约 7 个月** |
+| `InvMaster` | 16,022 行 | 18,368 行(**比源还多**,含历史/脏行) | 双向不一致 |
+
+镜像滞后正是 2026-08-11 夜 IQC 上架"165 已变、Ai-DOP 页面查不到"的直接原因(那 6 行/11 行差额里就包含本次样本)。
+
+### 2.2 MDP 通道现状
+
+```sql
+-- aidopdev 实测
+SELECT entity_code, source_table_name, target_table_name, sync_mode, incr_column, status, job_id, last_sync_to
+  FROM mdp_entity
+ WHERE entity_code IN ('S5_INV_MASTER_SQLSERVER','S5_INV_TRANS_HIST_SQLSERVER',
+                       'S5_LOCATION_DETAIL_SQLSERVER','S5_LABEL_MISSEDPRINT_SQLSERVER');
+```
+
+| entity_code | 源表 | 贴源表 | 模式 / 游标 | status | job_id | last_sync_to |
+|---|---|---|---|---|---|---|
+| `S5_LOCATION_DETAIL_SQLSERVER` | LocationDetail | `mdp_stg_inventory` | INCR / `UpdateTime` | 1 | **NULL** | **NULL** |
+| `S5_INV_MASTER_SQLSERVER` | InvMaster | `mdp_stg_inventory` | INCR / `UpdateTime` | 1 | **NULL** | **NULL** |
+| `S5_INV_TRANS_HIST_SQLSERVER` | InvTransHist | `mdp_stg_inv_trans` | INCR / `CreateTime` | 1 | **NULL** | **NULL** |
+| `S5_LABEL_MISSEDPRINT_SQLSERVER` | MissedPrint | `mdp_stg_label` | INCR / `UpdateTime` | 1 | **NULL** | NULL |
+
+行数:`mdp_stg_inventory` **0**、`mdp_stg_inv_trans` **0**、`mdp_stg_label` 8(这 8 行是 HotWatch 变更时顺带写的,不是冷链拉的)、`ado_inventory_location_detail` **0**、`ado_inventory_transaction` **0**。
+
+**结论:冷链(WP1)对库存三表从未真正跑过一次;标准层没有任何库存余额/流水表。** 注册种子在 `server/Admin.NET.Web.Entry/UpdateScripts/1.0.300.sql`(S5 库存/标签实体段)。
+
+### 2.3 谁在读 legacy 镜像(收敛必须逐个改完)
+
+| 读者 | 文件(符号为准,行号仅供定位) | 读表 | 语义 |
+|------|------|------|------|
+| S5 库存查询 | `MaterialWarehouse/StockQueryService.cs` | `LocationDetail`⋈`LocationMaster`,富集 `MissedPrint`/`ItemMaster` | 非限制/质检/冻结三列 |
+| S5 进出存查询 | `MaterialWarehouse/InoutQueryService.cs` | `InvTransHist` | 期初/入/出/期末 |
+| 齐套 | `Order/MaterialRequirementCalculator.cs`(并存 `Mes165StockReader`) | `InvMaster` 或 165 实时 | 可用量 |
+| 工单生成 | `Order/OrderWorkOrderGenerationService.cs` | `InvMaster` | 可用量 |
+| 采购需求合并 | `Supply/PurchaseRequestMergeService.cs` | `InvMaster` | 可用量 |
+| 采购管道 | `Supply/ProcurementPipelineService.cs` | `InvMaster`(限定库位白名单) | 可用量 |
+| 交付 | `Order/OrderDeliveryService.cs` | `InvTransHist`(`rct-wo` 完工入库) | 流水 |
+| 排程 | `Production/WorkOrderSchedulingService.cs` | `InvTransHist` | 流水 |
+| 供应商发货 | `ProcurementExecution/SupplierShipmentService.cs` | `InvTransHist`(批次收货量) | 流水 |
+
+另有三个"看着像库存其实不是"的页面,收敛时**不要顺手改口径**:暂收在检(`PendingInspectionService`,读 `MissedPrint` Status='I' 且 Location 空)、标签查询、盘点结果(账面 `Qty` vs 实盘 `CompQty`)。
+
+### 2.4 同一语义、三种公式(必须统一)
+
+| 场景 | 现公式 | 库位范围 |
+|------|--------|----------|
+| 库存查询列表 | 过滤 `(AvailStatusQty + Assay + FreezeQty) > 0`,分三列展示 | 全部 |
+| 齐套 | `SUM(COALESCE(AvailStatusQty, QtyOnHand))` | **全部库位** |
+| 采购管道 | `SUM(AvailStatusQty + Assay)` | **库位白名单**(1001/5007/…) |
+
+三者对同一物料会给出三个不同的"可用量"。**这是本书要收的核心口径债**,不是代码风格问题。
+
+### 2.5 租户 / Domain 现状
+
+| 环节 | 机制 | 位置 |
+|------|------|------|
+| 贴源写入 tenant | 源行 `tenant_id>0` 优先,否则 `ctx.TenantId`,两者皆无则**跳过该行**(不中断批次) | `DataPlatform/Executors/MdpStagingWriter.cs` |
+| 165 库存三表 | **无 `tenant_id` 列**,只有 `Domain` | 165 `dopdemorq` |
+| T8 账套 → 租户 | `AidopSourceTenantMap.ResolveTenantId("pbxfxp", 0)`,无映射抛错 | `Infrastructure/AidopSourceTenantMap.cs` |
+| S5 查询 Domain | `GeneralizedCodeMaster` 取 SystemConfig/CompanyCode,**默认回落 8010** | `StockQueryService.cs` |
+| 齐套走 165 的租户门 | 仅 `AiDOP:Stock:MesTenantId` 命中才允许 | `AidopStockOptions.cs` |
+
+→ **没有一张"Domain ↔ 租户"权威映射表**。库存冷链一旦开跑,每行都需要 domain→tenant 解析,否则整批被 `MdpStagingWriter` 跳过(静默 0 行,正是现在的隐患形态)。故 **WP-INV-0 是硬前置**。
+
+---
+
+## 3. 收敛后的口径定义(本书唯一定义,落地后写进 00B)
+
+### 3.1 分层与权威
+
+| 层 | 内容 | 物理表 | 谁可写 | 谁可读 |
+|----|------|--------|--------|--------|
+| L0 源 | 165 WMS 实时账 | `dopdemorq.LocationDetail` / `InvTransHist` / `MissedPrint` | **只有 165 自己**(Ai-DOP 只经存储过程过账,见 QcCheck 任务书) | 经 `MdpSourceScopeFactory` 的只读连接 |
+| L1 贴源 | 原样落地 + 批次/租户标记 | `mdp_stg_inventory`(LocationDetail 与 InvMaster 共表,`source_table` 区分)、`mdp_stg_inv_trans` | 冷链执行器 `MdpDbPullExecutor` + `MdpStagingWriter` | 仅 L2 转换 |
+| L2 标准 | 口径统一后的读模型 | **新增** `mdp_std_inventory`、`mdp_std_inv_trans` | 仅转换服务(全量替换语义 `MdpStdFullReplace`) | 所有页面/服务 |
+| L3 过账留痕 | IQC/收货过账的幂等与对账锚 | `ado_inventory_*`、`ado_iqc_inventory_posting` | 过账编排 | **仅对账**,禁止作为库存口径 |
+| 退役 | 历史归档 | `aidopdev.LocationDetail` / `InvTransHist` / `InvMaster` | **禁止写** | 迁移期只读,完成后归档 |
+
+### 3.2 字段口径(统一后只认这一套)
+
+| 口径名 | 定义 | 来源列 |
+|--------|------|--------|
+| 在库总量 `qty_on_hand` | 库位上的全部实物 | `LocationDetail.QtyOnHand` |
+| 非限制 `qty_unrestricted` | 可直接使用 | `AvailStatusQty` |
+| 质检 `qty_inspection` | 已上架但处于检验状态 | `Assay` |
+| 冻结 `qty_frozen` | 冻结/锁定 | `FreezeQty` |
+| **可用量 `qty_available`** | **待决策项 D-1**:候选 ① `AvailStatusQty`;② `AvailStatusQty + Assay` | 三处现公式必须收敛到同一个 |
+| 暂收在检 | **不属于库存余额**:`MissedPrint.Status='I'` 且 `Location` 为空的箱码数量之和 | `MissedPrint.Qty` |
+| 流水 | 期初 `BeginBalance`;`QtyChange` 符号派生入/出;期末 `BeginBalance + QtyChange` | `InvTransHist` |
+| 数据时点 `as_of` | L2 表的最后一次成功刷新时间,**必须在页面上显示** | 转换运行记录 |
+
+### 3.3 业务键(已核 165 唯一索引)
+
+- `LocationDetail` 唯一索引 `IX_LocationDetail` = `Domain, Location, LotSerial, ItemNum, Dimension1, Dimension2, Refs, Site`(实测这 8 列在 56,379 行中**无 NULL**),主键 `RecID`;
+- `InvTransHist`、`InvMaster` 主键均为 `RecID`;
+- 故贴源层:`source_row_id = RecID`,`biz_key_expr = Domain,Location,LotSerial,ItemNum,Dimension1,Dimension2,Refs,Site`(`MdpStagingWriter.BuildBizKey` 语义:任一列缺失即回落 `source_row_id`,落地后需抽样核对回落率为 0)。
+
+---
+
+## 4. 方案选型
+
+| 方案 | 做法 | 实时性 | 165 压力 | 实现量 | 与 00B 契约 | 结论 |
+|------|------|--------|----------|--------|-------------|------|
+| A 纯冷链定时 | 定时把 165 三表拉进 stg→std,页面只读 std | 分钟级滞后 | 周期批量 | 中 | 合规 | 报表可用,**扫码后立刻查不到**,UAT 体验差 |
+| B 冷链 + 热关注增量 | A + HotWatch 对在途单据把相关库位/批次即时刷 std | 秒级(仅在途) | 低 | 大 | 合规 | 效果最好但工作量最大,且热关注键是单据维、库存是库位维,映射复杂 |
+| C 页面直连 165 | 查询即读 165,Ai-DOP 不存 | 实时 | 每次查询打源库 | 小 | 需在 00B 记例外(已有 `Mes165StockReader` 先例) | 单点依赖 165,离线即不可用,报表/KPI 无法回溯 |
+| **D 混合(推荐)** | **余额类**:查询走"实时读 165 + std 兜底"双通道;**流水类**:走冷链增量(`InvTransHist` 只追加,天然适合 INCR);**标签/待检**:维持 HotWatch 现状 | 余额实时、流水分钟级 | 可控(余额查询走分页+索引) | 中 | 需在 00B 记一条例外并写清降级语义 | **采纳** |
+
+**为什么不选"定时刷本库 legacy 三表"**(看似最省事):违反 00B「本库不得把 165 业务表当权威镜像双写」,且会让"三套账"变成"三套都在动的账",对账成本更高;同时 `InvMaster` 本库比源还多 2,346 行,说明历史上已经因此产生过脏数据。
+
+**方案 D 的降级语义(必须实现)**:165 不可达时,余额查询自动回落 std 并在响应里标 `dataSource=STD`、`asOf=<时间>`,前端明显提示"非实时";**禁止静默返回旧数**。
+
+---
+
+## 5. 任务卡
+
+> 每张卡都必须自带:改动文件清单、SQL 脚本(若有)、验收命令/断言、留证落点、回滚方式。
+> 版本号规则见 [`.cursor/rules/version-bump-on-commit.mdc`](../../.cursor/rules/version-bump-on-commit.mdc);
+> 新增 `UpdateScripts/*.sql` 必须**先 `git fetch` 看远端最大脚本号再取号**、在 `.csproj` 登记 `CopyToOutputDirectory=Always`、脚本号 ≤ 当前 `<Version>`、启动后查 `sys_db_migration_log` 有 Success(2026-08-11 已因这三条各踩过一次,见留证 `30` 第三节)。
+
+### WP-INV-0 · 租户 × Domain 映射收口(硬前置)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 165 库存三表无 `tenant_id`,必须有权威 `Domain → tenant_id` 映射,否则冷链会静默跳过全部行 |
+| 前置阅读 | [`MDP贴源层租户映射缺失修复任务书.md`](./MDP贴源层租户映射缺失修复任务书.md)、[`租户隔离收口与造数前置任务书.md`](./租户隔离收口与造数前置任务书.md) |
+| 改动 | ① 落一张映射表(建议 `ado_source_domain_tenant_map`:`source_code, domain, tenant_id, status`,唯一键 `(source_code, domain)`);② `MdpStagingWriter` 的租户解析在"源行无 tenant_id"时查该映射(不得回落 0,也不得回落 8010);③ 种子写入 `DOPDEMORQ_SQLSERVER + 8010 → 797403760988229` |
+| 验收 | 对 165 取 100 行 LocationDetail 灌 stg,`SELECT COUNT(*) FROM mdp_stg_inventory WHERE tenant_id<=0` = 0;映射缺失时执行器**报错并计入失败**(不静默跳过) |
+| 回滚 | 映射表保留、解析逻辑加开关(默认关)即可回到现状 |
+
+### WP-INV-1 · 冷链真正跑起来(贴源层)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | `mdp_stg_inventory` / `mdp_stg_inv_trans` 从 0 行变成持续增量 |
+| 改动 | ① 新增 `MaterialWarehouse/InventoryMdpSyncService.cs`:仿 `PurchaseReceiptMdpSyncService` / `T8BaseInboundMdpSyncService` 的 `RunInboundAsync(tenantId, fullRefresh, entityCode, ct)`,按 `entity_code` 列表调 `IMdpSourcePullExecutor.PullAsync`;② 新增 `Job/S5InventoryInboundJob.cs`(`[JobDetail("job_s5_inventory_inbound")]` + `[Cron]`,频率见待决策 D-2),失败语义照 `S5MdpRefreshJob`:整轮硬门失败即结束、`OperationCanceledException` 上抛不吞;③ 手工补偿入口(Controller 一个 POST,参数 `entityCode`/`fullRefresh`);④ SQL 脚本设 `mdp_entity.biz_key_expr`(见 §3.3)与 `job_id`;⑤ 首轮全量、之后 INCR(`LocationDetail`/`InvMaster` 按 `UpdateTime`,`InvTransHist` 按 `CreateTime`) |
+| 注意 | `mdp_stg_inventory` 被 `LocationDetail` 与 `InvMaster` **共表**,一切查询必须带 `source_table`;`InvTransHist` 首轮全量 100 万行,务必按 `batch_size=2000` 分批并给出耗时留证 |
+| 验收 | ① `mdp_stg_inventory` 中 `source_table='LocationDetail'` 行数 ≈ 56,379(±当轮变动);② 用样本断言:`source_biz_key LIKE '8010#1001#260811014#81HC0854%'` 能查到且 `raw_data->>'$.AvailStatusQty'` = 500;③ `mdp_entity.last_sync_to` 推进;④ `biz_key` 回落 `RecID` 的行数为 0 |
+| 回滚 | 停 Job(`status=0`)+ `TRUNCATE` 两张 stg,不影响任何现有页面(现在没人读 stg) |
+
+### WP-INV-2 · 标准层(唯一读模型)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 新增 `mdp_std_inventory`、`mdp_std_inv_trans`,字段与口径按 §3.2 固定 |
+| 改动 | ① DDL(新版本 `UpdateScripts` 脚本):`mdp_std_inventory(tenant_id, domain, item_num, lot_serial, location, dimension1, dimension2, refs, site, qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, src_rec_id, as_of, sync_batch_id, …)`,唯一键 = 业务键 8 列 + tenant;`mdp_std_inv_trans(tenant_id, domain, trans_type, item_num, lot_serial, location, qty_change, begin_balance, eff_date, ord_nbr, work_ord, src_rec_id, …)`;② 转换服务写在 `InventoryMdpSyncService` 内(stg→std,余额表用 `MdpStdFullReplace` 全量替换、流水表用增量 UPSERT);③ 记录 `as_of`(本轮 std 数据时点),供页面展示与对账 |
+| 验收 | `mdp_std_inventory` 对样本行给出 `qty_unrestricted=500 @ 1001`;`mdp_std_inv_trans` 有那两笔 `iss-tr-ins`(+500/−500);与 165 同口径 `SUM` 差异为 0 |
+| 回滚 | 只加表不改读路径;`DROP` 两张 std 即回到 WP-INV-1 状态 |
+
+### WP-INV-3 · S5 两个页面切读模型
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 库存查询 / 进出存查询不再读 legacy 镜像 |
+| 改动 | ① `StockQueryService`:改为经统一接口取余额——实时通道读 165、兜底读 `mdp_std_inventory`(方案 D 双通道 + 降级标记);② `InoutQueryService`:改读 `mdp_std_inv_trans`;③ 响应新增 `dataSource`(`LIVE_165`/`STD`)与 `asOf`;④ 前端 `Web/src/views/aidop/s5/inventory/stockQueryList.vue`、`inoutQueryList.vue` 展示时点与"非实时"提示;⑤ Domain 解析不再默认 8010 —— 走 WP-INV-0 的映射(**待决策 D-3**:是否允许无映射时报错) |
+| 验收 | 用样本走真人路径:库存查询按物料 `81HC0854`+批 `260811014` 能查到 `1001` 上 500 非限制;进出存查询同批能看到两笔 `iss-tr-ins`;截图入留证 |
+| 跨模块影响 | 前端两页均在 S5 菜单下,无新增菜单 → **无需登记 FUNC 编号**;接口新增字段属向后兼容 |
+| 回滚 | 服务层留配置开关 `AiDOP:Inventory:ReadModel=LEGACY/STD/LIVE`,一键切回 |
+
+### WP-INV-4 · 齐套/采购/发货口径统一
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 消灭 §2.4 的三套公式 |
+| 改动 | ① 抽 `IInventoryBalanceReader`(两实现:165 实时 / std),统一 `qty_available` 公式(按 D-1 决策)与库位范围(按 D-4 决策);② 改造 `MaterialRequirementCalculator`、`OrderWorkOrderGenerationService`、`PurchaseRequestMergeService`、`ProcurementPipelineService` 全部走该接口;③ 流水类读者(`OrderDeliveryService`、`WorkOrderSchedulingService`、`SupplierShipmentService`)改读 `mdp_std_inv_trans` |
+| 跨模块影响 | **会改变 S1 齐套结论、S3 供需建议、S4 采购执行数量**——必须先在测试租户跑"改前/改后差异对照表",由业务确认后才允许合入 |
+| 验收 | 差异对照表:改前后每个物料的可用量差异清单 + 业务签字;齐套用例回归 |
+| 回滚 | 接口默认实现保留 legacy 公式开关 |
+
+### WP-INV-5 · legacy 镜像下线
+
+| 项 | 内容 |
+|----|------|
+| 目标 | `aidopdev.LocationDetail` / `InvTransHist` / `InvMaster` 不再被任何运行时代码读写 |
+| 改动 | ① 全仓 grep 断言零引用(含 SQL 脚本与运维 py);② 改名归档 `zz_archive_LocationDetail` 等,保留 N 天(**待决策 D-5**)后 DROP;③ 在 00B 增一条红线:新代码不得读写这三张表 |
+| 验收 | 重启后全站主要页面/齐套/排程/发货回归通过;`information_schema` 中原名表不存在或已改名 |
+| 回滚 | 改名可逆(改回原名即恢复) |
+
+### WP-INV-6 · 对账与运维
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 口径收敛后要能持续证明"没漂" |
+| 改动 | ① 日终对账 Job:165 与 `mdp_std_inventory` 按 `(domain,item,lot,location)` 比 `qty_*`,差异写 `ado_inventory_recon_diff` 并按阈值告警(复用 WP6 对账框架);② 冷链失败/滞后告警(`last_sync_to` 超时);③ 看板展示 `as_of`;④ 留证目录 `doc/plan/UAT留证/<日期>-库存口径收敛/` |
+| 验收 | 人为在 165 改一行 → 对账能在下一轮标出差异并在同步后归零 |
+
+**依赖顺序**:WP-INV-0 → 1 → 2 → 3 →(4 需业务确认)→ 5 → 6。WP-INV-6 可与 3 并行。
+
+---
+
+## 6. 端到端验收(用 2026-08-11 活样本)
+
+```sql
+-- ① 165(权威)
+SELECT Location, ItemNum, LotSerial, QtyOnHand, AvailStatusQty, Assay
+  FROM LocationDetail WHERE Domain='8010' AND ItemNum='81HC0854' AND LotSerial='260811014';
+-- 期望:1001 行 AvailStatusQty=500,1000 行 Assay=0
+
+-- ② 标准层(收敛后 Ai-DOP 唯一读模型)
+SELECT location, qty_unrestricted, qty_inspection, as_of
+  FROM mdp_std_inventory
+ WHERE tenant_id=797403760988229 AND domain='8010' AND item_num='81HC0854' AND lot_serial='260811014';
+-- 期望:与 ① 一致,as_of 为最近一轮刷新时间
+
+-- ③ 流水
+SELECT trans_type, location, qty_change, eff_date
+  FROM mdp_std_inv_trans
+ WHERE tenant_id=797403760988229 AND item_num='81HC0854' AND lot_serial='260811014'
+ ORDER BY eff_date DESC;
+-- 期望:iss-tr-ins @1001 +500、iss-tr-ins @1000 -500
+```
+
+界面:`#/aidop/s5/inventory/stock-query` 按物料+批次能查到 500;`#/aidop/s5/inventory/inout-query` 能看到上述两笔。截图入留证。
+
+---
+
+## 7. 跨模块影响(开工前必须确认)
+
+| 模块 | 影响 | 说明 |
+|------|------|------|
+| S1 产销协同 | **高** | 齐套可用量公式变化 → 评审/交期结论可能变 |
+| S3 供需 | **高** | 采购需求合并与管道口径变化 → 建议采购量可能变 |
+| S4 采购执行 | 中 | 发货批次收货量改读 std |
+| S2 排程 | 中 | `WorkOrderSchedulingService` 流水来源切换 |
+| S5 | 直接 | 两个查询页面切读 + Domain 解析不再默认 8010 |
+| S6/S7/S9 KPI | 低-中 | T8 周转率读 `mdp_std_t8_*`,与本书无直接冲突,但"库存"语义在看板上要与新口径一致 |
+| S8 监控 | 低 | 新增冷链滞后/对账差异两类告警 |
+
+---
+
+## 8. 风险与回滚
+
+| 风险 | 缓解 |
+|------|------|
+| `InvTransHist` 首轮全量 100 万行压垮 165 或撑爆 stg | 分批 2000、限时窗口(夜间)、只回溯 N 个月(**待决策 D-6**) |
+| 实时读 165 增加源库压力 | 查询必须带 Domain+物料/批次/库位条件与分页;禁止无条件全表;命中 `IX_LocationDetail` |
+| 165 不可达导致页面白屏 | 方案 D 的降级语义:回落 std 并标 `dataSource=STD` |
+| 口径统一改变业务结论 | WP-INV-4 强制"改前/改后差异对照表 + 业务签字"门槛 |
+| 多实例同时跑冷链互相打游标 | 同一库同一时刻只允许一个实例跑定时任务(2026-08-11 已因多实例踩过一次,见留证 `30` 第一节) |
+| 脚本号撞号让所有环境起不来 | 取号前 `git fetch`;见留证 `30` 第三节规范 |
+
+---
+
+## 9. 执行须知(摘 00B,违反即回退)
+
+1. 只读 165,**不新增对 165 业务表的直接 INSERT/UPDATE**;一切写 165 走 Outbox + `MdpDbPushExecutor`(含已批准的 `op=PROC` 例外);
+2. 本库不得再建"165 业务表的权威镜像";贴源层是 stg,不是同名表;
+3. 新增热关注表/贴源实体前,必须先给出**字段归属矩阵**(哪些列以 165 为权威、哪些是 Ai-DOP 自有、哪些只前进不回退)——这条是 2026-08-11 D1 事故后加的硬要求;
+4. 一切"数量"必须带口径名与时点,禁止在接口/页面出现无定义的 `qty`;
+5. 纯文档提交不升版本号;改后端/前端代码按各自端 patch +1。
+
+---
+
+## 10. 待决策项(负责人勾选后本书才可执行 WP-INV-1 之后的卡)
+
+| # | 决策点 | 候选 | 建议 |
+|---|--------|------|------|
+| D-1 | `qty_available` 公式 | ① `AvailStatusQty`;② `AvailStatusQty + Assay` | ①(质检未合格不应参与齐套承诺) |
+| D-2 | 冷链频率 | ① 每 5 分钟;② 每 30 分钟;③ 每小时 + 夜间全量 | ②(余额有实时通道兜底,冷链只作报表/兜底) |
+| D-3 | Domain 无映射时行为 | ① 报错;② 回落 8010 | ①(回落 8010 是当前多租户隐患的来源) |
+| D-4 | 可用量库位范围 | ① 全部库位;② 白名单 | ②(与现采购管道一致,白名单进配置表) |
+| D-5 | legacy 三表处置 | ① 改名保留 30 天再 DROP;② 直接 DROP;③ 永久保留只读 | ① |
+| D-6 | `InvTransHist` 回溯范围 | ① 全量 100 万行;② 近 12 个月;③ 近 24 个月 | ②(进出存查询默认按月,历史可另开归档通道) |
+
+---
+
+## 11. 附录 · 现状读写路径速查(本次探查产出)
+
+- 只读 legacy 余额:`StockQueryService`、`MaterialRequirementCalculator`、`OrderWorkOrderGenerationService`、`PurchaseRequestMergeService`、`ProcurementPipelineService`
+- 只读 legacy 流水:`InoutQueryService`、`OrderDeliveryService`、`WorkOrderSchedulingService`、`SupplierShipmentService`
+- 读 165 实时余额:`Mes165StockReader`(门控 `AidopStockOptions.MesTenantId`)
+- 写 165(经存储过程):`IqcWmsResultPushService` → Outbox → `MdpDbPushExecutor`(`op=PROC`,`pr_WMS_BPM_SaveInvUpShelf @TransCode=QcCheck`)
+- 写 `ado_inventory_*`:`InventoryPosting/Inventory/InventorySqlWriter`(经 `IqcReceiptOrchestrator`;目前无生产 API 接线)
+- 贴源写入:`MdpDbPullExecutor` + `MdpStagingWriter`(冷链)、`MdpHotWatchService`(热关注变更时顺带写 stg)
+- 热关注 watch 表:`WorkOrdMaster`/`WorkOrdRouting`/`PeriodSequenceDet`、`NbrMaster`/`NbrDetail`/`MissedPrint`、`PurOrdMaster`/`PurOrdDetail`/`MissedPrint`/`qms_qcp_*` —— **不含**任何库存余额表
+- 与库存**无关**但易混淆:`PendingInspectionService`(暂收在检=`MissedPrint`)、`LabelQueryService`、`StocktakeResultService`

+ 2 - 2
doc/plan/UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md

@@ -106,7 +106,7 @@
 
 `StockQueryService`(库存查询)与 `InoutQueryService`(出入库查询)都直读 `aidopdev` 的业务镜像表 `LocationDetail` / `InvTransHist`,而这两张镜像是历史一次性导入、之后**未常态同步**;MDP 侧虽有 `S5_LOCATION_DETAIL_SQLSERVER`、`S5_INV_TRANS_HIST_SQLSERVER` 等入站实体(`status=1`,`last_sync_to` 为空),但其目标是贴源层 `mdp_stg_inventory` / `mdp_stg_inv_trans`,与页面所读业务镜像表不是同一条链。
 
-结论:**过账本身已成功**(165 六处证据齐全),但 Ai-DOP 界面当前无法呈现该结果,"上架入库"的界面留证只能暂以 165 侧证据 + WMS APP(直读 165)截图替代。修复方案待决策,三条候选:① 过账成功后按"物料+批次+库位"窄回读同步这两张镜像(与 HotWatch PUR_ORDER 口径一致,改动最小);② 为两张表建常态化增量抽数并决定页面读镜像还是读贴源;③ 页面改为跨库直读 165(一致性最好,需评估性能与耦合)。
+结论:**过账本身已成功**(165 六处证据齐全),但 Ai-DOP 界面当前无法呈现该结果,"上架入库"的界面留证只能暂以 165 侧证据 + WMS APP(直读 165)截图替代。修复方案已单列任务书:[`../../S5库存口径收敛(现存量与出入库)方案与执行任务书.md`](../../S5库存口径收敛(现存量与出入库)方案与执行任务书.md)(含现状基线、四个候选方案对比与推荐、WP-INV-0~6 任务卡与 6 项待决策)。
 
 ## 已知非阻塞项
 
@@ -116,4 +116,4 @@
 
 ✅ **全链路通过并结案**:扫码收货 → 自动 IQC 报检 → 界面认领/生成检验单 → 判定合格 → QcCheck 上架入库 → 主管审核关单,质检库存 500 已转为合格库非限制库存,流程 `Approved`。
 
-遗留两项(均已单列、不阻塞结案):S5 两个查询页读冻结镜像看不到当日过账(见上文"缺口"小节,需口径收敛任务书);关单后 `FBILLSTATUS` 仍显示"检验中"(见步骤 6 观察项)。
+遗留两项(均已单列、不阻塞结案):S5 两个查询页读冻结镜像看不到当日过账(见上文"缺口"小节,方案已成书 [`S5库存口径收敛(现存量与出入库)方案与执行任务书.md`](../../S5库存口径收敛(现存量与出入库)方案与执行任务书.md));关单后 `FBILLSTATUS` 仍显示"检验中"(见步骤 6 观察项)。