Преглед изворни кода

feat(aidop): extend S1 MDP data flow

Bump server to 1.0.132 and add the S1 runtime script so the new S1 MDP, DWD, and KPI paths are versioned with the backend changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu пре 1 месец
родитељ
комит
5fda96490a

+ 2 - 0
doc/README.md

@@ -46,7 +46,9 @@
 | [审批流集成开发指南.md](./审批流集成开发指南.md) | `IFlowBizHandler`、`ApprovalPanel`、配置与 API |
 | [Windows后端WinSW守护重启方案.md](./Windows后端WinSW守护重启方案.md) | Windows 服务器上使用 WinSW 守护后端服务、自动重启与日志落盘 |
 | [plan/S1/S1-订单管理-审批流程实施方案.md](./plan/S1/S1-订单管理-审批流程实施方案.md) | S1 订单审批实施方案 |
+| [plan/S1-S3业务逻辑测试与数据准备方案.md](./plan/S1-S3业务逻辑测试与数据准备方案.md) | S1-S3 完整业务逻辑测试 case 与测试数据准备方案 |
 | [plan/S8/S8数据库处理优化方案.md](./plan/S8/S8数据库处理优化方案.md) | S8 调度、检测日志、通知扫描与数据库写入压力优化方案 |
+| [plan/新系统数据库简明介绍与S1数据流.md](./plan/新系统数据库简明介绍与S1数据流.md) | 新系统数据库简明说明,并以 S1 为例说明数据中台流转 |
 | [plan/数据库迁移/S1/S1-任务交接记忆.md](./plan/数据库迁移/S1/S1-任务交接记忆.md) | **S1 数据中台迁移**跨会话交接(当前进度、阻塞、验收 SQL) |
 | [plan/数据库迁移/S1/S1数据中台迁移实施计划.md](./plan/数据库迁移/S1/S1数据中台迁移实施计划.md) | S1 数据中台迁移主方案与分块步骤 |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |

+ 716 - 0
doc/plan/S1-S3业务逻辑测试与数据准备方案.md

@@ -0,0 +1,716 @@
+# S1-S3 业务逻辑测试与数据准备方案
+
+> 文档定位:S1 产销协同、S2 制造协同、S3 供应协同的完整业务逻辑测试范围与测试数据准备依据
+> 创建日期:2026-05-28
+> 需求来源:`D:\Projects\Ai-DOP\项目\项目管理\内部项目管理\AI-DOP一期功能清单20260203.xlsx` 中 `S1-S3产销、制造和供应协同` 工作表
+> 当前边界:旧系统目标数据库尚未确认,本方案先梳理测试 case、本系统涉及页面/服务/表、测试数据类型与数量建议;旧系统来源库表与抽数 SQL 后续补充
+
+---
+
+## 一、目标与范围
+
+本方案用于支撑 S1-S3 三大业务模块的完整业务逻辑测试。最终目标是从已有旧系统实际业务数据中抽取一批样本导入当前 Ai-DOP 数据库,形成可重复执行、可覆盖正常与异常逻辑的测试数据集。
+
+本轮先完成不依赖旧系统目标库的信息:
+
+1. 基于功能清单梳理 S1-S3 全量业务逻辑测试 case。
+2. 梳理每类测试涉及的当前系统页面、后端服务、当前库表。
+3. 梳理测试所需数据类型、场景组合和建议抽样数量。
+4. 标记旧系统来源数据为待确认,不编写旧库抽数 SQL。
+
+---
+
+## 二、功能需求基线
+
+Excel 中 S1-S3 页共覆盖 24 项业务功能。
+
+### 2.1 S1 产销协同
+
+1. 合同评审
+2. 产品设计
+3. 订单评审
+4. 订单交付
+5. 订单发货
+6. 工单池下达管理
+7. 需求明细核验
+8. 计划联动看板
+
+### 2.2 S2 制造协同
+
+1. 工单工序排产
+2. 排产异常记录
+3. 可执行生产日计划
+4. 产线工作日历管理
+5. 产线休息时间管理
+6. 产线节假日管理
+7. 产线加班管理
+8. 订单生产排程看板
+9. 产线生产排程看板
+
+### 2.3 S3 供应协同
+
+1. 物料需求计划
+2. 物料交货计划
+3. 交货单异常记录
+4. 物料采购申请
+5. 物料采购订单
+6. 委外加工订单
+7. 工序外协订单
+8. 工单物料齐套看板
+
+---
+
+## 三、当前系统实现映射
+
+### 3.1 S1 产销协同
+
+| 功能 | 前端页面 | 后端服务 | 当前库关键表 |
+|------|----------|----------|--------------|
+| 合同评审 | `Web/src/views/aidop/business/contractReviewList.vue`、`contractReviewForm.vue` | `ContractReviewService`、`ContractReviewBizHandler` | `ado_contract_review`、`ado_contract_review_flow`、审批流相关表 |
+| 产品设计 | `productDesignList.vue`、`productDesignForm.vue` | `ProductDesignService` | `ado_product_design`、`ado_product_design_bom`、`ado_product_design_routing` |
+| 订单评审 | `orderList.vue`、`salesOrderChangeForm.vue` | `SeOrderService`、`OrderReviewBizHandler`、`SeOrderOperationLogService` | `crm_seorder`、`crm_seorderentry`、`crm_seorder_change`、`SysLogOp` |
+| 订单交付 | `orderDeliveryList.vue`、`orderDeliveryFlow.vue` | `OrderDeliveryService` | `crm_seorder`、`crm_seorderentry`、采购/排程/发货相关事实表、S1 DWD 表 |
+| 订单发货 | `asnShipperList.vue`、`asnShipperForm.vue` | `AsnShipperService` | `ASNBOLShipperMaster`、`ASNBOLShipperDetail` |
+| 工单池下达管理 | `workOrderDispatchList.vue`、`workOrderDispatchForm.vue` | `WorkOrderDispatchService` | 工单、工艺、领料、齐套相关表 |
+| 需求明细核验 | `Web/src/views/aidop/s1/sales-kanban/RequirementExamineDetailList.vue` | `RequirementExamineDetailService` | 资源检查/需求明细结果表,后续以实际表结构补全 |
+| 计划联动看板 | `linkagePlanList.vue` | `LinkagePlanService` | `LinkagePlan` |
+
+### 3.2 S2 制造协同
+
+| 功能 | 前端页面 | 后端服务 | 当前库关键表 |
+|------|----------|----------|--------------|
+| 工单工序排产 | `workOrderSchedulingList.vue` | `WorkOrderSchedulingService` | `WorkOrdMaster`、`WorkOrdRouting`、`ScheduleResultOpMaster`、`PeriodSequenceDet` |
+| 排产异常记录 | `scheduleExceptionList.vue` | `ScheduleExceptionService` | `ScheduleExceptionMaster` |
+| 可执行生产日计划 | `executableDailyPlanList.vue` | `ExecutableDailyPlanService` | `PeriodSequenceDet`、`ScheduleResultOpMaster`、工单/工序相关表 |
+| 产线工作日历管理 | `shopCalendarWorkCtrList.vue`、`shopCalendarWorkCtrForm.vue` | `ShopCalendarWorkCtrService` | `ShopCalendarWorkCtr` |
+| 产线休息时间管理 | `qualityLineRestDetailList.vue`、`qualityLineRestDetailForm.vue` | `QualityLineWorkDetailService` | `QualityLineWorkDetail` |
+| 产线节假日管理 | `holidayMasterList.vue`、`holidayMasterForm.vue` | `HolidayMasterService` | `HolidayMaster` |
+| 产线加班管理 | `lineOvertimeList.vue`、`lineOvertimeForm.vue` | `ResourceOvertimeService` | `ResourceOccupancyTime` |
+| 订单生产排程看板 | 生产排程相关看板页面,待按菜单落点复核 | `WorkOrderSchedulingService`、看板服务 | 工单、工序、排程结果、齐套状态 |
+| 产线生产排程看板 | 生产排程相关看板页面,待按菜单落点复核 | `WorkOrderSchedulingService`、看板服务 | 产线/设备、日历、休息、节假日、加班、排程结果 |
+
+### 3.3 S3 供应协同
+
+| 功能 | 前端页面 | 后端服务 | 当前库关键表 |
+|------|----------|----------|--------------|
+| 物料需求计划 | `demandScheduleList.vue`、`demandScheduleForm.vue` | `DemandScheduleService` | `ic_demandschedule`、`mdp_std_supply_demand`、`dwd_supply_demand` |
+| 物料交货计划 | `deliveryScheduleList.vue`、`deliveryScheduleBatchForm.vue` | `DeliveryScheduleService` | `srm_polist_ds`、`mdp_std_delivery_schedule`、`dwd_supplier_delivery` |
+| 交货单异常记录 | `deliveryExceptionList.vue` | `DeliveryExceptionService` | `DeliveryExceptionMaster` |
+| 物料采购申请 | `purchaseRequestList.vue`、`purchaseRequestForm.vue` | `PurchaseRequestService`、`PurchaseRequestMergeService`、`PurchaseRequestExternalPushService` | `srm_pr_main`、`mdp_std_purchase_request` |
+| 物料采购订单 | `purchaseOrderList.vue` | `PurchaseOrderService`、`PurchaseOrderTransferService` | `PurOrdMaster`、`PurOrdDetail`、`dwd_supplier_delivery` |
+| 委外加工订单 | `outsourceOrderList.vue`、`outsourceOrderForm.vue`、`outsourceOrderDetailList.vue` | `OutsourceOrderService` | `PurOrdMaster`、`PurOrdDetail`、`PurOrdDetailBatch`、`PurOrdRctDetail` |
+| 工序外协订单 | `processOutsourceOrderList.vue`、`processOutsourceOrderForm.vue` | `ProcessOutsourceOrderService` | `PurOrdMaster`、`PurOrdDetail`、`WorkOrdMaster`、`WorkOrdRouting`、`RoutingOpDetail` |
+| 工单物料齐套看板 | `workOrderMaterialReadinessKanban.vue` | `WorkOrderMaterialReadinessService` | `dwd_material_readiness`、`dwd_material_shortage`、`WorkOrdTemp` |
+
+---
+
+## 四、业务逻辑测试 Case
+
+### 4.1 S1 合同评审
+
+正常 case:
+
+- 新建合同评审,填写合同号、客户、产品、评审负责人、计划时间,保存成功。
+- 发起合同评审审批,流程按“意见评审、意见反馈、二次评审、领导意见、合同盖章”推进。
+- 法律、技术售前、综合主计划、试验站分别提交评审意见。
+- 全流程通过后,合同评审状态、节点完成时间、周期统计正确。
+
+异常 case:
+
+- 缺少合同号、客户、评审负责人等必填字段时禁止保存。
+- 重复合同号或重复评审单据时提示冲突。
+- 任一部门驳回后状态回退或进入待反馈状态。
+- 二次评审缺少前置意见时禁止提交。
+- 审批人缺失、流程未配置、流程挂起时提示明确错误。
+- 节点超期后应能在 S8/S9 或相关监控中形成延期/预警口径。
+
+数据需求:
+
+- 合同样本 20 个,其中正常 10 个、驳回 4 个、超期 4 个、重复/缺字段 2 个。
+- 客户 10 个、产品 20 个、评审部门 4 个、评审人员 8 至 12 个。
+
+### 4.2 S1 产品设计
+
+正常 case:
+
+- 常规产品设计录入并关联合同号、设计负责人、产品编码、产品型号、产品名称。
+- 非标产品设计录入图号、制造 BOM、工艺路线、计划/实际开始结束时间。
+- 输入产品编码后自动带出产品型号和产品名称。
+- 保存 BOM 明细、工艺明细后,详情页可完整回显。
+
+异常 case:
+
+- 产品编码不存在时不能自动带出基础信息并给出提示。
+- 计划结束时间早于计划开始时间时提示。
+- 非标产品缺少图号、BOM 或工艺时进入待补齐状态。
+- 实际结束时间晚于计划结束时间时形成产品设计延期口径。
+- 同一合同同一图号重复录入时提示冲突。
+
+数据需求:
+
+- 产品设计样本 30 条:常规 15 条、非标 15 条。
+- 每条非标设计至少 3 至 8 行 BOM、2 至 5 道工艺。
+- 设计延期样本不少于 6 条。
+
+### 4.3 S1 订单评审
+
+正常 case:
+
+- 从 ERP/旧系统同步销售订单,列表展示订单号、客户、物料、数量、优先级、交期。
+- 手工新增计划订单,参与同一评审逻辑。
+- 按订单优先级定时或手动执行资源检查。
+- 有成品库存时,预计发货日期为订单下达日期加 1 天。
+- 无成品库存但有在制工单或物料齐套时,按生产周期计算预计发货日期。
+- 物料未齐套时,按物料齐套时间加生产周期计算预计发货日期。
+- 装备/非标订单叠加设计周期。
+- 勾选订单执行交期确认后,锁定供应资源并显示交期承诺。
+- 已锁定资源订单可执行资源解锁。
+
+异常 case:
+
+- 订单缺客户、物料、数量、交期时资源检查失败并记录原因。
+- 库存不足、无在制工单、无 BOM、无工艺、无供应来源时产生异常结果。
+- 优先级冲突或同资源被高优先级订单抢占时重新计算承诺交期。
+- 修改客户要求交期后应产生变更记录,并触发重评估。
+- 已确认交期的订单再次确认时应避免重复锁定。
+- 解锁不存在或已释放的资源时提示状态不一致。
+
+数据需求:
+
+- 销售订单 80 至 120 行,覆盖高/中/低优先级。
+- 成品库存充足 20 行、在制可满足 20 行、物料齐套可满足 20 行、物料不齐套 20 行、非标需设计 10 至 20 行。
+- 订单变更样本 20 条,覆盖交期变更、数量变更、客户变更、优先级变更。
+
+### 4.4 S1 订单交付
+
+正常 case:
+
+- 交期确认后,订单联动生产计划、物料计划、采购计划。
+- 订单交付页展示订单评审、计划排程、物料需求、采购执行、生产备料、生产执行、入库发运全链路节点。
+- 点击节点可追溯业务单据明细、责任人、计划/实际时间。
+- 交付状态随生产、采购、发货进度更新。
+
+异常 case:
+
+- 订单无评审结果时不能进入完整交付链路。
+- 计划排程缺失、物料需求未生成、采购订单不足、生产未完工、入库未完成时对应节点显示异常。
+- 任一节点计划开始/结束时间缺失时提示基础数据不完整。
+- 实际开始晚于计划开始、实际完成晚于计划完成时形成预警。
+
+数据需求:
+
+- 闭环订单 30 单,至少包含已评审、已排程、采购中、生产中、已入库、已发运状态。
+- 异常订单 20 单,覆盖排程缺失、物料缺口、采购延迟、生产延迟、发运延迟。
+
+### 4.5 S1 订单发货
+
+正常 case:
+
+- 从销售订单选择订单行,生成销售发货通知单。
+- 发货单包含发货单号、销售订单号、客户编码、客户名称、物料编码、物料名称、仓库、数量、日期。
+- 发货通知进入 S7 销售发货通知链路。
+
+异常 case:
+
+- 发货数量大于未发数量时禁止提交。
+- 客户、物料、仓库缺失时禁止生成。
+- 已关闭订单不能生成发货通知。
+- 同一订单行重复生成发货通知时校验剩余可发数量。
+
+数据需求:
+
+- 可发订单行 30 行。
+- 部分发货 10 行、超发校验 5 行、关闭订单 5 行、缺仓库/缺客户 5 行。
+
+### 4.6 S1 工单池下达管理
+
+正常 case:
+
+- 订单优先级生成生产工单池。
+- 工单执行物料齐套检查,计算每个工单齐套率。
+- 工单下达生成生产领料单和每行物料齐套率。
+- 支持配置排除物料类型和物料属性,例如包材。
+
+异常 case:
+
+- 工单缺 BOM、缺工艺、缺物料库存时齐套检查失败或显示缺料。
+- 物料不齐套但允许下达时,应明确风险状态。
+- 排除规则配置错误时齐套率计算结果可解释。
+- 重复下达工单时防重。
+
+数据需求:
+
+- 工单 60 个,其中齐套 20、部分齐套 20、不齐套 15、缺 BOM/工艺 5。
+- 每个工单 5 至 20 行组件物料。
+
+### 4.7 S1 需求明细核验
+
+正常 case:
+
+- 展示每次资源检查运算明细。
+- 明细包含日期、库存、可用库存、ROP、安全库存、单据号等。
+- 支持按订单、物料、日期、层级展开查询。
+
+异常 case:
+
+- 运算明细缺少来源单据时提示。
+- 库存口径不一致时可定位来源。
+- ROP/安全库存为空时按默认口径或显示未维护。
+
+数据需求:
+
+- 资源检查批次 20 个。
+- 每批 20 至 100 行明细,覆盖库存、在途、在制、缺料、安全库存不足。
+
+### 4.8 S1 计划联动看板
+
+正常 case:
+
+- 订单关键节点展示计划开始/结束与实际开始/结束。
+- 计划开始前 1 天产生待办。
+- 实际开始晚于计划开始时发给上级主管预警。
+- 实际完成晚于计划完成时发给部门主管领导预警。
+- 点击关键节点可下钻责任人、作业内容、实际作业时间。
+
+异常 case:
+
+- 订单变更后三级计划联动更新。
+- 缺负责人、缺计划时间、缺实际时间时显示待维护。
+- 同一节点重复预警时限频。
+- 下游节点早于上游节点完成时提示链路异常。
+
+数据需求:
+
+- 订单链路 40 条,覆盖正常、即将开始、开始延期、完成延期、订单变更重排。
+- 节点数据每单 5 至 8 个。
+
+---
+
+## 五、S2 业务逻辑测试 Case
+
+### 5.1 工单工序排产
+
+正常 case:
+
+- 已下达工单按优先级、产能约束、物料齐套数量进行排程。
+- 作业任务分配到班组、工序、设备。
+- 在制工单执行进度更新后,排程结果可调整。
+- 插单时支持调整工单优先级、物料和工序。
+
+异常 case:
+
+- 工单缺工艺路线时排产异常。
+- 设备产能不足或日历不可用时排程失败。
+- 物料齐套数量不足时只排可执行数量。
+- 插单导致原计划冲突时重排并保留调整记录。
+- 工序前后关系错误时提示。
+
+数据需求:
+
+- 工单 80 个,工序 3 至 8 道。
+- 设备/产线 10 至 20 个。
+- 可排、部分可排、不可排工单各一批。
+- 插单样本 10 个。
+
+### 5.2 排产异常记录
+
+正常 case:
+
+- 排产时发现数据维护不规范,生成异常记录。
+- 异常记录可查询、查看、编辑、删除。
+
+异常 case:
+
+- 缺工艺、缺设备、缺日历、工时为 0、数量为 0、前后工序矛盾。
+- 同一工单同一异常去重。
+- 异常修复后再次排产不重复报错。
+
+数据需求:
+
+- 排产异常 30 条,覆盖至少 8 类异常原因。
+
+### 5.3 可执行生产日计划
+
+正常 case:
+
+- 按时间段展示车间/产线生产任务。
+- 展示班组、工序、设备、工时、数量、开工时间、结束时间。
+- 作业任务可下达给班组。
+
+异常 case:
+
+- 计划跨休息时间、节假日、不可用产线时调整。
+- 实际完工数量少于计划数量时显示偏差。
+- 工序未完成时后续工序不可提前下达。
+
+数据需求:
+
+- 日计划 2 至 4 周。
+- 每天 20 至 50 条作业任务。
+- 覆盖正常、延期、跨班、跨日、部分完成。
+
+### 5.4 产线日历/休息/节假日/加班
+
+正常 case:
+
+- 工作日历影响可排产工时。
+- 休息时间扣减可用工时。
+- 节假日禁止或限制排产。
+- 加班增加可用工时。
+
+异常 case:
+
+- 日历时间重叠。
+- 休息时间超出班次范围。
+- 节假日和加班冲突。
+- 删除已被排程引用的日历配置时提示影响。
+
+数据需求:
+
+- 产线日历 10 条。
+- 休息时间 20 条。
+- 节假日 10 条。
+- 加班 20 条。
+- 至少 5 个排程样本覆盖日历冲突。
+
+### 5.5 制造协同看板
+
+正常 case:
+
+- 订单生产排程看板按订单/工单/工序纵轴和日期时间横轴展示甘特图。
+- 产线生产排程看板按生产中心/设备/产线展示甘特图。
+- 展示计划开工、完工、实际开工、实际完工。
+- 展示物料齐套图标、订单交货日期图标。
+
+异常 case:
+
+- 延期工序高亮。
+- 物料不齐套工单提示风险。
+- 设备冲突、产线超负荷、未排程工单提示。
+
+数据需求:
+
+- 至少 30 个订单对应 80 个工单。
+- 甘特跨度覆盖 2 至 4 周。
+- 产线/设备冲突样本不少于 10 条。
+
+---
+
+## 六、S3 业务逻辑测试 Case
+
+### 6.1 物料需求计划
+
+正常 case:
+
+- 已下达工单按优先级和 BOM 进行 MRP 运算。
+- 考虑物料替代关系,计算净需求数量和需求日期。
+- 根据库存、在途、已分配、已采购量计算缺口。
+
+异常 case:
+
+- 工单无 BOM、BOM 组件无物料主数据、替代料未维护时提示。
+- 库存不足、在途超期、需求日期早于当前日期时标记异常。
+- 重复运行 MRP 时结果幂等。
+
+数据需求:
+
+- 工单 60 个,BOM 组件 300 至 800 行。
+- 替代料关系 20 组。
+- 库存充足、库存不足、替代料满足、完全缺料样本各一批。
+
+### 6.2 物料交货计划
+
+正常 case:
+
+- 根据工单计划开工日期、需求数量、已交数量、IQC 周期计算交货数量和交货日期。
+- 关联采购订单。
+- 发布交货计划并支持批量生成。
+
+异常 case:
+
+- 缺货源清单。
+- 供应商配额之和不为 100%。
+- 采购订单不足时生成采购申请或记录异常。
+- 物料或供应商主键缺失时不自动生成 PR 并记录原因。
+- IQC 周期缺失时按默认值或提示未维护。
+
+数据需求:
+
+- 交货计划 100 至 200 行。
+- 货源清单 50 至 100 条。
+- 采购订单充足 50 行、不足 30 行、无货源 10 行、配额异常 10 行。
+
+### 6.3 交货单异常记录
+
+正常 case:
+
+- 交货计划生成时异常自动记录。
+- 页面可按物料、供应商、异常原因、时间查询。
+
+异常 case:
+
+- 同一计划重复生成异常时去重或更新。
+- 异常修复后重新生成交货计划,异常状态应可追踪。
+
+数据需求:
+
+- 交货异常 30 至 50 条,覆盖无货源、配额错误、采购订单不足、主键缺失、规则关闭。
+
+### 6.4 物料采购申请
+
+正常 case:
+
+- 根据周生产工单分解生成采购申请。
+- 支持列表查询、详情、保存、删除。
+- 支持采购申请合并、转采购订单/要货令、外部事务推送。
+
+异常 case:
+
+- 采购申请缺物料、供应商、需求数量、需求日期时禁止提交。
+- 已转单采购申请禁止重复转单。
+- 推送外部事务失败时记录状态并可重试。
+
+数据需求:
+
+- 采购申请 100 至 200 行。
+- 待合并、已合并、已转 PO、推送失败、已关闭状态各一批。
+
+### 6.5 物料采购订单
+
+正常 case:
+
+- 从 ERP 下载采购订单或由系统生成采购订单。
+- 列表展示采购订单、明细、供应商、物料、数量、交期、收货进度。
+- 交货计划可关联采购订单。
+
+异常 case:
+
+- 采购订单交期晚于需求日期。
+- 采购订单数量不足。
+- 已关闭或取消采购订单不能被交货计划占用。
+- 收货数量大于订单数量时提示异常。
+
+数据需求:
+
+- 采购订单 100 单、明细 300 至 600 行。
+- 正常、延期、不足、关闭、部分收货、全部收货状态各一批。
+
+### 6.6 委外加工订单
+
+正常 case:
+
+- 系统自动生成委外加工采购订单和物料发料明细。
+- 支持维护委外订单、明细、收货和供应商信息。
+
+异常 case:
+
+- 委外供应商缺失。
+- 委外物料、发料明细、收货记录不匹配。
+- 委外订单重复生成。
+- 收货数量异常。
+
+数据需求:
+
+- 委外订单 30 至 50 单。
+- 每单 2 至 8 行明细。
+- 发料不足、收货不足、供应商缺失样本各一批。
+
+### 6.7 工序外协订单
+
+正常 case:
+
+- 根据工单、工序、工艺供应商生成工序外协订单。
+- 自动生成采购单号。
+- 外协订单与工单、工艺路线、采购订单关联。
+
+异常 case:
+
+- 工序无外协供应商。
+- 编号规则缺失导致单号生成失败。
+- 已生成外协订单的工序重复生成。
+- 外协交期晚于后续工序计划。
+
+数据需求:
+
+- 工序外协候选工单 30 个。
+- 工序外协订单 20 至 40 单。
+- 编号失败、供应商缺失、重复生成、交期冲突样本各一批。
+
+### 6.8 工单物料齐套看板
+
+正常 case:
+
+- 按工单优先级和物料明细跟踪需求数量、发料数量、欠料数量、可用状态。
+- 按库存检查范围展示发料、备料、在库、在检、在途、承诺等数量。
+- 支持从数量和时间两个维度跟踪从需求生成到物料上线。
+
+异常 case:
+
+- 关键物料欠料。
+- 在途到货晚于开工日期。
+- 承诺数量不足。
+- 替代料满足但主料不足。
+- 齐套率低于阈值时触发风险。
+
+数据需求:
+
+- 工单齐套明细 300 至 800 行。
+- 齐套、部分齐套、不齐套、替代料齐套、时间不齐套场景各一批。
+
+---
+
+## 七、测试数据总体设计
+
+### 7.1 基础主数据
+
+| 数据类别 | 建议数量 | 用途 |
+|----------|----------|------|
+| 客户 | 20 至 30 个 | 合同、销售订单、发货 |
+| 产品/物料 | 200 至 500 个 | 产品设计、BOM、订单、MRP、采购 |
+| BOM | 50 至 100 套 | 工单、MRP、齐套 |
+| 工艺路线 | 50 至 100 套 | 排产、工序外协 |
+| 供应商 | 30 至 50 个 | 货源、采购、委外、外协 |
+| 货源清单 | 80 至 150 条 | 交货计划、采购申请 |
+| 仓库/库存地点 | 10 至 20 个 | 库存、发货、齐套 |
+| 产线/设备/工作中心 | 10 至 30 个 | 排程、日计划、日历 |
+| 部门/人员 | 30 至 80 个 | 审批、责任人、作业计划 |
+| 编号规则 | 5 至 10 组 | 发货单、采购申请、采购订单、外协单 |
+
+### 7.2 交易与事实数据
+
+| 数据类别 | 建议数量 | 覆盖目的 |
+|----------|----------|----------|
+| 合同评审 | 20 个 | 审批流、周期、驳回、超期 |
+| 产品设计 | 30 条 | 常规/非标、BOM、工艺、延期 |
+| 销售订单 | 80 至 120 行 | 资源检查、交期承诺、订单交付 |
+| 订单变更 | 20 条 | 重评估、联动更新 |
+| 生产工单 | 60 至 80 个 | 齐套、排程、日计划、MRP |
+| 排程结果 | 100 至 300 条 | 甘特图、日计划、产能冲突 |
+| 物料需求 | 300 至 800 行 | MRP、采购申请、齐套 |
+| 采购申请 | 100 至 200 行 | 合并、转单、推送 |
+| 采购订单 | 100 单 / 300 至 600 行 | 交货计划、供应进度 |
+| 交货计划 | 100 至 200 行 | 到货计划、异常记录 |
+| 委外/外协订单 | 50 至 90 单 | 委外加工、工序外协 |
+| 发货通知 | 30 至 50 单 | S1/S7 联动 |
+| 交付链路节点 | 200 至 400 条 | 计划联动、预警、下钻 |
+
+### 7.3 场景组合矩阵
+
+必须覆盖以下组合:
+
+- 正常闭环:销售订单 → 订单评审 → 产品设计 → 工单 → 排程 → MRP → 采购 → 齐套 → 生产 → 入库/发货。
+- 有成品库存:订单评审直接承诺短交期。
+- 有在制工单:按生产周期计算交期。
+- 物料齐套:可排产、可下达。
+- 物料不齐套:生成采购需求、交货计划和齐套风险。
+- 非标设计:设计周期影响订单承诺交期。
+- 采购不足:生成采购申请或交货异常。
+- 委外加工:外购物料或加工件委外交付。
+- 工序外协:工序级外协订单与工单工艺联动。
+- 产线日历冲突:排程受休息、节假日、加班影响。
+- 订单变更:触发重评估和计划联动更新。
+- 延期预警:计划开始/完成延期触发待办/预警。
+
+---
+
+## 八、旧系统来源数据待确认
+
+旧系统目标数据库尚未确认,因此本节只保留抽数口径,不写具体 SQL。
+
+### 8.1 待确认事项
+
+- 旧系统数据库类型、连接方式、库名、账号权限。
+- 旧系统表结构是否与当前代码中使用的表名一致。
+- 是否允许直接抽取生产数据,是否需要脱敏。
+- 抽取时间范围:建议优先取最近 3 至 6 个月已闭环和未闭环混合数据。
+- 业务域范围:S1/S2/S3 是否来自同一旧库,还是分 ERP/MES/SRM/QAD 多库。
+
+### 8.2 旧系统来源对象占位
+
+| 当前测试数据类别 | 旧系统来源对象 | 状态 |
+|------------------|----------------|------|
+| 客户、销售订单、订单明细 | 待确认,当前代码可见 `crm_seorder`、`crm_seorderentry` 口径 | 待确认 |
+| 合同评审 | 待确认,当前中台映射含 `ado_contract_review`、`ado_contract_review_flow` | 待确认 |
+| 产品设计、BOM、工艺 | 待确认,当前库目标为 `ado_product_design*` | 待确认 |
+| 工单、工序、排程 | 待确认,当前代码可见 `WorkOrdMaster`、`WorkOrdRouting`、`ScheduleResultOpMaster`、`PeriodSequenceDet` | 待确认 |
+| 物料需求计划 | 待确认,当前代码可见 `ic_demandschedule` | 待确认 |
+| 采购申请 | 待确认,当前代码可见 `srm_pr_main` | 待确认 |
+| 采购订单 | 待确认,当前代码可见 `PurOrdMaster`、`PurOrdDetail` | 待确认 |
+| 交货计划 | 待确认,当前代码可见 `srm_polist_ds` | 待确认 |
+| 交货异常 | 待确认,当前库目标为 `DeliveryExceptionMaster` | 待确认 |
+| 齐套/缺料 | 待确认,当前中台目标为 `dwd_material_readiness`、`dwd_material_shortage` | 待确认 |
+
+---
+
+## 九、后续执行步骤
+
+1. 确认旧系统目标数据库和表结构。
+2. 按本方案确定测试样本范围和数据量。
+3. 输出旧系统抽数映射表:旧库表、字段、过滤条件、目标表、转换规则。
+4. 先抽 10% 小样本做导入验证。
+5. 修正主键、业务键、时间、状态、组织、租户字段映射。
+6. 批量导入完整测试数据。
+7. 按本方案执行 S1-S3 正常/异常 case。
+8. 记录缺口:数据缺口、功能缺口、口径缺口、性能问题。
+9. 固化为可重复的数据准备脚本和回归测试清单。
+
+---
+
+## 十、后续待办任务列表
+
+### 10.1 S1-S3 测试方案完善
+
+- 补齐本方案中“当前库目标表”的未确认项。
+- 把每个测试 case 拆成可执行验收步骤:前置数据、操作步骤、预期结果、异常判断。
+- 给每个 case 标优先级:P0 主链闭环、P1 关键异常、P2 边界/报表。
+- 区分一期已开发功能和二期/三期规划功能,避免测试范围混淆。
+
+### 10.2 旧系统数据源确认
+
+- 确认旧系统目标数据库:库类型、地址、库名、账号、权限。
+- 确认 S1/S2/S3 是否来自同一旧库,还是 ERP/MES/SRM/QAD 多库。
+- 确认可抽取时间范围,建议先取最近 3 至 6 个月。
+- 确认是否需要脱敏客户、供应商、人员、价格等敏感字段。
+
+### 10.3 抽数映射设计
+
+- 输出旧系统表到当前库表的映射清单。
+- 明确业务键:订单号、工单号、物料编码、采购单号、供应商编码、图号等。
+- 明确目标层:贴源 `mdp_stg_*`、标准 `mdp_std_*`、DWD、业务运行表。
+- 明确抽样数量:正常闭环、异常闭环、未闭环、延期、缺料、委外、外协等场景。
+- 设计小样本导入方案,先跑 10% 样本验证。
+
+### 10.4 数据导入与校验
+
+- 准备小样本抽取脚本和导入脚本。
+- 导入后校验行数、业务键唯一性、租户/工厂字段、时间字段、状态字段。
+- 跑 S1/S3 MDP 转换,确认 `mdp_sync_log`、`mdp_transform_run_log` 成功。
+- 核对 DWD 和 KPI 是否符合预期。
+- 再扩大到完整测试数据集。
+
+### 10.5 自动化与半自动化测试
+
+- 建立 S1-S3 P0 主链回归清单。
+- 优先覆盖:订单评审、订单交付、排程、MRP、交货计划、采购订单、齐套看板。
+- 对异常 case 建数据断言:缺 BOM、缺货源、采购不足、排程冲突、延期预警。
+- 后续沉淀为接口测试脚本或人工验收表。
+
+### 10.6 数据库与调度稳定性
+
+- 清理或归档 `SysLogOp`、`AdoS8DetectionLog`、审批流日志等大表。
+- 停止多实例重复跑 S8、审批、MDP 调度。
+- 给调度加环境开关,确保只有指定调度实例运行。
+- 监控 MySQL `/tmp`、磁盘、`waiting for handler commit`、慢 SQL。
+
+---
+
+## 十一、开放问题
+
+1. 旧系统数据库是否为单库,还是 ERP/MES/SRM/QAD 多库组合。
+2. 测试数据是否允许包含真实客户、供应商、人员名称,是否必须脱敏。
+3. S1-S3 测试是否只验证一期已开发功能,还是连二期/三期规划项一起准备样本。
+4. 订单评审中的“资源锁定/解锁”当前目标表和状态机需进一步确认。
+5. 需求明细核验的当前落库表需要结合实际数据和页面接口补齐。
+6. S2 两个排程看板的最终页面落点和接口口径需确认。
+7. S3 工单物料齐套看板是否以当前 DWD 表为准,还是仍需兼容旧临时表 `WorkOrdTemp`。
+

+ 102 - 2
doc/plan/数据库迁移/S1/S1-任务交接记忆.md

@@ -2,7 +2,7 @@
 
 > **用途**:供任意 Cursor 账号 / 开发者在新会话中读取后,**无需依赖聊天历史**即可继续本任务。
 > **维护**:每完成一个分块或遇到新阻塞时,更新本文 **§2 当前状态** 与 **§8 变更记录**(只改这两节即可)。
-> **最后更新**:2026-05-26(加固已在远端验证;待 Git 提交 + 前端人工核对
+> **最后更新**:2026-05-29(S1 彻底打通推进:需求核验 DWD、订单交付 8 Tab 最小切换、成品库存周转代理口径
 > **关联主方案**:[S1数据中台迁移实施计划.md](./S1数据中台迁移实施计划.md)
 
 ---
@@ -72,7 +72,29 @@
 
 ### 2.3 当前阻塞
 
-**无**。Block 7 已通过。后续若再失败可参考 §2.5 历史阻塞与修复模式继续延展。
+2026-05-28 19:41 / 19:59 / 20:40 / 22:57、2026-05-29 00:06 / 00:30 已完成在线重跑验证,`1.0.132.sql` 对应实体与映射已生效,S1 MDP 刷新成功。当前剩余阻塞为部分深层业务样本不足,而不是链路不可执行:
+
+- 合同评审流程节点已补最小样本并通过 L3/L4 KPI 落表验证;更多真实审批流样本仍需从业务侧补齐。
+- 发货计划覆盖率、发货达成率、计划联动达成率已形成最小真实计算口径并落表;更多真实发货/ASN/联动样本仍需补齐。
+- 需求明细核验已补 `S1_REQUIREMENT_EXAMINE_RESULT`、`S1_REQUIREMENT_EXAMINE_DETAIL` 贴源实体和 `dwd_requirement_examine_detail`,查询服务及订单交付 Tab2/Tab4 已切到最新成功批次 DWD;实库验证发现 `b_bom_child_examine.is_use` 贴源为 `base64:type16:AQ==/AA==`,代码已兼容有效位。2026-05-29 01:17 按同口径对批次 `S1_MDP_FULL_20260529005835` 做数据库侧验证,`dwd_requirement_examine_detail` 可生成 76 行,覆盖订单分录 `795636933214277` / `SO001`。
+- 成品库存周转 `S1_L1_004` 已补“数量代理”计算:基于当前批次 `dwd_ship_trans` 的剩余量与实际发货量估算周转天数,目标值沿用 60 天。该口径不是种子定义中的“库存金额 / 销售成本”官方成本口径,后续需 S7 成品库存快照、BOM 成本或 ERP 成本字段确认后替换。
+- 当前验证环境阻塞:新版后端启动会被旧库 CodeFirst 历史表结构问题拖住(如 `0000-00-00`、NOT NULL 改列失败),进程完成调度预加载但未稳定监听 `5005`;代码编译通过,需求核验 DWD SQL 已数据库侧验证通过。若需完整端到端验证,需先绕过/修复 CodeFirst 启动阻塞后再跑 `POST /api/Order/linkageplan/refresh`。
+
+已在实库确认:
+
+- `mdp_entity` 已有 `S1_PRODUCT_DESIGN`、`S1_PRODUCT_DESIGN_BOM`、`S1_PRODUCT_DESIGN_ROUTING`。
+- `mdp_field_mapping` 已有 `tenant_id`、`source_system`、`source_table`、`raw_data` 四类映射。
+- `POST /api/Order/linkageplan/refresh` 两次成功:`S1_MDP_FULL_20260528194113`、`S1_MDP_FULL_20260528195924`,均 `SUCCESS`。
+- 最新 batch `S1_MDP_FULL_20260528195924`:`stageRows=32 / standardRows=18 / dwdRows=16 / duration=7134ms`。
+- 产品设计贴源进入最新 batch:`ado_product_design=2`、`ado_product_design_bom=5`、`ado_product_design_routing=4`。
+- `S1_L2_001/002/003/006/010/011/012` 已写入本轮 `calc_time=2026-05-28 19:59:24.827`。
+- 2026-05-28 20:40 按确认更新测试样本 `PD2026050001`(租户 `797403760988229`):补计划/实际设计时间与负责人 `AIDOPDemo`;重跑 `S1_MDP_FULL_20260528204055` 成功,`stageRows=32 / standardRows=18 / dwdRows=16 / duration=6954ms`。
+- `S1_L2_004/005/006` 已写入本轮 `calc_time=2026-05-28 20:40:55.213`:`S1_L2_004=3.2917`、`S1_L2_005=100.0000`、`S1_L2_006=1.0000`。
+- 2026-05-28 22:57 补租户 `797403760988229` 最小样本:订单 `SO001`、物料 `AIDOP-DEMO-001`,`ShippingPlan.RecID=1`、`ShippingPlanDetail.RecID=1`、`LinkagePlan.id=1`;重跑 `S1_MDP_FULL_20260528225707` 成功,`stageRows=35 / standardRows=22 / dwdRows=16`。
+- 同批次确认:`mdp_std_ship_trans` 中 `ShippingPlanDetail` 计划量 `plan_qty=100`,`LinkagePlan` 已转 `trans_type=LINKAGE_PLAN`、`linkage_status=LINKED`;`dwd_ship_trans` 已带出 `planned_ship_qty=100`、`plan_ship_date=2026-05-31`、`delivery_status=PLANNED`、`linkage_status=LINKED`。
+- 2026-05-28 23:31 使用 `AIDOPDemo` 登录租户 `797403760988229` 验证接口:`/api/DataPlatform/mdp-monitor/list?moduleCode=S1`、`/api/Order/seorder/list`、`/api/Order/delivery/list` 均返回 200;交付列表已按最新成功 DWD 批次过滤,避免历史批次重复行。
+- 2026-05-29 00:06 补 `CR2026050001` 合同评审流程最小样本 8 条,并修正贴源中文节点名;重跑 `S1_MDP_FULL_20260529000638` 成功,`stageRows=43 / standardRows=22 / dwdRows=16 / kpiRows=45`。`S1_L3_001~005`、`S1_L3_101~105` 已写入 `ado_s9_kpi_value_l3_day`,`S1_L4_001~004`、`S1_L4_101~104` 已写入 `ado_s9_kpi_value_l4_day`,`calc_time=2026-05-29 00:06:38.570`。
+- 2026-05-29 00:30 新增 S1 L2 发货/联动独立 KPI 主数据与计算:`S1_L2_013` 发货计划覆盖率、`S1_L2_014` 发货达成率、`S1_L2_015` 计划联动达成率;重跑 `S1_MDP_FULL_20260529003054` 成功,`stageRows=43 / standardRows=22 / dwdRows=16 / kpiRows=49`。当前租户 DWD 基数 `total=4 / planned=1 / shipped=0 / linked=1`,对应指标 `25.0000% / 0.0000% / 100.0000%`,`calc_time=2026-05-29 00:30:54.670`。
 
 ### 2.4 本轮已修复的根因
 
@@ -164,6 +186,66 @@ WHERE status = 'RUNNING'
 2. 配置驱动执行器:让 `mdp_pipeline*` 消费配置,先覆盖贴源/简单标准化。
 3. 逐步替换模块硬编码 SQL:按 S1/S3/S4 等模块逐步迁移,不一次性推翻现有稳定链路。
 
+### 2.12 S1 彻底打通验收矩阵(2026-05-28)
+
+| S1 功能 | 当前读写链路 | MDP/DWD 状态 | KPI 状态 | 验收状态 | 后续动作 |
+|---------|--------------|--------------|----------|----------|----------|
+| 合同评审 | 业务写入 `ado_contract_review` / `ado_contract_review_flow`;审批由 `CONTRACT_REVIEW` 业务流承载 | 已作为 `S1_CONTRACT_REVIEW`、`S1_CONTRACT_REVIEW_FLOW` 进入 `mdp_stg_so`;`CR2026050001` 已补最小流程节点样本 | `S1_L2_001/002/003`、`S1_L3_001~005/101~105`、`S1_L4_001~004/101~104` 已真实计算 | 已打通最小闭环 | 后续补更多真实审批流样本,覆盖退回、超时、多人并行等场景 |
+| 产品设计 | 业务写入 `ado_product_design`、`ado_product_design_bom`、`ado_product_design_routing` | 本轮新增 `S1_PRODUCT_DESIGN`、`S1_PRODUCT_DESIGN_BOM`、`S1_PRODUCT_DESIGN_ROUTING`,已进入 `mdp_stg_so` 最新 batch | `S1_L2_004/005/006` 已真实写入本轮 `calc_time` | 已打通 | 后续补更多正常/延期/缺数据样本扩大覆盖 |
+| 订单评审 | 列表读 `mdp_std_so`;价值流订单信息优先读 `mdp_std_so` | 已切 MDP 标准层;变更记录优先从 `mdp_stg_so` 的 `crm_seorder_change` 读取 | `S1_L1_001/002/003`、`S1_L2_010/011/012` 已真实计算 | 待人工核对 | 抽检订单列表、订单详情、变更记录与原始订单一致 |
+| 订单交付 | 列表读 `dwd_ship_trans`;价值流基础信息优先读 `dwd_ship_trans` | 已切 DWD 列表,列表只展示最新成功批次避免历史批次重复;Tab2 评审、Tab4 物料需求已切 `dwd_requirement_examine_detail`,Tab8 成品发运已切 `mdp_std_ship_trans`;生产/采购/质检/入库等深层链路仍只读运行表 | 交期评审类 KPI 已真实计算 | 部分打通 | 后续等待 S2/S3/S4/S6/S7 主题层后再收敛深层 Tab |
+| 订单发货 | 发货计划/ASN 写 `ShippingPlan*`、`ASNBOLShipper*` | 已进入 `mdp_stg_ship_trans`、`mdp_std_ship_trans`,并参与 `dwd_ship_trans`;最小样本已验证计划量进入 DWD | `S1_L2_013` 发货计划覆盖率、`S1_L2_014` 发货达成率已真实计算 | 已打通最小闭环 | 后续补更多发货/ASN 样本,提高达成率验证覆盖 |
+| 工单池下达 | 当前属于 S2/S6 生产运行链路,S1 只读关联 | 未纳入 S1 专属 MDP;订单交付价值流仍查工单/排程运行表 | 暂不纳入 S1 本轮 KPI | 临时依赖 | 保持跨模块只读,待 S2/S6 数据中台化后再收敛 |
+| 需求明细核验 | 页面查询 `/api/Order/examine-detail/list` | 已新增贴源实体 `S1_REQUIREMENT_EXAMINE_RESULT` / `S1_REQUIREMENT_EXAMINE_DETAIL` 与 `dwd_requirement_examine_detail`,接口改读最新成功批次 DWD | 暂不纳入本轮真实 KPI | 代码已打通;数据库侧验证 76 行 | 后续补新版后端端到端 refresh,并补更多资源核验样本 |
+| 计划联动看板 | `LinkagePlanService.Refresh` 触发 S1 `RunFullAsync`;旧存储过程已替换 | `S1_LINKAGE_PLAN` 已纳入 `mdp_stg_ship_trans`,并转入 `mdp_std_ship_trans` / `dwd_ship_trans` 的 `linkage_status` | `S1_L2_015` 计划联动达成率已真实计算 | 已打通最小闭环 | 后续补多节点/异常/未启用样本 |
+
+结论:S1 已具备“订单列表、订单交付列表、合同评审、产品设计、发货/ASN、计划联动刷新”的主链路;发货计划/计划联动最小闭环已通过 MDP/DWD 与独立 KPI 验证。仍未彻底打通的是订单交付 8 Tab 深层 DWD 化、需求明细核验 MDP 化、更多真实业务样本以及浏览器登录租户下的人工验收。
+
+### 2.13 S1 MDP/DWD 数据覆盖与缺字段(2026-05-28)
+
+| 层级 | 已覆盖对象 | 已知缺口 |
+|------|------------|----------|
+| 贴源 `mdp_stg_so` | 销售订单、订单明细、订单变更、合同评审、合同评审节点、产品设计、产品设计 BOM、产品设计工艺、需求核验结果、需求核验明细 | 合同评审节点与需求核验已有最小样本,仍需扩大真实审批流/资源核验覆盖 |
+| 贴源 `mdp_stg_ship_trans` | 发货计划、发货计划明细、ASN 主表、ASN 明细、计划联动 | 最小 `ShippingPlan` / `ShippingPlanDetail` / `LinkagePlan` 样本已补;仍需扩大正常/异常/未启用场景 |
+| 标准 `mdp_std_so` | 订单主/行、客户、产品、数量、计划/承诺交期、评审状态、计划员 | 合同评审/产品设计目前先作为贴源/KPI 来源,尚未扩展到独立标准表 |
+| 标准 `mdp_std_ship_trans` | 发货计划、ASN、计划联动的发货/数量/状态基础字段 | 已支撑发货计划覆盖率、发货达成率、计划联动达成率;仍需补更多异常/未启用/实际发货样本 |
+| DWD `dwd_ship_trans` / `dwd_requirement_examine_detail` | 订单交付列表、交付状态、风险、计划/实际发货上下文、计划联动状态;需求核验物料明细快照 | 8 Tab 深层生产/采购/质检/入库仍依赖运行表;需后续主题宽表化 |
+| KPI `ado_s9_kpi_value_*` | L1 订单评审 3 项、L2 合同评审 3 项、产品设计 3 项、交期评审 3 项、发货/计划联动 3 项;合同评审 L3/L4 节点与人效链路已真实计算;`S1_L1_004` 已补数量代理口径 | `S1_L1_004` 官方成本口径仍待 S7 成品库存快照和成本数据确认 |
+
+### 2.14 S1 最小闭环测试数据准备(2026-05-28)
+
+| 数据类型 | 当前库样本 | 最小闭环目标 | 缺口 |
+|----------|------------|--------------|------|
+| 销售订单/订单明细 | `crm_seorder`、`crm_seorderentry` 已有样本,MDP 标准层已跑通 | 10 个销售订单,覆盖正常、延期、变更、关闭 | 当前样本数量偏少,变更单为空 |
+| 合同评审 | `ado_contract_review` 已有样本;`CR2026050001` 已补 8 条 `ado_contract_review_flow` 最小节点样本 | 3 个合同评审,至少 1 个完整流程节点 | 最小样本已满足,仍需补更多真实流程、退回/超时/并行节点 |
+| 产品设计 | `ado_product_design`、BOM、工艺已有样本;`PD2026050001` 已补租户内计划/实际时间用于闭环 | 5 个产品设计,含正常、延期、缺实际完成时间 | 当前已验证正常场景;延期/缺数据样本仍需补 |
+| 工单/生产 | `mes_moentry` 等运行表有少量样本 | 10 个工单,覆盖排程、备料、报工、入库 | S1 本轮只读,深层 DWD 化待 S2/S6 |
+| 采购/交货 | 订单交付 8 Tab 仍只读采购/交货运行表 | 10 行采购/交货数据 | 旧系统真实来源待确认,当前不造大批量数据 |
+| 发货计划/ASN | ASN 主/明细有样本;已补 `SO001/AIDOP-DEMO-001` 的 `ShippingPlan`、`ShippingPlanDetail`、`LinkagePlan` 最小样本并验证到 DWD/KPI | 5 个发货计划/ASN,8 条计划联动节点 | 仍需补多订单、多节点、异常/未启用、实际发货样本 |
+
+本轮准备结论:最小闭环数据清单已确定,当前库已有订单、合同评审、合同评审流程节点、产品设计、BOM/工艺、ASN、需求核验部分样本;发货计划/计划联动、合同评审流程节点已补最小样本并完成在线刷新验证。后续阻塞主要是更多业务场景样本、成品库存周转口径与浏览器登录租户人工验收。
+
+### 2.15 需求明细核验处理结论(2026-05-28)
+
+当前实现:
+
+- 前端页面:`Web/src/views/aidop/s1/sales-kanban/RequirementExamineDetailList.vue`,用户输入条件后调用 `/api/Order/examine-detail/list`。
+- 后端服务:`RequirementExamineDetailService.PageRequirementExamineDetail`,已改为读取 `dwd_requirement_examine_detail` 最新成功批次。
+- 当前库有效样本:按 `COALESCE(bce.tenant_id, ber.tenant_id, sen.tenant_id)` 统计,租户 `797403760988229` 有 76 行。
+- MDP 元数据:已登记 `S1_REQUIREMENT_EXAMINE_RESULT` / `S1_REQUIREMENT_EXAMINE_DETAIL` 对应 `b_examine_result` / `b_bom_child_examine`。
+
+本轮已完成:
+
+- 新增运行时 DWD 表 `dwd_requirement_examine_detail`,S1 刷新时幂等建表并由贴源 JSON 生成资源核验快照。
+- 需求明细核验接口按 `tenant_id + 最新 SUCCESS batch` 读取 DWD,避免跨租户和历史批次串数。
+- 订单交付 Tab2 评审、Tab4 物料需求复用同一张 DWD;`b_bom_child_examine.is_use` 已兼容 bit 贴源值 `base64:type16:AQ==`。
+- 后端 `dotnet build` 通过;在线刷新验证受旧库 CodeFirst 启动阻塞影响,数据库侧已按服务 SQL 口径确认可生成 76 行 DWD,待后端稳定监听后补跑端到端 refresh。
+
+处理建议:
+
+- 短期:继续按“资源检查快照”使用 `dwd_requirement_examine_detail`,不扩展 KPI。
+- 中期:补多订单、多版本资源核验样本,并补齐与订单交付 Tab4 的新旧口径对账。
+
 ---
 
 ## 3. 已落地代码清单(按模块)
@@ -249,6 +331,14 @@ Block 7 已通过,本节为收尾建议:
 - [x] **Decide-1** `Database.json` 末尾 `AllowPublicKeyRetrieval=True;` 保留(B1,2026-05-26);安全收敛留作下迭代单独 PR(SSL + 密码外置)
 - [x] **Menu-1** 已补 `MDP运行监控` 正式落库脚本 `1.0.126.sql`,并修复角色授权保存链路:角色配置勾选该菜单后按租户菜单范围正常持久化(2026-05-26)
 - [x] **Menu-2** 已增强 `MDP运行监控` 链路可视化(第一阶段只读):阶段说明、实体链路、字段映射、本批次实体日志(见 §2.11)
+- [x] **Verify-5** 建立 S1 功能验收矩阵,覆盖合同评审、产品设计、订单评审、订单交付、订单发货、工单池、需求明细、计划联动(见 §2.12)
+- [x] **Verify-6** 标记每项 S1 功能状态:`已切 MDP/DWD`、`业务表写入 + MDP 来源`、`仍查运行表/旧表`、`待人工核对`、`待补数验证`(见 §2.12)
+- [x] **Verify-7** 已使用 `AIDOPDemo` 登录租户 `797403760988229` 抽检 S1 MDP 监控、订单列表、订单交付列表 API;页面浏览器视觉验收仍建议负责人再抽看一次
+- [x] **Verify-8** 核对 S1 L2/L3/L4 KPI 是否真实产出:L1 001/002/003、L2 001/002/003/004/005/006/010/011/012 已接真实计算;L3/L4 仍为演示/待补状态(见 §2.12、§2.13)
+- [x] **Verify-9** 执行 `1.0.132.sql` 后重跑 S1 MDP,核对产品设计实体、L2 KPI 与 `calc_time`:`S1_MDP_FULL_20260528195924` SUCCESS,产品设计贴源进入最新 batch;`S1_L2_001/002/003/006/010/011/012` 有本轮 `calc_time`
+- [x] **Data-1** S1 最小闭环测试数据清单已建立,明确当前样本与缺口(见 §2.14)
+- [x] **Data-2** 已补发货计划/计划联动最小样本并完成在线刷新验证:`S1_MDP_FULL_20260528225707` SUCCESS,`SO001/AIDOP-DEMO-001` 在 DWD 中带出计划量与联动状态(见 §2.3、§2.12、§2.14)
+- [x] **Build-2** 2026-05-28 后端 `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug` 通过(159 warnings / 0 errors,警告为既有包漏洞与 XML 注释问题)
 - [ ] **Commit-1** 按 `.cursor/rules/version-bump-on-commit.mdc`,后端版本已 bump 至 1.0.126,提交:<br>• `S1MdpSyncTransformService.cs`(CAST 守卫 + tenant_id 兜底 + MarkFailed try-catch)<br>• `S3MdpSyncTransformService.cs`(MarkFailed try-catch)<br>• `Database.json`(保留 `AllowPublicKeyRetrieval` + 连接池参数)<br>• `SysRoleMenuService.cs` / `editRole.vue` / `1.0.126.sql`(MDP 菜单授权彻底修复) |
 
 ---
@@ -334,6 +424,16 @@ cd Web; pnpm dev
 | 2026-05-26 | Cursor 会话 | 应用侧加固:S1/S3 的 `MarkSyncLogFailedAsync` / `MarkTransformRunFailedAsync` 均包 try-catch;`Database.json` 加 `Pooling/MinPool=2/MaxPool=50/Lifetime=300`;`dotnet build` 通过;上午 165 磁盘满导致 MySQL/Git 短暂不可用,恢复后 10:27 现网 refresh `S1_MDP_FULL_20260526102739` SUCCESS(id=478);删最早远端备份分支 `backup/full-2026-04-06`;清孤儿 `mdp_transform_run_log id=471 → FAILED` |
 | 2026-05-26 | Cursor 会话 | 修复「数据中台管理 → MDP运行监控」菜单不显示 / 概览跳转 404:补 `1.0.126.sql` 幂等写入 `SysMenu 1320990000406` 与各租户 `SysTenantMenu`;前端角色授权提交 checked+halfChecked;后端 `GrantRoleMenu` 按角色租户的 `SysTenantMenu` 过滤并持久化授权;前端 `pnpm build`、后端 `dotnet build` 均通过;后端已重启到 1.0.126 且脚本执行成功。 |
 | 2026-05-26 | Cursor 会话 | MDP 链路可视化第一阶段:确认原设计为“配置化元数据 + 代码执行范式”,新增统一监控 lineage 只读接口和前端链路详情抽屉,可查看阶段说明、实体源/目标、字段映射、本批次 `mdp_sync_log` 行数与错误;不做前端配置写入,不引入 `mdp_pipeline*` 配置驱动执行器。 |
+| 2026-05-28 | Cursor 会话 | 推进 S1 彻底打通:新增 S1 功能验收矩阵与数据覆盖清单;产品设计主表/BOM/工艺路线纳入 MDP 实体与贴源配置;产品设计服务补租户隔离;订单交付价值流基础/订单/变更信息优先读 DWD/MDP;补合同评审与产品设计 L2 KPI 真实计算,L3/L4 明确为待补。 |
+| 2026-05-28 | Cursor 会话 | 后端构建通过;实库已部分 upsert 产品设计 MDP 实体和前三类字段映射,但 `user-mysql-aidopdev` MCP 随后长时间无响应,在线 refresh / KPI `calc_time` 验证暂未完成,已记录为当前阻塞。 |
+| 2026-05-28 | Cursor 会话 | MCP/MySQL 恢复后完成端到端验证:确认产品设计实体和 `raw_data` 映射存在;两次 refresh 成功,最新 batch `S1_MDP_FULL_20260528195924` SUCCESS;修复 JSON 日期解析后 `S1_L2_001/002/003/006/010/011/012` 写入本轮 `calc_time`。产品设计周期/满足率仍缺带租户日期样本。 |
+| 2026-05-28 | Cursor 会话 | 按确认更新测试样本 `PD2026050001` 的计划/实际设计时间与负责人;重跑 `S1_MDP_FULL_20260528204055` SUCCESS;`S1_L2_004/005/006` 均写入本轮 `calc_time`。后端 build 通过(110 warnings / 0 errors)。 |
+| 2026-05-28 | Cursor 会话 | 需求明细核验补当前租户过滤,避免跨租户读取 `b_examine_result` / `b_bom_child_examine` / `crm_seorderentry`;确认现有样本集中在租户 `797403760988229` 共 76 行;短期仍作为资源检查运行表只读,MDP/DWD 化作为后续专项。 |
+| 2026-05-28 | Cursor 会话 | 出货计划列表/详情/保存/明细保存补当前租户过滤与写入;补 `SO001/AIDOP-DEMO-001` 最小 `ShippingPlan` / `ShippingPlanDetail` / `LinkagePlan` 样本;`S1_MDP_FULL_20260528225707` SUCCESS,标准层新增 `LINKAGE_PLAN`,DWD 已带出 `planned_ship_qty=100`、`delivery_status=PLANNED`、`linkage_status=LINKED`。 |
+| 2026-05-28 | Cursor 会话 | 使用 `AIDOPDemo` 完成目标租户登录态接口验收:S1 监控、订单列表、交付列表均返回 200;修复交付列表直接读取 DWD 全历史批次导致的重复行,改为只取最新 `S1_MDP_SYNC_TRANSFORM` 成功批次。 |
+| 2026-05-29 | Cursor 会话 | 补 `CR2026050001` 合同评审流程最小样本 8 条;`S1MdpSyncTransformService` 新增合同评审 L3/L4 节点耗时与人效链路 KPI 真实计算,L4 优先按 `DeptNo` 映射;修正贴源中文节点名后 `S1_MDP_FULL_20260529000638` SUCCESS,L3/L4 指标均已写入对应日表。 |
+| 2026-05-29 | Cursor 会话 | 新增 S1 发货/计划联动独立 L2 KPI:`S1_L2_013` 发货计划覆盖率、`S1_L2_014` 发货达成率、`S1_L2_015` 计划联动达成率;按本轮 `dwd_ship_trans.calc_batch_id` 汇总到 `factory_id=1`;`S1_MDP_FULL_20260529003054` SUCCESS,指标值 `25% / 0% / 100%` 已落表。 |
+| 2026-05-29 | Cursor 会话 | 扩展 S1“本轮不做”项:新增需求核验贴源实体与 `dwd_requirement_examine_detail`,需求核验页面及订单交付 Tab2/Tab4 改读 DWD;Tab8 成品发运改读 `mdp_std_ship_trans`;补 `S1_L1_004` 数量代理口径并标明非官方成本口径;发现旧库 CodeFirst 启动阻塞导致新版后端未稳定监听。数据库侧已按修正后 SQL 验证需求核验 DWD 可生成 76 行,完整端到端 refresh 待启动阻塞修复后补跑。 |
 
 ---
 

+ 143 - 0
doc/plan/新系统数据库简明介绍与S1数据流.md

@@ -0,0 +1,143 @@
+# 新系统数据库简明介绍与 S1 数据流
+
+> 面向项目成员快速理解新系统数据库结构、数据中台分层,以及 S1 产销协同数据如何从业务表流向页面、看板和 KPI。
+
+## 1. 数据库总体定位
+
+新系统以 MySQL `aidopdev` 为当前主库,后端采用 Admin.NET + SqlSugar。系统不是简单迁移旧 SQL Server 表,而是在新项目中重写业务模块,并逐步把旧系统业务数据沉淀到统一数据中台。
+
+当前数据库大致分为四类表:
+
+| 类型 | 代表表 | 用途 |
+|------|--------|------|
+| 系统与权限 | `sys_*` | 用户、角色、菜单、租户、调度等 Admin.NET 基础能力 |
+| 业务运行表 | `crm_*`、`mes_*`、`srm_*`、`ShippingPlan*`、`LinkagePlan` 等 | 页面保存、业务流程、生产采购发货等实时运行数据 |
+| 数据中台表 | `mdp_source`、`mdp_entity`、`mdp_stg_*`、`mdp_std_*`、`dwd_*` | 外部/运行数据接入、标准化、主题宽表、数据血缘 |
+| 指标与看板 | `ado_smart_ops_kpi_master`、`ado_s9_kpi_value_*` | 指标定义、日粒度指标值、九宫格/运营看板消费 |
+
+需要注意:设计文档里常说的 `dop_core`、`dop_mdp`、`dop_bi` 是逻辑分层。当前实现上主要仍在同一个 MySQL 库内,通过表名前缀和服务职责区分。
+
+## 2. 数据中台分层
+
+新系统数据流遵循一个简单原则:业务表负责“发生了什么”,中台表负责“整理成可复用数据”,指标表负责“算出管理口径”。
+
+```text
+业务运行表 / 外部系统
+        │
+        ▼
+mdp_stg_* 贴源层
+保留源行、raw_data、source_table、source_row_id、sync_batch_id
+        │
+        ▼
+mdp_std_* 标准层
+统一字段口径,例如订单、发货、采购、物料等标准对象
+        │
+        ▼
+dwd_* 明细宽表层
+面向页面、诊断、价值流和指标计算的主题事实表
+        │
+        ▼
+ado_s9_kpi_value_* 指标层
+按天写入 L1/L2/L3/L4 指标值,供 S9/九宫格/模块看板读取
+```
+
+配套的运行记录主要有两类:
+
+| 表 | 说明 |
+|----|------|
+| `mdp_transform_run_log` | 一次模块级同步转换任务的总日志,例如 `S1_MDP_SYNC_TRANSFORM` |
+| `mdp_sync_log` | 每个源实体的贴源同步日志,记录读取行数、状态和错误 |
+
+## 3. S1 数据流总览
+
+S1 产销协同当前采用 S3 试点范式:由 `S1MdpSyncTransformService` 统一执行贴源、标准化、DWD 构建和 KPI 写入。
+
+```text
+S1 业务运行数据
+订单 / 合同评审 / 产品设计 / 发货计划 / ASN / 计划联动 / 需求核验
+        │
+        ▼
+mdp_stg_so、mdp_stg_ship_trans
+        │
+        ▼
+mdp_std_so、mdp_std_ship_trans
+        │
+        ▼
+dwd_ship_trans、dwd_requirement_examine_detail
+        │
+        ├─► S1 页面接口:订单列表、订单交付列表、需求核验、订单交付部分 Tab
+        │
+        └─► ado_s9_kpi_value_*:订单评审、合同评审、产品设计、发货/联动等 KPI
+```
+
+S1 作业信息:
+
+| 项 | 内容 |
+|----|------|
+| 作业编码 | `S1_MDP_SYNC_TRANSFORM` |
+| 批次格式 | `S1_MDP_FULL_yyyyMMddHHmmss` |
+| 手动刷新入口 | `POST /api/Order/linkageplan/refresh` |
+| 监控入口 | `mdp_transform_run_log`、`mdp_sync_log`、前端 MDP 运行监控 |
+
+## 4. S1 主要对象映射
+
+| 业务对象 | 运行表 / 源对象 | 贴源层 | 标准层 / DWD | 主要消费 |
+|----------|----------------|--------|--------------|----------|
+| 销售订单 | `crm_seorder`、`crm_seorderentry` | `mdp_stg_so` | `mdp_std_so`、`dwd_ship_trans` | 订单列表、订单交付列表、订单评审 KPI |
+| 订单变更 | `crm_seorder_change` | `mdp_stg_so` | 暂以贴源 + `mdp_std_so` 关联 | 订单交付 Tab2 变更记录 |
+| 合同评审 | `ado_contract_review`、`ado_contract_review_flow` | `mdp_stg_so` | KPI 计算直接消费贴源 | 合同评审 L2/L3/L4 KPI |
+| 产品设计 | `ado_product_design`、BOM、工艺 | `mdp_stg_so` | KPI 计算直接消费贴源 | 产品设计周期、满足率、人效 |
+| 发货计划 | `ShippingPlan`、`ShippingPlanDetail` | `mdp_stg_ship_trans` | `mdp_std_ship_trans`、`dwd_ship_trans` | 发货计划覆盖率、订单交付列表 |
+| ASN 发货 | `ASNBOLShipperMaster`、`ASNBOLShipperDetail` | `mdp_stg_ship_trans` | `mdp_std_ship_trans`、`dwd_ship_trans` | 发货达成率、Tab8 成品发运 |
+| 计划联动 | `LinkagePlan` | `mdp_stg_ship_trans` | `mdp_std_ship_trans`、`dwd_ship_trans` | 计划联动达成率 |
+| 需求核验 | `b_examine_result`、`b_bom_child_examine` | `mdp_stg_so` | `dwd_requirement_examine_detail` | 需求核验页面、订单交付 Tab2/Tab4 |
+
+## 5. S1 页面与接口读取关系
+
+| 页面 / 功能 | 当前主要读数 |
+|-------------|--------------|
+| S1 订单列表 | `mdp_std_so` |
+| 订单交付列表 | `dwd_ship_trans`,只取最新成功批次 |
+| 订单交付基础信息 / Tab1 | `dwd_ship_trans` + `mdp_std_so` |
+| 订单交付 Tab2 评审 | `dwd_requirement_examine_detail` |
+| 订单交付 Tab2 变更 | `mdp_stg_so` 中的订单变更贴源 |
+| 订单交付 Tab4 物料需求 | `dwd_requirement_examine_detail` |
+| 订单交付 Tab8 成品发运 | `mdp_std_ship_trans` 的 ASN 发货记录 |
+| 需求明细核验 | `dwd_requirement_examine_detail` |
+| MDP 运行监控 | `mdp_transform_run_log`、`mdp_sync_log` |
+
+仍需分阶段收敛的部分:订单交付 Tab3 排程、Tab5 采购/交货、Tab6 备料、Tab7 报工/IQC、Tab8 FQC/入库仍依赖 MES/SRM/QMS/WMS 等运行表。它们跨 S2/S3/S4/S6/S7,不宜在 S1 内强行重定义。
+
+## 6. S1 KPI 流向
+
+S1 指标定义在 `ado_smart_ops_kpi_master`,计算结果写入 `ado_s9_kpi_value_l1_day`、`ado_s9_kpi_value_l2_day`、`ado_s9_kpi_value_l3_day`、`ado_s9_kpi_value_l4_day`。
+
+已形成真实或最小真实口径的指标包括:
+
+| 指标类别 | 示例 |
+|----------|------|
+| 订单评审 | `S1_L1_001`、`S1_L1_002`、`S1_L1_003` |
+| 合同评审 | `S1_L2_001`、`S1_L2_002`、`S1_L2_003`,以及 L3/L4 节点指标 |
+| 产品设计 | `S1_L2_004`、`S1_L2_005`、`S1_L2_006` |
+| 交期评审 | `S1_L2_010`、`S1_L2_011`、`S1_L2_012` |
+| 发货与联动 | `S1_L2_013` 发货计划覆盖率、`S1_L2_014` 发货达成率、`S1_L2_015` 计划联动达成率 |
+
+特别说明:`S1_L1_004` 成品库存周转当前是基于 `dwd_ship_trans` 的数量代理口径,不是正式的“库存金额 / 销售成本”口径。正式口径需要 S7 成品库存快照、BOM 成本或 ERP 成本数据支撑。
+
+## 7. 阅读与维护建议
+
+想看详细设计,可继续阅读:
+
+- `doc/plan/新系统数据库设计方案.md`
+- `doc/plan/新系统数据库实施总方案.md`
+- `doc/plan/数据库迁移/S1/S1数据中台迁移实施计划.md`
+- `doc/plan/数据库迁移/S1/S1-任务交接记忆.md`
+
+日常排查 S1 数据时,建议按这个顺序看:
+
+1. 查 `mdp_transform_run_log` 最近一次 `S1_MDP_SYNC_TRANSFORM` 是否成功。
+2. 查 `mdp_sync_log` 中每个 S1 源实体是否成功贴源。
+3. 查 `mdp_stg_so` / `mdp_stg_ship_trans` 是否有源数据。
+4. 查 `mdp_std_so` / `mdp_std_ship_trans` 是否标准化成功。
+5. 查 `dwd_ship_trans` / `dwd_requirement_examine_detail` 是否生成最新批次数据。
+6. 查 `ado_s9_kpi_value_*` 是否写入对应指标。

+ 23 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs

@@ -19,11 +19,13 @@ public static class AidopKpiMasterSeed
             if (db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId).Any())
             {
                 EnsureS1DiagnosisDrillSeed(db, tenantId);
+                EnsureS1ShippingLinkageKpiSeed(db, tenantId);
                 EnsureS1DiagnosisDemoValues(db, tenantId);
                 return;
             }
             SeedAll(db, tenantId);
             EnsureS1DiagnosisDrillSeed(db, tenantId);
+            EnsureS1ShippingLinkageKpiSeed(db, tenantId);
             EnsureS1DiagnosisDemoValues(db, tenantId);
         }
         catch (Exception ex)
@@ -835,6 +837,27 @@ public static class AidopKpiMasterSeed
         EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l4_day", "S1_L4_204", 2m, 2m, "green");
     }
 
+    private static void EnsureS1ShippingLinkageKpiSeed(ISqlSugarClient db, long tenantId)
+    {
+        var byCode = db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == "S1")
+            .ToList()
+            .ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        long? Parent(string code) => byCode.TryGetValue(code, out var item) ? item.Id : null;
+        var rateParent = Parent("S1_L1_002");
+
+        EnsureIns(db, byCode, tenantId, "S1_L2_013", "S1", 2, rateParent, "发货计划覆盖率(%)", "%", "higher_is_better", 13, false,
+            "已形成发货计划的订单行占订单行总数比例", "有计划发货数量的订单行数/订单行总数*100%",
+            "按本轮 DWD 订单交付事实统计 planned_ship_qty>0 的订单行覆盖率", "S1-产销协同", "天", "供应链/计划部", "planned_ship_qty", "");
+        EnsureIns(db, byCode, tenantId, "S1_L2_014", "S1", 2, rateParent, "发货达成率(%)", "%", "higher_is_better", 14, false,
+            "已按计划完成发货的订单行占已计划订单行比例", "实际发货数量>=计划发货数量的订单行数/已计划订单行数*100%",
+            "按本轮 DWD 订单交付事实统计 shipped_qty 与 planned_ship_qty", "S1-产销协同", "天", "仓储物流部", "planned_ship_qty,shipped_qty", "");
+        EnsureIns(db, byCode, tenantId, "S1_L2_015", "S1", 2, rateParent, "计划联动达成率(%)", "%", "higher_is_better", 15, false,
+            "已完成计划联动的订单行占已计划订单行比例", "linkage_status=LINKED 的订单行数/已计划订单行数*100%",
+            "按本轮 DWD 订单交付事实统计计划联动状态", "S1-产销协同", "天", "供应链/计划部", "linkage_status", "");
+    }
+
     private static void EnsureKpiValue(ISqlSugarClient db, long tenantId, string table, string metricCode, decimal target, decimal actual, string status)
     {
         var bizObj = db.Ado.GetScalar(

+ 188 - 157
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderDeliveryService.cs

@@ -32,7 +32,17 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var innerConditions = new List<string>
         {
             "d.tenant_id = @TenantId",
-            "d.order_no IS NOT NULL"
+            "d.order_no IS NOT NULL",
+            """
+            d.calc_batch_id = (
+                SELECT r.batch_id
+                FROM mdp_transform_run_log r
+                WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
+                  AND r.status = 'SUCCESS'
+                ORDER BY r.start_time DESC, r.id DESC
+                LIMIT 1
+            )
+            """
         };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
@@ -128,24 +138,22 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
     [HttpGet("delivery/flow/{entryId}")]
     public async Task<object> GetDeliveryFlow(long entryId)
     {
-        var tenantId = _userManager.TenantId;
-
         // 同一 ISqlSugarClient 底层共用一条连接,不能对 _db.Ado 并发执行多查询(MySqlConnector 会报 Cannot Open when State is Connecting)
-        var basic = await QueryBasic(entryId, tenantId);
-        var orderInfo = await QueryOrderInfo(entryId, tenantId);
-        var review = await QueryReview(entryId, tenantId);
-        var changes = await QueryChanges(entryId, tenantId);
-        var schedule = await QuerySchedule(entryId, tenantId);
-        var material = await QueryMaterials(entryId, tenantId);
-        var po = await QueryPo(entryId, tenantId);
-        var pr = await QueryPr(entryId, tenantId);
-        var deliverySchedule = await QueryDeliverySchedule(entryId, tenantId);
-        var nbr = await QueryNbr(entryId, tenantId);
-        var prodReport = await QueryProdReport(entryId, tenantId);
-        var iqc = await QueryIqc(entryId, tenantId);
-        var fqc = await QueryFqc(entryId, tenantId);
-        var receipt = await QueryReceipt(entryId, tenantId);
-        var shipment = await QueryShipment(entryId, tenantId);
+        var basic = await QueryBasic(entryId);
+        var orderInfo = await QueryOrderInfo(entryId);
+        var review = await QueryReview(entryId);
+        var changes = await QueryChanges(entryId);
+        var schedule = await QuerySchedule(entryId);
+        var material = await QueryMaterials(entryId);
+        var po = await QueryPo(entryId);
+        var pr = await QueryPr(entryId);
+        var deliverySchedule = await QueryDeliverySchedule(entryId);
+        var nbr = await QueryNbr(entryId);
+        var prodReport = await QueryProdReport(entryId);
+        var iqc = await QueryIqc(entryId);
+        var fqc = await QueryFqc(entryId);
+        var receipt = await QueryReceipt(entryId);
+        var shipment = await QueryShipment(entryId);
 
         return new
         {
@@ -162,45 +170,61 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
     }
 
     // ── 基础信息 ──
-    private async Task<FlowBasicRow?> QueryBasic(long entryId, long tenantId)
+    private async Task<FlowBasicRow?> QueryBasic(long entryId)
     {
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                entry.item_number   AS ItemNumber,
-                entry.item_name     AS ItemName,
-                entry.specification AS Specification,
-                entry.qty           AS Qty,
-                sorder.bill_from    AS BillFrom,
-                entry.plan_date     AS PlanDate,
-                CAST(entry.progress AS CHAR) AS Progress,
-                entry.update_time   AS Mrptime
-            FROM crm_seorderentry entry
-            LEFT JOIN crm_seorder sorder ON entry.seorder_id = sorder.Id
-            WHERE entry.Id = @Id AND entry.IsDeleted = 0 AND entry.tenant_id = @TenantId
+                d.item_code          AS ItemNumber,
+                d.item_name          AS ItemName,
+                d.item_spec          AS Specification,
+                d.order_qty          AS Qty,
+                so.customer_order_no AS BillFrom,
+                d.plan_delivery_date AS PlanDate,
+                CASE
+                    WHEN d.delivery_status = 'COMPLETED' THEN '7'
+                    WHEN d.planned_ship_qty > 0 THEN '7'
+                    WHEN d.order_status = 'CLOSED' THEN '7'
+                    WHEN d.review_status IS NOT NULL THEN '1'
+                    ELSE '1'
+                END                  AS Progress,
+                d.update_time        AS Mrptime
+            FROM dwd_ship_trans d
+            LEFT JOIN mdp_std_so so
+              ON so.tenant_id = d.tenant_id
+             AND so.order_entry_id = d.order_entry_id
+            WHERE (d.order_entry_id = @Id OR d.id = @Id)
+              AND d.tenant_id = @TenantId
+              AND d.order_no IS NOT NULL
+            ORDER BY d.calc_time DESC, d.id DESC
+            LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowBasicRow>(sql, new { Id = entryId, TenantId = tenantId })).FirstOrDefault();
     }
 
     // ── Tab1 订单信息 ──
-    private async Task<FlowOrderInfoRow?> QueryOrderInfo(long entryId, long tenantId)
+    private async Task<FlowOrderInfoRow?> QueryOrderInfo(long entryId)
     {
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                sorder.bill_no          AS BillNo,
-                sorder.custom_name      AS CustomName,
-                sorder.custom_level     AS CustomLevel,
-                sorder.flowstate        AS State,
-                sorder.date             AS Date,
-                sorder.create_by_name   AS CreateByName,
-                sorder.order_type       AS OrderType,
-                entry.update_time       AS AuditDate,
-                sorder.auditor          AS Auditor,
-                sorder.bill_from        AS BillFrom,
-                sorder.emp_name         AS EmpName,
-                sorder.emp_no           AS EmpNo
-            FROM crm_seorderentry entry
-            LEFT JOIN crm_seorder sorder ON entry.seorder_id = sorder.Id
-            WHERE entry.Id = @Id AND entry.IsDeleted = 0 AND entry.tenant_id = @TenantId
+                so.order_no          AS BillNo,
+                so.customer_name     AS CustomName,
+                NULL                 AS CustomLevel,
+                so.flow_state        AS State,
+                so.order_date        AS Date,
+                NULL                 AS CreateByName,
+                so.order_type        AS OrderType,
+                so.update_time       AS AuditDate,
+                NULL                 AS Auditor,
+                so.customer_order_no AS BillFrom,
+                so.planner_name      AS EmpName,
+                so.planner_no        AS EmpNo
+            FROM mdp_std_so so
+            WHERE so.order_entry_id = @Id
+              AND so.tenant_id = @TenantId
+            ORDER BY so.sync_time DESC, so.id DESC
+            LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowOrderInfoRow>(sql, new { Id = entryId, TenantId = tenantId })).FirstOrDefault();
     }
@@ -209,52 +233,61 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
     /// <remarks>
     /// 查询最新评审记录(num=1 的首行物料)的预计交期评估信息。
     /// </remarks>
-    private async Task<FlowReviewRow?> QueryReview(long entryId, long tenantId)
+    private async Task<FlowReviewRow?> QueryReview(long entryId)
     {
-        //测试提交
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                b.satisfy_time AS SysCapacityDate,
-                c.moentry_mono AS MorderNo,
-                c.create_time AS CreateTime,
-                IF(b.make_qty = 0 AND b.erp_cls = 1, b.lack_qty, b.make_qty) AS MakeQty,
-                b.use_qty AS UseQty,
-                b.mo_qty AS MoQty
-            FROM b_examine_result a
-            LEFT JOIN b_bom_child_examine b ON a.id = b.examine_id
-            LEFT JOIN mes_moentry c ON a.sentry_id = c.soentry_id
-            LEFT JOIN mes_morder d ON c.moentry_mono = d.morder_no            WHERE a.tenant_id = @TenantId AND a.create_time = (
-                SELECT MAX(create_time)
-                FROM b_examine_result
-                WHERE sentry_id = @EntryId AND tenant_id = @TenantId
-            )
-            AND b.num = 1
-            AND a.sentry_id = @EntryId
+                d.satisfy_time AS SysCapacityDate,
+                d.morder_no AS MorderNo,
+                d.create_time AS CreateTime,
+                d.make_qty AS MakeQty,
+                d.use_qty AS UseQty,
+                d.mo_qty AS MoQty
+            FROM dwd_requirement_examine_detail d
+            WHERE d.order_entry_id = @EntryId
+              AND d.tenant_id = @TenantId
+              AND d.num = '1'
+              AND d.calc_batch_id = (
+                  SELECT r.batch_id
+                  FROM mdp_transform_run_log r
+                  WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
+                    AND r.status = 'SUCCESS'
+                  ORDER BY r.start_time DESC, r.id DESC
+                  LIMIT 1
+              )
+            ORDER BY d.create_time DESC, d.row_id
             LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowReviewRow>(sql, new { EntryId = entryId, TenantId = tenantId })).FirstOrDefault();
     }
 
     // ── Tab2 变更记录(MySQL:按订单行关联变更单)──
-    private async Task<List<FlowChangeRow>> QueryChanges(long entryId, long tenantId)
+    private async Task<List<FlowChangeRow>> QueryChanges(long entryId)
     {
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
                 ROW_NUMBER() OVER (ORDER BY a.id) AS Sno,
-                a.bill_no AS BillNo,
-                a.change_Type AS ChangeType,
-                a.change_Reason AS ChangeReason,
-                a.change_content AS ChangeContent,
+                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.bill_no')) AS BillNo,
+                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_Type')) AS ChangeType,
+                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_Reason')) AS ChangeReason,
+                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_content')) AS ChangeContent,
                 a.update_time AS UpdateTime
-            FROM crm_seorder_change a
-            LEFT JOIN crm_seorderentry b ON a.bill_no = b.bill_no            WHERE a.bill_no IS NOT NULL AND b.id = @Id AND b.tenant_id = @TenantId
+            FROM mdp_stg_so a
+            INNER JOIN mdp_std_so so
+                    ON so.order_entry_id = @Id
+                   AND so.order_no = JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.bill_no'))
+                   AND so.tenant_id = a.tenant_id
+            WHERE a.source_table = 'crm_seorder_change'
+              AND a.tenant_id = @TenantId
             ORDER BY a.id
             """;
         return await _db.Ado.SqlQueryAsync<FlowChangeRow>(sql, new { Id = entryId, TenantId = tenantId });
     }
 
     // ── Tab3 计划排程1(MySQL:按分录 Id 关联工单排程 + 物料主档)──
-    private async Task<List<FlowScheduleRow>> QuerySchedule(long entryId, long tenantId)
+    private async Task<List<FlowScheduleRow>> QuerySchedule(long entryId)
     {
         const string sql = """
             SELECT
@@ -271,66 +304,61 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
             FROM PeriodSequenceDet sch
             LEFT JOIN ItemMaster im ON sch.ItemNum = im.ItemNum            LEFT JOIN mes_morder mo ON mo.morder_no = sch.WorkOrds            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid
             LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id
-            WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0
+            WHERE se.id = @EntryId AND mo.IsDeleted = 0
             ORDER BY sch.PlanDate
             """;
-        return await _db.Ado.SqlQueryAsync<FlowScheduleRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowScheduleRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab4 物料需求(MySQL:最新评审结果 + BOM 子件明细,同需求明细核验口径)──
-    private async Task<List<FlowMaterialRow>> QueryMaterials(long entryId, long tenantId)
+    private async Task<List<FlowMaterialRow>> QueryMaterials(long entryId)
     {
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                ROW_NUMBER() OVER (ORDER BY bce.id) AS Sno,
-                CAST(bce.num AS CHAR) AS Num,
-                bce.item_number AS ItemNumber,
-                bce.item_name AS ItemName,
-                bce.bom_number AS BomNumber,
-                bce.model AS Model,
-                DATE_FORMAT(bce.kitting_time, '%Y-%m-%d') AS KittingTime,
-                CASE WHEN bce.type = 1 THEN '替代件' ELSE '标准件' END AS Type,
-                bce.unit AS Unit,
-                CASE bce.erp_cls
-                    WHEN 0 THEN '配置类'
-                    WHEN 1 THEN '自制'
-                    WHEN 2 THEN '委外加工'
-                    WHEN 3 THEN '外购'
-                    WHEN 4 THEN '虚拟件'
-                END AS ErpClsName,
-                CAST(bce.qty AS DECIMAL(18, 2)) AS Qty,
-                CAST(bce.needCount AS DECIMAL(18, 2)) AS NeedCount,
-                CAST(bce.sqty AS DECIMAL(18, 2)) AS Sqty,
-                CAST(bce.use_qty AS DECIMAL(18, 2)) AS UseQty,
-                CAST(bce.self_lack_qty AS DECIMAL(18, 2)) AS SelfLackQty,
-                CAST(bce.lack_qty AS DECIMAL(18, 2)) AS LackQty,
-                CAST(bce.mo_qty AS DECIMAL(18, 2)) AS MoQty,
-                CAST(IF(bce.make_qty = 0 AND bce.erp_cls = 1, bce.lack_qty, bce.make_qty) AS DECIMAL(18, 2)) AS MakeQty,
-                CAST(bce.purchase_qty AS DECIMAL(18, 2)) AS PurchaseQty,
-                CAST(bce.purchase_occupy_qty AS DECIMAL(18, 2)) AS PurchaseOccupyQty,
-                CAST(bce.subcontracting_qty AS DECIMAL(18, 2)) AS SubcontractingQty,
-                CASE WHEN bce.backflush = 1 THEN '是' ELSE '否' END AS Backflush,
-                DATE_FORMAT(bce.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
-                CASE WHEN bce.haveicsubs = 1 THEN '是' ELSE '否' END AS HaveIcSubs,
-                bce.substitute_code AS SubstituteCode
-            FROM crm_seorder se
-            INNER JOIN crm_seorderentry sen ON se.id = sen.seorder_id
-            LEFT JOIN b_examine_result ber ON ber.Id = (
-                SELECT br.Id
-                FROM b_examine_result br
-                WHERE br.sorderid = se.id AND br.sentry_id = sen.id AND br.tenant_id = @TenantId
-                ORDER BY br.create_time DESC
-                LIMIT 1
-            )
-            INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
-            WHERE sen.id = @EntryId AND sen.tenant_id = @TenantId
-            ORDER BY bce.id ASC
+                ROW_NUMBER() OVER (ORDER BY d.row_id) AS Sno,
+                d.num AS Num,
+                d.item_number AS ItemNumber,
+                d.item_name AS ItemName,
+                d.bom_number AS BomNumber,
+                d.model AS Model,
+                DATE_FORMAT(d.kitting_time, '%Y-%m-%d') AS KittingTime,
+                d.item_type AS Type,
+                NULL AS Unit,
+                d.erp_cls_name AS ErpClsName,
+                d.qty AS Qty,
+                d.need_count AS NeedCount,
+                d.sqty AS Sqty,
+                d.use_qty AS UseQty,
+                d.self_lack_qty AS SelfLackQty,
+                d.lack_qty AS LackQty,
+                d.mo_qty AS MoQty,
+                d.make_qty AS MakeQty,
+                d.purchase_qty AS PurchaseQty,
+                d.purchase_occupy_qty AS PurchaseOccupyQty,
+                NULL AS SubcontractingQty,
+                NULL AS Backflush,
+                DATE_FORMAT(d.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
+                d.have_ic_subs AS HaveIcSubs,
+                d.substitute_code AS SubstituteCode
+            FROM dwd_requirement_examine_detail d
+            WHERE d.order_entry_id = @EntryId
+              AND d.tenant_id = @TenantId
+              AND d.calc_batch_id = (
+                  SELECT r.batch_id
+                  FROM mdp_transform_run_log r
+                  WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
+                    AND r.status = 'SUCCESS'
+                  ORDER BY r.start_time DESC, r.id DESC
+                  LIMIT 1
+              )
+            ORDER BY d.row_id ASC
             """;
         return await _db.Ado.SqlQueryAsync<FlowMaterialRow>(sql, new { EntryId = entryId, TenantId = tenantId });
     }
 
     // ── Tab5 采购订单(MySQL:经占用/工单关联到订单分录)──
-    private async Task<List<FlowPoRow>> QueryPo(long entryId, long tenantId)
+    private async Task<List<FlowPoRow>> QueryPo(long entryId)
     {
         const string sql = """
             SELECT
@@ -363,15 +391,15 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 LEFT JOIN srm_po_occupy occ ON pl.Id = occ.polist_id AND CAST(pm.factory_id AS CHAR(64)) = CAST(occ.factory_id AS CHAR(64))                LEFT JOIN mes_morder mo ON mo.id = occ.morder_id
                 LEFT JOIN mes_moentry me ON mo.id = me.moentry_moid
                 LEFT JOIN crm_seorderentry se ON me.soentry_id = se.id
-                WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND pm.IsDeleted = 0
+                WHERE se.id = @EntryId AND pm.IsDeleted = 0
             ) AS t
             ORDER BY t.PoBillNo
             """;
-        return await _db.Ado.SqlQueryAsync<FlowPoRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowPoRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab5 采购申请(MySQL)──
-    private async Task<List<FlowPrRow>> QueryPr(long entryId, long tenantId)
+    private async Task<List<FlowPrRow>> QueryPr(long entryId)
     {
         const string sql = """
             SELECT
@@ -392,14 +420,14 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
             LEFT JOIN mes_morder mo ON mo.id = occ.morder_id
             LEFT JOIN mes_moentry me ON mo.id = me.moentry_moid
             LEFT JOIN crm_seorderentry se ON me.soentry_id = se.id
-            WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND pm.state <> 0 AND pm.IsDeleted = 0
+            WHERE se.id = @EntryId AND pm.state <> 0 AND pm.IsDeleted = 0
             ORDER BY pm.pr_billno
             """;
-        return await _db.Ado.SqlQueryAsync<FlowPrRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowPrRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab5 物料交货单(MySQL:srm_polist_ds)──
-    private async Task<List<FlowDeliveryScheduleRow>> QueryDeliverySchedule(long entryId, long tenantId)
+    private async Task<List<FlowDeliveryScheduleRow>> QueryDeliverySchedule(long entryId)
     {
         const string sql = """
             SELECT
@@ -433,15 +461,15 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 LEFT JOIN srm_po_occupy occ ON pl.Id = occ.polist_id AND CAST(pm.factory_id AS CHAR(64)) = CAST(occ.factory_id AS CHAR(64))                LEFT JOIN mes_morder mo ON mo.id = occ.morder_id
                 LEFT JOIN mes_moentry me ON mo.id = me.moentry_moid
                 LEFT JOIN crm_seorderentry se ON me.soentry_id = se.id
-                LEFT JOIN srm_polist_ds ds ON pl.po_billno = ds.ponumber AND pl.ItemNum = ds.itemnum                LEFT JOIN ItemMaster im ON ds.itemnum = im.ItemNum                WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND pm.IsDeleted = 0
+                LEFT JOIN srm_polist_ds ds ON pl.po_billno = ds.ponumber AND pl.ItemNum = ds.itemnum                LEFT JOIN ItemMaster im ON ds.itemnum = im.ItemNum                WHERE se.id = @EntryId AND pm.IsDeleted = 0
             ) AS t
             ORDER BY t.DsNum
             """;
-        return await _db.Ado.SqlQueryAsync<FlowDeliveryScheduleRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowDeliveryScheduleRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab6 生产备料(MySQL:备料明细经工单关联分录)──
-    private async Task<List<FlowNbrRow>> QueryNbr(long entryId, long tenantId)
+    private async Task<List<FlowNbrRow>> QueryNbr(long entryId)
     {
         const string sql = """
             SELECT
@@ -461,14 +489,14 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 nd.WorkOrd AS WorkOrd
             FROM NbrDetail nd
             LEFT JOIN NbrMaster nm ON nd.nbr = nm.nbr            LEFT JOIN mes_morder mo ON mo.morder_no = nm.WorkOrd            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid
-            LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id AND mo.product_code = se.item_number            LEFT JOIN ItemMaster im ON nd.ItemNum = im.ItemNum            WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0 AND nm.Type = 'SM'
+            LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id AND mo.product_code = se.item_number            LEFT JOIN ItemMaster im ON nd.ItemNum = im.ItemNum            WHERE se.id = @EntryId AND mo.IsDeleted = 0 AND nm.Type = 'SM'
             ORDER BY nm.Nbr
             """;
-        return await _db.Ado.SqlQueryAsync<FlowNbrRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowNbrRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab7 生产报工(OpTransEmployee + 销售订单行)──
-    private async Task<List<FlowProdReportRow>> QueryProdReport(long entryId, long tenantId)
+    private async Task<List<FlowProdReportRow>> QueryProdReport(long entryId)
     {
         const string sql = """
             SELECT
@@ -487,14 +515,14 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
             FROM OpTransEmployee ote
             LEFT JOIN mes_morder mo ON mo.morder_no = ote.WorkOrd            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid
             LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id
-            LEFT JOIN ItemMaster im ON ote.ItemNum = im.ItemNum            WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0
+            LEFT JOIN ItemMaster im ON ote.ItemNum = im.ItemNum            WHERE se.id = @EntryId AND mo.IsDeleted = 0
             ORDER BY ote.ProdDate DESC
             """;
-        return await _db.Ado.SqlQueryAsync<FlowProdReportRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowProdReportRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab7 IQC信息(请购/采购/检验链路)──
-    private async Task<List<FlowIqcRow>> QueryIqc(long entryId, long tenantId)
+    private async Task<List<FlowIqcRow>> QueryIqc(long entryId)
     {
         const string sql = """
             SELECT
@@ -528,14 +556,13 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
             ) iqcjy ON pod.PurOrd = iqcjy.po_bill AND CAST(pod.Line AS CHAR(32)) = CAST(iqcjy.po_billline AS CHAR(32))            WHERE jq.flag = '1'
               AND pr.sentry_id IS NOT NULL
               AND pr.sentry_id = @EntryId
-              AND pr.tenant_id = @TenantId
             ORDER BY iqcjy.FAPPLYTIME DESC
             """;
-        return await _db.Ado.SqlQueryAsync<FlowIqcRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowIqcRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab8 FQC信息 ──
-    private async Task<List<FlowFqcRow>> QueryFqc(long entryId, long tenantId)
+    private async Task<List<FlowFqcRow>> QueryFqc(long entryId)
     {
         const string sql = """
             SELECT
@@ -550,14 +577,14 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 CAST(a.FRINSQTY AS DECIMAL(18,2)) AS FRinsQty,
                 a.FINSPEENDDATE AS FInspeEndDate
             FROM qms_qcpp_inspbill a
-            LEFT JOIN WorkOrdMaster b ON a.sczld = b.Batch            LEFT JOIN mes_moentry c ON b.WorkOrd = c.moentry_mono            WHERE c.soentry_id = @EntryId AND c.TenantId = @TenantId
+            LEFT JOIN WorkOrdMaster b ON a.sczld = b.Batch            LEFT JOIN mes_moentry c ON b.WorkOrd = c.moentry_mono            WHERE c.soentry_id = @EntryId
             ORDER BY a.FBILLNO
             """;
-        return await _db.Ado.SqlQueryAsync<FlowFqcRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowFqcRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab8 生产入库 ──
-    private async Task<List<FlowReceiptRow>> QueryReceipt(long entryId, long tenantId)
+    private async Task<List<FlowReceiptRow>> QueryReceipt(long entryId)
     {
         const string sql = """
             SELECT
@@ -590,31 +617,35 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 GROUP BY WorkOrds
             ) compp ON c.WorkOrd = compp.WorkOrds            LEFT JOIN mes_morder mo ON mo.morder_no = c.WorkOrd AND mo.IsDeleted = 0
             LEFT JOIN mes_moentry motry ON motry.moentry_moid = mo.Id AND motry.IsDeleted = 0
-            WHERE motry.soentry_id = @EntryId AND motry.TenantId = @TenantId
+            WHERE motry.soentry_id = @EntryId
             ORDER BY mo.morder_no
             """;
-        return await _db.Ado.SqlQueryAsync<FlowReceiptRow>(sql, new { EntryId = entryId, TenantId = tenantId });
+        return await _db.Ado.SqlQueryAsync<FlowReceiptRow>(sql, new { EntryId = entryId });
     }
 
     // ── Tab8 成品发运(与订单行 OrdNbr + 行物料一致)──
-    private async Task<List<FlowShipmentRow>> QueryShipment(long entryId, long tenantId)
+    private async Task<List<FlowShipmentRow>> QueryShipment(long entryId)
     {
+        var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                ROW_NUMBER() OVER (ORDER BY ad.Id) AS Sno,
-                ad.Id,
-                lm.Descr AS Location,
-                ad.UpdateTime AS ShipDate,
-                CAST(ad.QtyToShip AS DECIMAL(18,2)) AS QtyToShip,
-                lm.location as LocationCode,
-                ad.RealQty
-            FROM ASNBOLShipperDetail ad
-            INNER JOIN ASNBOLShipperMaster am ON ad.ASNBOLShipperRecID = am.RecID
-            LEFT JOIN LocationMaster lm ON ad.location = lm.Location            INNER JOIN crm_seorderentry se ON se.id = @EntryId
-                AND ad.ContainerItem = se.item_number            INNER JOIN crm_seorder so ON so.Id = se.seorder_id AND ad.OrdNbr = so.bill_no            WHERE am.shtype = 'SH'
-              AND am.typed <> 'S'
-              AND am.tenant_id = @TenantId
-            ORDER BY ad.Id
+                ROW_NUMBER() OVER (ORDER BY st.actual_ship_date DESC, st.source_row_id DESC) AS Sno,
+                COALESCE(st.shipper_no, CAST(st.source_row_id AS CHAR)) AS Id,
+                st.site AS Location,
+                st.actual_ship_date AS ShipDate,
+                CAST(st.qty_to_ship AS DECIMAL(18,2)) AS QtyToShip,
+                st.site AS LocationCode,
+                st.real_qty AS RealQty
+            FROM mdp_std_ship_trans st
+            INNER JOIN mdp_std_so so
+                    ON so.order_entry_id = @EntryId
+                   AND so.tenant_id = st.tenant_id
+                   AND so.order_no = st.order_no
+                   AND IFNULL(so.order_line, '') = IFNULL(st.order_line, '')
+                   AND IFNULL(so.item_code, '') = IFNULL(st.item_code, '')
+            WHERE st.trans_type = 'ASN_SHIPPER'
+              AND st.tenant_id = @TenantId
+            ORDER BY st.actual_ship_date DESC, st.source_row_id DESC
             """;
         return await _db.Ado.SqlQueryAsync<FlowShipmentRow>(sql, new { EntryId = entryId, TenantId = tenantId });
     }

+ 15 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ProductDesignService.cs

@@ -157,11 +157,11 @@ public class ProductDesignService : IDynamicApiController, ITransient
                 var billNo = $"PD{month}{(maxSeq + 1):D4}";
 
                 designId = YitIdHelper.NextId();
-                var entity = MapMaster(new ProductDesign { Id = designId, BillNo = billNo }, input, user, now, isNew: true);
+                var entity = MapMaster(new ProductDesign { Id = designId, BillNo = billNo, TenantId = tenantId }, input, user, now, isNew: true);
                 await _designRep.InsertAsync(entity);
 
-                await SaveBomsAsync(designId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: true);
-                await SaveRoutingsAsync(designId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: true);
+                await SaveBomsAsync(designId, tenantId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: true);
+                await SaveRoutingsAsync(designId, tenantId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: true);
 
                 await _db.Ado.CommitTranAsync();
                 return new { id = designId, billNo, message = "新增成功" };
@@ -174,8 +174,8 @@ public class ProductDesignService : IDynamicApiController, ITransient
             MapMaster(existing, input, user, now, isNew: false);
             await _designRep.UpdateAsync(existing);
 
-            await SaveBomsAsync(designId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: false);
-            await SaveRoutingsAsync(designId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: false);
+            await SaveBomsAsync(designId, tenantId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: false);
+            await SaveRoutingsAsync(designId, tenantId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: false);
 
             await _db.Ado.CommitTranAsync();
             return new { id = designId, billNo = existing.BillNo, message = "编辑成功" };
@@ -198,8 +198,8 @@ public class ProductDesignService : IDynamicApiController, ITransient
         await _db.Ado.BeginTranAsync();
         try
         {
-            await _bomRep.DeleteAsync(u => u.ProductDesignId == entity.Id);
-            await _routingRep.DeleteAsync(u => u.ProductDesignId == entity.Id);
+            await _bomRep.DeleteAsync(u => u.ProductDesignId == entity.Id && u.TenantId == tenantId);
+            await _routingRep.DeleteAsync(u => u.ProductDesignId == entity.Id && u.TenantId == tenantId);
             await _designRep.DeleteAsync(u => u.Id == entity.Id);
             await _db.Ado.CommitTranAsync();
             return new { message = "删除成功" };
@@ -301,10 +301,10 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         return DateTime.TryParse(s.Trim(), out var dt) ? dt : null;
     }
 
-    private async Task SaveBomsAsync(long designId, List<ProductDesignBomInput> items, bool isNew)
+    private async Task SaveBomsAsync(long designId, long tenantId, List<ProductDesignBomInput> items, bool isNew)
     {
         var dbRows = isNew ? new List<ProductDesignBom>()
-            : await _bomRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == _userManager.TenantId);
+            : await _bomRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == tenantId);
         var dbById = dbRows.ToDictionary(u => u.Id);
 
         if (items.Any(x => !x.Id.HasValue))
@@ -343,6 +343,7 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
                 {
                     Id = newId,
                     ProductDesignId = designId,
+                    TenantId = tenantId,
                     ParentBomId = parentResolved,
                     Seq = seq,
                     ItemNum = string.IsNullOrWhiteSpace(d.ItemNum) ? null : d.ItemNum.Trim(),
@@ -358,7 +359,7 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         }
 
         foreach (var row in dbRows.Where(u => !keptIds.Contains(u.Id)))
-            await _bomRep.DeleteAsync(u => u.Id == row.Id);
+            await _bomRep.DeleteAsync(u => u.Id == row.Id && u.TenantId == tenantId);
     }
 
     /// <summary>按父子顺序排列:父(含库中已有 Id)先于子;负数临时 Id 先于引用它的子行。</summary>
@@ -409,10 +410,10 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         return parentBomId;
     }
 
-    private async Task SaveRoutingsAsync(long designId, List<ProductDesignRoutingInput> items, bool isNew)
+    private async Task SaveRoutingsAsync(long designId, long tenantId, List<ProductDesignRoutingInput> items, bool isNew)
     {
         var dbRows = isNew ? new List<ProductDesignRouting>()
-            : await _routingRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == _userManager.TenantId);
+            : await _routingRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == tenantId);
         var dbById = dbRows.ToDictionary(u => u.Id);
         var inputIds = new HashSet<long>(items.Where(d => d.Id is > 0).Select(d => d.Id!.Value));
 
@@ -440,6 +441,7 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
                 {
                     Id = YitIdHelper.NextId(),
                     ProductDesignId = designId,
+                    TenantId = tenantId,
                     Seq = seq,
                     OpName = string.IsNullOrWhiteSpace(d.OpName) ? null : d.OpName.Trim(),
                     OpCode = string.IsNullOrWhiteSpace(d.OpCode) ? null : d.OpCode.Trim(),
@@ -453,7 +455,7 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         }
 
         foreach (var toDelete in dbRows.Where(u => !inputIds.Contains(u.Id)))
-            await _routingRep.DeleteAsync(u => u.Id == toDelete.Id);
+            await _routingRep.DeleteAsync(u => u.Id == toDelete.Id && u.TenantId == tenantId);
     }
 
     /// <summary>获取合同下拉选项</summary>

+ 45 - 47
server/Plugins/Admin.NET.Plugin.AiDOP/Order/RequirementExamineDetailService.cs

@@ -28,23 +28,36 @@ public class RequirementExamineDetailService : IDynamicApiController, ITransient
     {
         var tenantId = _userManager.TenantId;
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
-        var where = new List<string> { "bce.is_use = 1", "ber.tenant_id = @TenantId" };
+        var where = new List<string>
+        {
+            "d.tenant_id = @TenantId",
+            """
+            d.calc_batch_id = (
+                SELECT r.batch_id
+                FROM mdp_transform_run_log r
+                WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
+                  AND r.status = 'SUCCESS'
+                ORDER BY r.start_time DESC, r.id DESC
+                LIMIT 1
+            )
+            """
+        };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
         {
-            where.Add("sen.bill_no LIKE @BillNo");
+            where.Add("d.bill_no LIKE @BillNo");
             pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
         }
 
         if (!string.IsNullOrWhiteSpace(input.MorderNo))
         {
-            where.Add("ber.morder_no LIKE @MorderNo");
+            where.Add("d.morder_no LIKE @MorderNo");
             pars.Add(new SugarParameter("@MorderNo", $"%{input.MorderNo.Trim()}%"));
         }
 
         if (!string.IsNullOrWhiteSpace(input.ItemNumber))
         {
-            where.Add("bce.item_number = @ItemNumber");
+            where.Add("d.item_number = @ItemNumber");
             pars.Add(new SugarParameter("@ItemNumber", input.ItemNumber.Trim()));
         }
 
@@ -53,49 +66,34 @@ public class RequirementExamineDetailService : IDynamicApiController, ITransient
 
         var baseSql = $"""
             SELECT
-                bce.id AS RowId,
-                CASE
-                    WHEN bce.num = 1 THEN NULL
-                    ELSE (
-                        SELECT MIN(x.id)
-                        FROM b_bom_child_examine x
-                        WHERE x.examine_id = bce.examine_id AND x.num = 1 AND x.is_use = 1
-                    )
-                END AS ParentRowId,
-                CAST(bce.num AS CHAR) AS Num,
-                bce.item_number AS ItemNumber,
-                bce.item_name AS ItemName,
-                bce.bom_number AS BomNumber,
-                bce.model AS Model,
-                DATE_FORMAT(bce.kitting_time, '%Y-%m-%d') AS KittingTime,
-                CASE WHEN bce.type = 1 THEN '替代件' ELSE '标准件' END AS Type,
-                CASE bce.erp_cls
-                    WHEN 0 THEN '配置类'
-                    WHEN 1 THEN '自制'
-                    WHEN 2 THEN '委外加工'
-                    WHEN 3 THEN '外购'
-                    WHEN 4 THEN '虚拟件'
-                END AS ErpClsName,
-                CAST(bce.qty AS DECIMAL(18,4)) AS Qty,
-                CAST(IFNULL(bce.wastage, 0) AS DECIMAL(18,4)) AS Wastage,
-                CAST(bce.needCount AS DECIMAL(18,4)) AS NeedCount,
-                CAST(bce.sqty AS DECIMAL(18,4)) AS Sqty,
-                CAST(bce.use_qty AS DECIMAL(18,4)) AS UseQty,
-                CAST(bce.self_lack_qty AS DECIMAL(18,4)) AS SelfLackQty,
-                CAST(bce.lack_qty AS DECIMAL(18,4)) AS LackQty,
-                CAST(bce.mo_qty AS DECIMAL(18,4)) AS MoQty,
-                CAST(IF(bce.make_qty = 0 AND bce.erp_cls = 1, bce.lack_qty, bce.make_qty) AS DECIMAL(18,4)) AS MakeQty,
-                CAST(bce.purchase_qty AS DECIMAL(18,4)) AS PurchaseQty,
-                CAST(bce.purchase_occupy_qty AS DECIMAL(18,4)) AS PurchaseOccupyQty,
-                DATE_FORMAT(bce.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
-                CASE WHEN bce.haveicsubs = 1 THEN '是' ELSE '否' END AS HaveIcSubs,
-                bce.substitute_code AS SubstituteCode,
-                sen.bill_no AS BillNo,
-                ber.morder_no AS MorderNo,
-                ber.create_time AS CreateTime
-            FROM b_examine_result ber
-            INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id
-            LEFT JOIN crm_seorderentry sen ON ber.sentry_id = sen.id
+                d.row_id AS RowId,
+                d.parent_row_id AS ParentRowId,
+                d.num AS Num,
+                d.item_number AS ItemNumber,
+                d.item_name AS ItemName,
+                d.bom_number AS BomNumber,
+                d.model AS Model,
+                DATE_FORMAT(d.kitting_time, '%Y-%m-%d') AS KittingTime,
+                d.item_type AS Type,
+                d.erp_cls_name AS ErpClsName,
+                d.qty AS Qty,
+                IFNULL(d.wastage, 0) AS Wastage,
+                d.need_count AS NeedCount,
+                d.sqty AS Sqty,
+                d.use_qty AS UseQty,
+                d.self_lack_qty AS SelfLackQty,
+                d.lack_qty AS LackQty,
+                d.mo_qty AS MoQty,
+                d.make_qty AS MakeQty,
+                d.purchase_qty AS PurchaseQty,
+                d.purchase_occupy_qty AS PurchaseOccupyQty,
+                DATE_FORMAT(d.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
+                d.have_ic_subs AS HaveIcSubs,
+                d.substitute_code AS SubstituteCode,
+                d.bill_no AS BillNo,
+                d.morder_no AS MorderNo,
+                d.create_time AS CreateTime
+            FROM dwd_requirement_examine_detail d
             WHERE {whereSql}
             """;
 

+ 488 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -23,10 +23,11 @@ public class S1MdpSyncTransformService : ITransient
 
         try
         {
+            await EnsureS1RuntimeObjectsAsync();
             result.StageRows = await SyncStagingAsync(batchId, now, cancellationToken);
             result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
             result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
-            result.KpiRows = await BuildS1KpiValuesAsync(now, cancellationToken);
+            result.KpiRows = await BuildS1KpiValuesAsync(batchId, now, cancellationToken);
             await MarkTransformRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -37,6 +38,87 @@ public class S1MdpSyncTransformService : ITransient
         }
     }
 
+    private async Task EnsureS1RuntimeObjectsAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS dwd_requirement_examine_detail (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL,
+                factory_id BIGINT NULL,
+                stat_date DATE NOT NULL,
+                row_id BIGINT NOT NULL,
+                parent_row_id BIGINT NULL,
+                examine_id BIGINT NULL,
+                order_entry_id BIGINT NULL,
+                bill_no VARCHAR(100) NULL,
+                morder_no VARCHAR(100) NULL,
+                num VARCHAR(100) NULL,
+                item_number VARCHAR(100) NULL,
+                item_name VARCHAR(200) NULL,
+                bom_number VARCHAR(100) NULL,
+                model VARCHAR(200) NULL,
+                kitting_time DATE NULL,
+                item_type VARCHAR(50) NULL,
+                erp_cls_name VARCHAR(80) NULL,
+                qty DECIMAL(18,4) NULL,
+                wastage DECIMAL(18,4) NULL,
+                need_count DECIMAL(18,4) NULL,
+                sqty DECIMAL(18,4) NULL,
+                use_qty DECIMAL(18,4) NULL,
+                self_lack_qty DECIMAL(18,4) NULL,
+                lack_qty DECIMAL(18,4) NULL,
+                mo_qty DECIMAL(18,4) NULL,
+                make_qty DECIMAL(18,4) NULL,
+                purchase_qty DECIMAL(18,4) NULL,
+                purchase_occupy_qty DECIMAL(18,4) NULL,
+                satisfy_time DATE NULL,
+                have_ic_subs VARCHAR(10) NULL,
+                substitute_code VARCHAR(100) NULL,
+                create_time DATETIME NULL,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                sync_batch_id VARCHAR(100) NOT NULL,
+                calc_batch_id VARCHAR(100) NOT NULL,
+                calc_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_dwd_req_exam_detail (tenant_id, row_id, calc_batch_id),
+                KEY idx_req_exam_tenant_batch (tenant_id, calc_batch_id),
+                KEY idx_req_exam_bill (tenant_id, bill_no),
+                KEY idx_req_exam_item (tenant_id, item_number)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S1需求明细核验DWD';
+            """);
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_entity
+            (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, status, remark)
+            SELECT 0, s.id, 'S1_REQUIREMENT_EXAMINE_RESULT', 'S1需求核验结果主表', 'TABLE',
+                   'b_examine_result', 'mdp_stg_so', 'FULL', 5000, 1, '需求核验结果主表,进入 S1 贴源层'
+            FROM mdp_source s
+            WHERE s.tenant_id=0 AND s.source_code='AIDOPDEV_MYSQL'
+            LIMIT 1
+            ON DUPLICATE KEY UPDATE
+                source_id=VALUES(source_id), entity_name=VALUES(entity_name), source_table_name=VALUES(source_table_name),
+                target_table_name=VALUES(target_table_name), sync_mode=VALUES(sync_mode), status=VALUES(status),
+                remark=VALUES(remark), update_time=CURRENT_TIMESTAMP;
+            """);
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_entity
+            (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, status, remark)
+            SELECT 0, s.id, 'S1_REQUIREMENT_EXAMINE_DETAIL', 'S1需求核验BOM明细', 'TABLE',
+                   'b_bom_child_examine', 'mdp_stg_so', 'FULL', 5000, 1, '需求核验BOM明细,进入 S1 贴源层和 DWD'
+            FROM mdp_source s
+            WHERE s.tenant_id=0 AND s.source_code='AIDOPDEV_MYSQL'
+            LIMIT 1
+            ON DUPLICATE KEY UPDATE
+                source_id=VALUES(source_id), entity_name=VALUES(entity_name), source_table_name=VALUES(source_table_name),
+                target_table_name=VALUES(target_table_name), sync_mode=VALUES(sync_mode), status=VALUES(status),
+                remark=VALUES(remark), update_time=CURRENT_TIMESTAMP;
+            """);
+    }
+
     private async Task<int> SyncStagingAsync(string batchId, DateTime now, CancellationToken cancellationToken)
     {
         var total = 0;
@@ -144,10 +226,10 @@ public class S1MdpSyncTransformService : ITransient
         return total;
     }
 
-    private async Task<int> BuildS1KpiValuesAsync(DateTime now, CancellationToken cancellationToken)
+    private async Task<int> BuildS1KpiValuesAsync(string batchId, DateTime now, CancellationToken cancellationToken)
     {
         var statDate = now.Date;
-        var rows = await CalculateS1KpiValuesAsync(statDate);
+        var rows = await CalculateS1KpiValuesAsync(batchId, statDate);
         var affected = 0;
         foreach (var row in rows)
         {
@@ -158,7 +240,7 @@ public class S1MdpSyncTransformService : ITransient
         return affected;
     }
 
-    private async Task<List<S1KpiCalcRow>> CalculateS1KpiValuesAsync(DateTime statDate)
+    private async Task<List<S1KpiCalcRow>> CalculateS1KpiValuesAsync(string batchId, DateTime statDate)
     {
         return await _db.Ado.SqlQueryAsync<S1KpiCalcRow>(
             """
@@ -186,6 +268,14 @@ public class S1MdpSyncTransformService : ITransient
             WHERE order_date IS NOT NULL AND order_date <= @StatDate
             GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
             UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L1_004' AS MetricCode,
+                   ROUND(AVG(GREATEST(IFNULL(remaining_qty, 0), 0)) / GREATEST(SUM(IFNULL(shipped_qty, 0)), 1) * 30, 4) AS MetricValue
+            FROM dwd_ship_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id
+            HAVING SUM(IFNULL(shipped_qty, 0)) > 0
+            UNION ALL
             SELECT tenant_id AS TenantId, COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId,
                    'S1_L2_010' AS MetricCode,
                    ROUND(AVG(TIMESTAMPDIFF(HOUR, order_date, COALESCE(promised_delivery_date, capacity_date, material_ready_date, plan_delivery_date)) / 24), 4) AS MetricValue
@@ -209,7 +299,244 @@ public class S1MdpSyncTransformService : ITransient
             FROM mdp_std_so
             WHERE order_date IS NOT NULL AND order_date <= @StatDate
             GROUP BY tenant_id, COALESCE(NULLIF(factory_id, 0), 1)
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_013' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN planned_ship_qty > 0 THEN 1 ELSE 0 END) / COUNT(1), 4) AS MetricValue
+            FROM dwd_ship_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_014' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN shipped_qty >= planned_ship_qty AND planned_ship_qty > 0 THEN 1 ELSE 0 END)
+                         / GREATEST(SUM(CASE WHEN planned_ship_qty > 0 THEN 1 ELSE 0 END), 1), 4) AS MetricValue
+            FROM dwd_ship_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id
+            HAVING SUM(CASE WHEN planned_ship_qty > 0 THEN 1 ELSE 0 END) > 0
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_015' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN linkage_status = 'LINKED' THEN 1 ELSE 0 END)
+                         / GREATEST(SUM(CASE WHEN planned_ship_qty > 0 THEN 1 ELSE 0 END), 1), 4) AS MetricValue
+            FROM dwd_ship_trans
+            WHERE calc_batch_id=@BatchId
+            GROUP BY tenant_id
+            HAVING SUM(CASE WHEN planned_ship_qty > 0 THEN 1 ELSE 0 END) > 0
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_001' AS MetricCode,
+                   ROUND(AVG(TIMESTAMPDIFF(HOUR, create_time, update_time) / 24), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f') AS create_time,
+                       COALESCE(
+                           STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.UpdateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                           STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                       ) AS update_time
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review'
+            ) t
+            WHERE create_time IS NOT NULL AND update_time IS NOT NULL AND update_time >= create_time
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_002' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN TIMESTAMPDIFF(HOUR, create_time, update_time) <= 72 THEN 1 ELSE 0 END) / COUNT(1), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f') AS create_time,
+                       COALESCE(
+                           STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.UpdateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                           STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                       ) AS update_time
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review'
+            ) t
+            WHERE create_time IS NOT NULL AND update_time IS NOT NULL AND update_time >= create_time
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_003' AS MetricCode,
+                   ROUND(COUNT(1) / GREATEST(COUNT(DISTINCT NULLIF(owner_account, '')), 1), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       COALESCE(
+                           JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ResponsibleAccount')),
+                           JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateUser'))
+                       ) AS owner_account
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review'
+            ) t
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_004' AS MetricCode,
+                   ROUND(AVG(cycle_hours / 24), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       COALESCE(
+                           CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingDesignCycle')) REGEXP '^-?[0-9]+$'
+                                THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingDesignCycle')) AS DECIMAL(18,6)) END,
+                           TIMESTAMPDIFF(HOUR,
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingActualStart')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingActualEnd')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                           )
+                       ) AS cycle_hours
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_product_design'
+            ) t
+            WHERE cycle_hours IS NOT NULL AND cycle_hours >= 0
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_005' AS MetricCode,
+                   ROUND(100 * SUM(CASE WHEN actual_end <= plan_end THEN 1 ELSE 0 END) / COUNT(1), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingPlanEnd')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f') AS plan_end,
+                       STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DrawingActualEnd')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f') AS actual_end
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_product_design'
+            ) t
+            WHERE plan_end IS NOT NULL AND actual_end IS NOT NULL
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   'S1_L2_006' AS MetricCode,
+                   ROUND(COUNT(1) / GREATEST(COUNT(DISTINCT NULLIF(design_lead, '')), 1), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       COALESCE(
+                           JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DesignLeadAccount')),
+                           JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DesignLeadName')),
+                           JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CreateUser'))
+                       ) AS design_lead
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_product_design'
+            ) t
+            GROUP BY tenant_id
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   CASE stage_no
+                       WHEN 1 THEN 'S1_L3_001'
+                       WHEN 2 THEN 'S1_L3_002'
+                       WHEN 3 THEN 'S1_L3_003'
+                       WHEN 4 THEN 'S1_L3_004'
+                       WHEN 5 THEN 'S1_L3_005'
+                   END AS MetricCode,
+                   ROUND(AVG(cycle_hours), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) REGEXP '^-?[0-9]+$'
+                            THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) AS SIGNED) END AS stage_no,
+                       COALESCE(
+                           TIMESTAMPDIFF(HOUR,
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompleteTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                           ),
+                           CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                                THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) AS DECIMAL(18,6)) * 24 END
+                       ) AS cycle_hours
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review_flow'
+            ) t
+            WHERE stage_no BETWEEN 1 AND 5
+              AND cycle_hours IS NOT NULL AND cycle_hours >= 0
+            GROUP BY tenant_id, stage_no
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   CASE stage_no
+                       WHEN 1 THEN 'S1_L3_101'
+                       WHEN 2 THEN 'S1_L3_102'
+                       WHEN 3 THEN 'S1_L3_103'
+                       WHEN 4 THEN 'S1_L3_104'
+                       WHEN 5 THEN 'S1_L3_105'
+                   END AS MetricCode,
+                   ROUND(AVG(cycle_hours), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) REGEXP '^-?[0-9]+$'
+                            THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) AS SIGNED) END AS stage_no,
+                       COALESCE(
+                           TIMESTAMPDIFF(HOUR,
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompleteTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                           ),
+                           CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                                THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) AS DECIMAL(18,6)) * 24 END
+                       ) AS cycle_hours
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review_flow'
+            ) t
+            WHERE stage_no BETWEEN 1 AND 5
+              AND cycle_hours IS NOT NULL AND cycle_hours >= 0
+            GROUP BY tenant_id, stage_no
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   metric_code AS MetricCode,
+                   ROUND(AVG(cycle_hours), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       CASE COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DeptNo')), ''), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Department')))
+                           WHEN 'LAW' THEN 'S1_L4_001'
+                           WHEN 'PRE_SALES' THEN 'S1_L4_002'
+                           WHEN 'MPS' THEN 'S1_L4_003'
+                           WHEN 'TEST' THEN 'S1_L4_004'
+                           WHEN '法律事务部' THEN 'S1_L4_001'
+                           WHEN '技术售前组' THEN 'S1_L4_002'
+                           WHEN '综合主计划' THEN 'S1_L4_003'
+                           WHEN '试验站' THEN 'S1_L4_004'
+                       END AS metric_code,
+                       COALESCE(
+                           TIMESTAMPDIFF(HOUR,
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompleteTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                           ),
+                           CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                                THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) AS DECIMAL(18,6)) * 24 END
+                       ) AS cycle_hours
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review_flow'
+                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) = '1'
+            ) t
+            WHERE metric_code IS NOT NULL
+              AND cycle_hours IS NOT NULL AND cycle_hours >= 0
+            GROUP BY tenant_id, metric_code
+            UNION ALL
+            SELECT tenant_id AS TenantId, 1 AS FactoryId,
+                   metric_code AS MetricCode,
+                   ROUND(AVG(cycle_hours), 4) AS MetricValue
+            FROM (
+                SELECT tenant_id,
+                       CASE COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DeptNo')), ''), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Department')))
+                           WHEN 'LAW' THEN 'S1_L4_101'
+                           WHEN 'PRE_SALES' THEN 'S1_L4_102'
+                           WHEN 'MPS' THEN 'S1_L4_103'
+                           WHEN 'TEST' THEN 'S1_L4_104'
+                           WHEN '法律事务部' THEN 'S1_L4_101'
+                           WHEN '技术售前组' THEN 'S1_L4_102'
+                           WHEN '综合主计划' THEN 'S1_L4_103'
+                           WHEN '试验站' THEN 'S1_L4_104'
+                       END AS metric_code,
+                       COALESCE(
+                           TIMESTAMPDIFF(HOUR,
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                               STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompleteTime')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f')
+                           ),
+                           CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                                THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ActualDays')) AS DECIMAL(18,6)) * 24 END
+                       ) AS cycle_hours
+                FROM mdp_stg_so
+                WHERE source_table = 'ado_contract_review_flow'
+                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StageNo')) = '1'
+            ) t
+            WHERE metric_code IS NOT NULL
+              AND cycle_hours IS NOT NULL AND cycle_hours >= 0
+            GROUP BY tenant_id, metric_code
             """,
+            new SugarParameter("@BatchId", batchId),
             new SugarParameter("@StatDate", statDate));
     }
 
@@ -486,10 +813,140 @@ public class S1MdpSyncTransformService : ITransient
                 confirm_status=VALUES(confirm_status), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
                 update_time=CURRENT_TIMESTAMP
             """, batchId, now);
+
+        yield return Cmd(
+            """
+            INSERT INTO mdp_std_ship_trans
+            (tenant_id, factory_id, source_system, trans_type, order_no, customer_no, item_code, item_name, qty,
+             plan_ship_date, status, linkage_status, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(d.tenant_id, 0),
+                d.factory_id,
+                'AIDOP',
+                'LINKAGE_PLAN',
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bill_no')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.custom_no')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.item_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Descr')),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.qty')) AS DECIMAL(18,6)) END,
+                NULLIF(NULLIF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sys_capacity_date')), JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.fystarttime'))), 'null'), ''),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.type')),
+                CASE
+                    WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.isuse')) = '1' THEN 'LINKED'
+                    ELSE 'INACTIVE'
+                END,
+                d.source_table,
+                d.source_row_id,
+                d.source_biz_key,
+                @BatchId,
+                @Now
+            FROM mdp_stg_ship_trans d
+            WHERE d.source_table='LinkagePlan'
+              AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bill_no')), '') <> ''
+            ON DUPLICATE KEY UPDATE
+                order_no=VALUES(order_no), customer_no=VALUES(customer_no), item_code=VALUES(item_code),
+                item_name=VALUES(item_name), qty=VALUES(qty), plan_ship_date=VALUES(plan_ship_date),
+                status=VALUES(status), linkage_status=VALUES(linkage_status), sync_batch_id=VALUES(sync_batch_id),
+                sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
     }
 
     private IEnumerable<S1MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now)
     {
+        yield return Cmd(
+            """
+            INSERT INTO dwd_requirement_examine_detail
+            (tenant_id, factory_id, stat_date, row_id, parent_row_id, examine_id, order_entry_id, bill_no, morder_no,
+             num, item_number, item_name, bom_number, model, kitting_time, item_type, erp_cls_name, qty, wastage,
+             need_count, sqty, use_qty, self_lack_qty, lack_qty, mo_qty, make_qty, purchase_qty, purchase_occupy_qty,
+             satisfy_time, have_ic_subs, substitute_code, create_time, source_system, sync_batch_id, calc_batch_id, calc_time)
+            SELECT
+                COALESCE(d.tenant_id, r.tenant_id, so.tenant_id, 0),
+                COALESCE(d.factory_id, r.factory_id, so.factory_id),
+                @StatDate,
+                CAST(d.source_row_id AS SIGNED),
+                CASE
+                    WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.num')) = '1' THEN NULL
+                    ELSE p.parent_row_id
+                END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.examine_id')) REGEXP '^-?[0-9]+$'
+                     THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.examine_id')) AS SIGNED) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.sentry_id')) REGEXP '^-?[0-9]+$'
+                     THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.sentry_id')) AS SIGNED) END,
+                COALESCE(so.order_no, JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.bill_no'))),
+                JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.morder_no')),
+                CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.num')) AS CHAR),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.item_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.item_name')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.bom_number')),
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.model')),
+                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.kitting_time')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.type')) = '1' THEN '替代件' ELSE '标准件' END,
+                COALESCE(
+                    JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.erp_cls_name')),
+                    CASE JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.erp_cls'))
+                        WHEN '0' THEN '配置类'
+                        WHEN '1' THEN '自制'
+                        WHEN '2' THEN '委外加工'
+                        WHEN '3' THEN '外购'
+                        WHEN '4' THEN '虚拟件'
+                    END
+                ),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.qty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.wastage')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.wastage')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.needCount')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.needCount')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sqty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.sqty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.use_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.use_qty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.self_lack_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.self_lack_qty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.lack_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.lack_qty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.mo_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.mo_qty')) AS DECIMAL(18,4)) END,
+                CASE
+                    WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.make_qty')) = '0' AND JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.erp_cls')) = '1'
+                        THEN CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.lack_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.lack_qty')) AS DECIMAL(18,4)) END
+                    WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.make_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                        THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.make_qty')) AS DECIMAL(18,4))
+                END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.purchase_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.purchase_qty')) AS DECIMAL(18,4)) END,
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.purchase_occupy_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.purchase_occupy_qty')) AS DECIMAL(18,4)) END,
+                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.satisfy_time')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.haveicsubs')) = '1' THEN '是' ELSE '否' END,
+                JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.substitute_code')),
+                STR_TO_DATE(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.create_time')), 'null'), ''), '%Y-%m-%d %H:%i:%s.%f'),
+                'AIDOP',
+                d.sync_batch_id,
+                @BatchId,
+                @Now
+            FROM mdp_stg_so d
+            INNER JOIN mdp_stg_so r ON r.source_table='b_examine_result'
+                AND r.source_row_id = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.examine_id'))
+            LEFT JOIN mdp_std_so so ON so.tenant_id = COALESCE(r.tenant_id, d.tenant_id)
+                AND so.order_entry_id = CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.sentry_id')) REGEXP '^-?[0-9]+$'
+                                             THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.sentry_id')) AS SIGNED) END
+            LEFT JOIN (
+                SELECT examine_id, MIN(source_row_id) AS parent_row_id
+                FROM (
+                    SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.examine_id')) AS examine_id, CAST(source_row_id AS SIGNED) AS source_row_id
+                    FROM mdp_stg_so
+                    WHERE source_table='b_bom_child_examine'
+                      AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.num')) = '1'
+                      AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.is_use')) IN ('1', 'true', 'True', 'base64:type16:AQ==')
+                ) x
+                GROUP BY examine_id
+            ) p ON p.examine_id = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.examine_id'))
+            WHERE d.source_table='b_bom_child_examine'
+              AND JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.is_use')) IN ('1', 'true', 'True', 'base64:type16:AQ==')
+            ON DUPLICATE KEY UPDATE
+                parent_row_id=VALUES(parent_row_id), order_entry_id=VALUES(order_entry_id), bill_no=VALUES(bill_no),
+                morder_no=VALUES(morder_no), num=VALUES(num), item_number=VALUES(item_number), item_name=VALUES(item_name),
+                bom_number=VALUES(bom_number), model=VALUES(model), kitting_time=VALUES(kitting_time),
+                item_type=VALUES(item_type), erp_cls_name=VALUES(erp_cls_name), qty=VALUES(qty), wastage=VALUES(wastage),
+                need_count=VALUES(need_count), sqty=VALUES(sqty), use_qty=VALUES(use_qty), self_lack_qty=VALUES(self_lack_qty),
+                lack_qty=VALUES(lack_qty), mo_qty=VALUES(mo_qty), make_qty=VALUES(make_qty), purchase_qty=VALUES(purchase_qty),
+                purchase_occupy_qty=VALUES(purchase_occupy_qty), satisfy_time=VALUES(satisfy_time), have_ic_subs=VALUES(have_ic_subs),
+                substitute_code=VALUES(substitute_code), create_time=VALUES(create_time), sync_batch_id=VALUES(sync_batch_id),
+                calc_time=VALUES(calc_time), update_time=CURRENT_TIMESTAMP
+            """, batchId, now);
+
         yield return Cmd(
             """
             INSERT INTO dwd_ship_trans
@@ -547,13 +1004,19 @@ public class S1MdpSyncTransformService : ITransient
                 @Now
             FROM mdp_std_so so
             LEFT JOIN (
-                SELECT tenant_id, order_no, IFNULL(order_line, '') AS order_line, IFNULL(item_code, '') AS item_code,
+                SELECT tenant_id, order_no, order_entry_id, IFNULL(order_line, '') AS order_line, IFNULL(item_code, '') AS item_code,
                        SUM(IFNULL(plan_qty, IFNULL(qty, 0))) AS plan_qty,
                        MIN(plan_ship_date) AS plan_ship_date
                 FROM mdp_std_ship_trans
                 WHERE trans_type='SHIP_PLAN'
-                GROUP BY tenant_id, order_no, IFNULL(order_line, ''), IFNULL(item_code, '')
-            ) p ON so.tenant_id=p.tenant_id AND so.order_no=p.order_no AND IFNULL(so.order_line, '')=p.order_line AND IFNULL(so.item_code, '')=p.item_code
+                GROUP BY tenant_id, order_no, order_entry_id, IFNULL(order_line, ''), IFNULL(item_code, '')
+            ) p ON so.tenant_id=p.tenant_id
+                AND so.order_no=p.order_no
+                AND IFNULL(so.item_code, '')=p.item_code
+                AND (
+                    (p.order_entry_id IS NOT NULL AND so.order_entry_id=p.order_entry_id)
+                    OR (p.order_entry_id IS NULL AND IFNULL(so.order_line, '')=p.order_line)
+                )
             LEFT JOIN (
                 SELECT tenant_id, order_no, IFNULL(order_line, '') AS order_line, IFNULL(item_code, '') AS item_code,
                        SUM(IFNULL(real_qty, IFNULL(qty_to_ship, 0))) AS real_qty,
@@ -750,9 +1213,19 @@ public class S1MdpSyncTransformService : ITransient
     {
         return metricCode switch
         {
-            "S1_L1_001" or "S1_L2_010" => 3m,
-            "S1_L1_002" or "S1_L2_011" => 95m,
-            "S1_L1_003" or "S1_L2_012" => 100m,
+            "S1_L1_001" or "S1_L2_001" or "S1_L2_004" or "S1_L2_010" => 3m,
+            "S1_L1_004" => 60m,
+            "S1_L1_002" or "S1_L2_002" or "S1_L2_005" or "S1_L2_011" or "S1_L2_013" or "S1_L2_014" or "S1_L2_015" => 95m,
+            "S1_L1_003" or "S1_L2_003" or "S1_L2_006" or "S1_L2_012" => 100m,
+            "S1_L3_001" or "S1_L3_101" => 8m,
+            "S1_L3_002" or "S1_L3_102" => 12m,
+            "S1_L3_003" or "S1_L3_103" => 8m,
+            "S1_L3_004" or "S1_L3_104" => 10m,
+            "S1_L3_005" or "S1_L3_105" => 2m,
+            "S1_L4_001" or "S1_L4_101" => 2m,
+            "S1_L4_002" or "S1_L4_102" => 3m,
+            "S1_L4_003" or "S1_L4_103" => 1m,
+            "S1_L4_004" or "S1_L4_104" => 2m,
             _ => 0m
         };
     }
@@ -849,6 +1322,11 @@ internal sealed record S1MdpEntityConfig(
         new("S1_SEORDER_CHANGE", "crm_seorder_change", "mdp_stg_so", "Id", "CONCAT(IFNULL(s.`bill_no`,''), ':', CAST(s.`Id` AS CHAR))"),
         new("S1_CONTRACT_REVIEW", "ado_contract_review", "mdp_stg_so", "RecID", "COALESCE(s.`BillNo`, CAST(s.`RecID` AS CHAR))"),
         new("S1_CONTRACT_REVIEW_FLOW", "ado_contract_review_flow", "mdp_stg_so", "RecID", "CONCAT(IFNULL(s.`ReviewBillNo`,''), ':', IFNULL(s.`StageNo`,''), ':', CAST(s.`RecID` AS CHAR))"),
+        new("S1_PRODUCT_DESIGN", "ado_product_design", "mdp_stg_so", "Id", "COALESCE(s.`BillNo`, CAST(s.`Id` AS CHAR))"),
+        new("S1_PRODUCT_DESIGN_BOM", "ado_product_design_bom", "mdp_stg_so", "Id", "CONCAT(CAST(s.`ProductDesignId` AS CHAR), ':', CAST(s.`Id` AS CHAR))"),
+        new("S1_PRODUCT_DESIGN_ROUTING", "ado_product_design_routing", "mdp_stg_so", "Id", "CONCAT(CAST(s.`ProductDesignId` AS CHAR), ':', CAST(s.`Id` AS CHAR))"),
+        new("S1_REQUIREMENT_EXAMINE_RESULT", "b_examine_result", "mdp_stg_so", "Id", "CONCAT(IFNULL(s.`bill_no`,''), ':', IFNULL(s.`morder_no`,''), ':', CAST(s.`Id` AS CHAR))"),
+        new("S1_REQUIREMENT_EXAMINE_DETAIL", "b_bom_child_examine", "mdp_stg_so", "Id", "CONCAT(IFNULL(s.`examine_id`,''), ':', IFNULL(s.`item_number`,''), ':', CAST(s.`Id` AS CHAR))"),
         new("S1_SHIPPING_PLAN", "ShippingPlan", "mdp_stg_ship_trans", "RecID", "COALESCE(s.`LotSerial`, CAST(s.`RecID` AS CHAR))"),
         new("S1_SHIPPING_PLAN_DETAIL", "ShippingPlanDetail", "mdp_stg_ship_trans", "RecID", "CONCAT(IFNULL(s.`plan_id`,''), ':', IFNULL(s.`OrdNbr`,''), ':', CAST(s.`RecID` AS CHAR))"),
         new("S1_ASN_SHIPPER_MASTER", "ASNBOLShipperMaster", "mdp_stg_ship_trans", "RecID", "COALESCE(s.`Id`, CONCAT(IFNULL(s.`OrdNbr`,''), ':', CAST(s.`RecID` AS CHAR)))"),

+ 9 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ShippingPlanService.cs

@@ -43,7 +43,7 @@ public class ShippingPlanService : IDynamicApiController, ITransient
     {
         var tenantId = _userManager.TenantId;
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
-        var conditions = new List<string> { "a.tenant_id = @TenantId" };
+        var conditions = new List<string> { "a.tenant_id = @TenantId", "b.tenant_id = @TenantId" };
 
         if (!string.IsNullOrWhiteSpace(input.OrdNbr))
         {
@@ -177,6 +177,7 @@ public class ShippingPlanService : IDynamicApiController, ITransient
     {
         var now = DateTime.Now;
         var user = _userManager.Account ?? "system";
+        var tenantId = _userManager.TenantId;
 
         if (input.RecID is null or 0)
         {
@@ -198,15 +199,15 @@ public class ShippingPlanService : IDynamicApiController, ITransient
                 IsConfirm = 1,
                 CreateUser = user,
                 CreateTime = now,
+                TenantId = tenantId,
             };
             await _planRep.InsertAsync(entity);
-            await SaveDetailsAsync(nextPlanId, input.Details, user, now, isNew: true);
+            await SaveDetailsAsync(nextPlanId, tenantId, input.Details, user, now, isNew: true);
             return new { id = nextPlanId, message = "新增成功" };
         }
         else
         {
             // ── 编辑:参照 SysJobService.UpdateJobDetail ──
-            var tenantId = _userManager.TenantId;
             var entity = await _planRep.GetFirstAsync(u => u.RecID == input.RecID.Value && u.TenantId == tenantId)
                          ?? throw Oops.Oh("出货计划不存在");
 
@@ -221,7 +222,7 @@ public class ShippingPlanService : IDynamicApiController, ITransient
             entity.UpdateTime = now;
             await _planRep.UpdateAsync(entity);
 
-            await SaveDetailsAsync(input.RecID.Value, input.Details, user, now, isNew: false);
+            await SaveDetailsAsync(input.RecID.Value, tenantId, input.Details, user, now, isNew: false);
             return new { id = input.RecID, message = "编辑成功" };
         }
     }
@@ -232,11 +233,11 @@ public class ShippingPlanService : IDynamicApiController, ITransient
     /// ② DB无但入参有                  → 新增
     /// ③ DB有但入参无                  → 删除
     /// </summary>
-    private async Task SaveDetailsAsync(int planId, List<ShippingPlanDetailInput> details,
+    private async Task SaveDetailsAsync(int planId, long tenantId, List<ShippingPlanDetailInput> details,
         string user, DateTime now, bool isNew)
     {
         var dbDetails = isNew ? new List<ShippingPlanDetail>()
-            : await _detailRep.GetListAsync(u => u.PlanId == planId && u.TenantId == _userManager.TenantId);
+            : await _detailRep.GetListAsync(u => u.PlanId == planId && u.TenantId == tenantId);
         var dbById = dbDetails.ToDictionary(u => u.RecID);
         var inputIds = new HashSet<int>(details.Where(d => d.RecID is > 0).Select(d => d.RecID!.Value));
 
@@ -293,6 +294,7 @@ public class ShippingPlanService : IDynamicApiController, ITransient
                     IsConfirm = 1,
                     CreateUser = user,
                     CreateTime = now,
+                    TenantId = tenantId,
                 };
                 await _detailRep.InsertAsync(detail);
             }
@@ -301,7 +303,7 @@ public class ShippingPlanService : IDynamicApiController, ITransient
         // ③ 删除:DB有、入参无
         foreach (var toDelete in dbDetails.Where(u => !inputIds.Contains(u.RecID)))
         {
-            await _detailRep.DeleteAsync(u => u.RecID == toDelete.RecID);
+            await _detailRep.DeleteAsync(u => u.RecID == toDelete.RecID && u.TenantId == tenantId);
         }
     }