Selaa lähdekoodia

feat(s0): 补齐物料关系的六张桥并统一挂接物料维度

在物料维度与同库集合式物化就位后,一次性补齐 S0 的全部六张物料关系桥,
合计 111510 行:

  ProdLineDetail         → bridge_item_op_line        10646
  RoutingOpDetail        → bridge_item_routing_op     38806
  ItemOpCondition        → bridge_item_op_condition      10
  QualitySegmentImage    → bridge_item_op_image           6
  ProductStructureMaster → bridge_item_bom            62036
  ItemSubstituteDetail   → bridge_item_substitute        16

六张的父维度统一是物料维度,一律走自然键 item_code。父键列名必须逐字叫
item_code——孤儿检查生成的是 p.<列> <=> d.<列> 的同名列 join,命名成
item_num 会通过 Validate 而在运行时报未知列。绝不用维度代理 id 建关系:
每轮 FULL REPLACE 后代理 id 都会变。

本批最重要的发现是 BOM 的 legacy 代理键会静默串租户:
ComponentMaterialId 关联 ItemMaster.RecID 时,带租户有 170 行解析失败,
不带租户则 0 行失败——看起来完美,而那 170 行恰恰全部挂到了别的租户的
物料上;ParentMaterialId 同款,量更大(带租户失败 2060 行)。也就是说
去掉租户条件不报错、不为 NULL,只会悄悄跨租户。而同租户可解析的 61866
行里,代理键解出的编码与 ComponentItem 完全一致(61866/61866),说明
自然键完全可靠。故 BOM 桥走自然键,两个 id 仅作技术属性留痕,唯一键里
没有它们,索引一律以 tenant_id 打头。

身份判定按当前业务证据而非旧唯一键:ProdLineDetail 的 Line 不可省
(去掉后 tenant+Part+Op 有 197 组重复);RoutingOpDetail 的 RouteCode 与
SortNo 可省(前者与 MaterialCode 逐值相同、后者近乎常量);BOM 加
LineOp/Op/SequenceNum 零增益;替代料的 ParentItem 与 ItemNum 十六行无一
相等,是真实不同信息故保留。Domain/Company/Factory/Site 一律不入身份。

四类列不予物化,绕过的结果是静默失真或每轮对账被打回:bit(1) 用整数会
让校验和必挂(ProductStructureMaster.IsActive);可空 tinyint(1) 用布尔
会把 NULL 行整行滤掉导致行数不等(RoutingOpDetail 的 IsActive 与
MilestoneOp);tinyint(1) 用整数会把 true/false 双双静默压成 0
(ItemOpCondition.IsMain,正解是布尔属性);distinct 为一的列零信息。

两处命名陷阱已钉死:RoutingOpDetail 的时间列叫 UpdatedAt 而非全仓通用的
UpdateTime,写错会得到恒 NULL 且不报错;QualitySegmentImage 没有任何
时间列,不得为字段齐整回填。RoutingOpDetail 另有六组新旧列近重复,
四列逐值相同,取错得到的不是 NULL 而是看起来合法的错值。

RoutingOpDetail 的 StdOp 不设父:对 StdOpMaster 孤儿 37112/38746,CRUD
从不写该列、界面无下拉、码空间也不同构。它与物料孤儿是两回事——物料孤儿
只有 44 行,全在一个租户、仅两个物料码,按保留并外化处理。

六张全部满足同库集合式物化条件,BOM 的 57199 行 27.1 秒、路线工序的
32309 行 14.9 秒,按旧逐行路径分别约需 63 与 36 分钟。batch_size 逐 entity
按各自最大租户行数留三倍头寸,未改全局默认。

验收:逐租户 source 与 target 行数全等、校验和全等、重复与批次杂质与
tenant<=0 全为零、六张源表行数刷新前后不变、BOM 连刷三次校验和逐位稳定。
共享入站核心四个文件仍为零改动。测试 2109 通过、零失败。
YY968XX 2 päivää sitten
vanhempi
commit
88f74eefb4

+ 21 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.511</AssemblyVersion>
-    <FileVersion>1.0.511</FileVersion>
-    <Version>1.0.511</Version>
+    <AssemblyVersion>1.0.514</AssemblyVersion>
+    <FileVersion>1.0.514</FileVersion>
+    <Version>1.0.514</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -745,6 +745,24 @@
     <None Update="UpdateScripts\1.0.510.verify.sql">
     <None Update="UpdateScripts\1.0.510.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.512.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.512.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.513.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.513.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.514.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.514.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 170 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.512.sql

@@ -0,0 +1,170 @@
+-- =====================================================================================
+-- WIP-C3A  ·  S0 · C3-A 物料桥:ProdLineDetail / RoutingOpDetail
+--   (占位名,提交那刻 rename 为正式 1.0.<next>.sql)
+--
+--   ProdLineDetail  → mdp_stg_s0_item_op_line    → bridge_item_op_line
+--   RoutingOpDetail → mdp_stg_s0_item_routing_op → bridge_item_routing_op
+--
+-- 两桥的父都是 dim_item,走**自然键 item_code**。
+-- 🔴 父连接是 `p.<col> <=> d.<col>` 同名列 join(S0DimSqlBuilder.OrphanFromWhere),
+--    故子表该列必须逐字命名 item_code;命名成 item_num 会 Validate() 通过而运行时报未知列。
+-- 🔴 绝不用 dim 代理 id 建关系:每轮 FULL REPLACE 后 dim_item.id 都会变。
+-- =====================================================================================
+
+-- ── 1. ProdLineDetail → 物料-工序-产线产能 ────────────────────────────────────────────
+-- 身份 tenant+Part+Op+Line(重复 0 组);去掉 Line 会塌陷成 197 组/394 行重复。
+-- Domain 每租户单值,零判别力 → LEGACY_SCOPE 不入身份。
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_op_line` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_op_line` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_op_line_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_op_line_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:物料工序产线产能(源 ProdLineDetail)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_op_line` (
+  `id`                      bigint        NOT NULL AUTO_INCREMENT,
+  `tenant_id`               bigint        NOT NULL,
+  `item_code`               varchar(100)  NOT NULL COMMENT '业务键 + 父键 ← $.Part;父 = dim_item.item_code(实测孤儿 0/10646)',
+  `operation_no`            int           NOT NULL COMMENT '业务键 ← $.Op(本仓首个 Int 业务键,三侧渲染已验证一致)',
+  `line_code`               varchar(100)  NOT NULL COMMENT '业务键 ← $.Line;11 行带尾随空格,NO PAD 下是真实差异,禁止 TRIM;软引用 dim_line(8848/10646 对不上)故不设父',
+  `domain_code`             varchar(50)   NOT NULL COMMENT 'LEGACY_SCOPE ← $.Domain(每租户单值,零判别力)',
+  `work_center_code`        varchar(100)  DEFAULT NULL COMMENT '← $.Site(5823/10646 为 NULL);软引用 dim_work_center,不设父',
+  `rate`                    decimal(18,6) DEFAULT NULL COMMENT '← $.Rate;scale 必须与源逐位一致',
+  `setup_time`              decimal(18,6) DEFAULT NULL COMMENT '← $.SetupTime',
+  `overlap_time`            decimal(18,6) DEFAULT NULL COMMENT '← $.OverlapTime',
+  `sequence_no`             decimal(18,6) DEFAULT NULL COMMENT '← $.Sequence',
+  `standard_staff_count`    decimal(18,6) DEFAULT NULL COMMENT '← $.StandardStaffCount',
+  `skill_code`              varchar(100)  DEFAULT NULL COMMENT '← $.SkillNo',
+  `internal_equipment_code` varchar(100)  DEFAULT NULL COMMENT '🔴 ← $.InternalEquipmentCode(2430/10646 有值)。**不是**同表 0/10646 的死影子列 internal_equipment_type_code',
+  `mold_type_code`          varchar(100)  DEFAULT NULL COMMENT '← $.MoldTypeCode',
+  `start_marker`            bigint        DEFAULT NULL COMMENT '← $.Start(列名避开 MySQL 保留字 START)',
+  `is_active`               tinyint(1)    NOT NULL COMMENT '← $.IsActive(源 NOT NULL)',
+  `is_confirmed`            tinyint(1)    NOT NULL COMMENT '← $.IsConfirm(源 NOT NULL)',
+  `run_crew`                varchar(8)    DEFAULT NULL COMMENT '🔴 ← $.RunCrew,源 varchar(8)。dim_line_post 的同名列源自 LineSkillMaster 是 decimal(10,3),同名不同型',
+  `op_type`                 varchar(50)   DEFAULT NULL COMMENT '← $.OpType',
+  `bom_code`                varchar(24)   DEFAULT NULL COMMENT '← $.Bom',
+  `source_system`           varchar(50)   NOT NULL,
+  `source_biz_key`          varchar(300)  NOT NULL,
+  `source_updated_at`       datetime      DEFAULT NULL COMMENT '← $.UpdateTime(5283/10646)',
+  `sync_batch_id`           varchar(64)   NOT NULL,
+  `sync_time`               datetime      NOT NULL,
+  `create_time`             datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`             datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 + 4 + 100*4 = 812 < 3072(DYNAMIC/utf8mb4)。加宽任一 varchar 前须重算。
+  UNIQUE KEY `uk_bridge_item_op_line` (`tenant_id`,`item_code`,`operation_no`,`line_code`),
+  KEY `idx_biol_parent` (`tenant_id`,`item_code`),
+  KEY `idx_biol_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:物料-工序-产线产能。源 ProdLineDetail,FULL REPLACE。父 = dim_item(自然键 item_code)。';
+
+-- ── 2. RoutingOpDetail → 物料工艺路线工序(S0 最大的桥,38806 行)────────────────────
+-- 身份 tenant+MaterialCode+OperationCode(重复 0 组);去掉 OperationCode 塌陷成 2879 组。
+-- 🔴 时间列是 UpdatedAt/CreatedAt,不是全仓通用的 UpdateTime。
+-- 🔴 六组新旧列近重复:真列 RouteCode/OperationCode/OperationDescription,
+--    旧列 RoutingCode/Op/Descr 不得取(38805/38806 同值,取错得到「看起来合法的错值」)。
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_routing_op` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_routing_op` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_routing_op_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_routing_op_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:物料工艺路线工序(源 RoutingOpDetail)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_routing_op` (
+  `id`                    bigint        NOT NULL AUTO_INCREMENT,
+  `tenant_id`             bigint        NOT NULL,
+  `item_code`             varchar(100)  NOT NULL COMMENT '业务键 + 父键 ← $.MaterialCode;父 = dim_item(实测孤儿 44/38806,ALLOW+WARNING 不阻断)',
+  `operation_code`        varchar(100)  NOT NULL COMMENT '业务键 ← $.OperationCode;🔴 不是同表旧列 Op(int)',
+  `route_code`            varchar(100)  DEFAULT NULL COMMENT '← $.RouteCode;🔴 不是旧列 RoutingCode。与 MaterialCode 38805/38806 逐值相同',
+  `route_name`            varchar(200)  DEFAULT NULL COMMENT '← $.RouteName;实测存的是码不是名(distinct 与 RouteCode 同为 2797)',
+  `operation_description` varchar(200)  DEFAULT NULL COMMENT '← $.OperationDescription;🔴 不是旧列 Descr。1 行为空串(RecID=106435)',
+  `work_center_code`      varchar(100)  DEFAULT NULL COMMENT '← $.WorkCenterCode(12067/38806 非空);软引用,5630 孤儿故不设父',
+  `std_op`                varchar(100)  DEFAULT NULL COMMENT '🔴 ← $.StdOp,**不设父**:对 StdOpMaster 孤儿 37112/38746,CRUD 不写、UI 无下拉、码空间不同构(0010 vs 1-1/包装)。与 item 孤儿(44)是两回事',
+  `domain_code`           varchar(100)  DEFAULT NULL COMMENT 'LEGACY_SCOPE ← $.Domain;源 1 行 NULL(RecID=106435)故不设 Required',
+  `company_id`            bigint        NOT NULL COMMENT 'LEGACY_SCOPE ← $.CompanyRefId',
+  `factory_id`            bigint        NOT NULL COMMENT 'LEGACY_SCOPE ← $.FactoryRefId',
+  `ufld1`                 varchar(200)  DEFAULT NULL COMMENT '← $.Ufld1(Ufld2..Ufld9 全库 distinct=1,不物化)',
+  `udeci1`                decimal(18,5) DEFAULT NULL COMMENT '← $.UDeci1',
+  `udeci2`                decimal(18,5) DEFAULT NULL COMMENT '← $.UDeci2',
+  `udeci3`                decimal(18,5) DEFAULT NULL COMMENT '← $.UDeci3',
+  `udeci4`                decimal(18,5) DEFAULT NULL COMMENT '← $.UDeci4',
+  `udeci5`                decimal(18,5) DEFAULT NULL COMMENT '← $.UDeci5',
+  `run_time`              decimal(18,6) DEFAULT NULL COMMENT '🔴 ← $.RunTime,源是 (18,6),与 UDeci* 的 (18,5) 不同,不得统一',
+  `outsourced_lead_time`  int           DEFAULT NULL COMMENT '← $.OutsourcedLeadTime',
+  `sort_no`               int           DEFAULT NULL COMMENT '← $.SortNo(在源 UK 内但近乎常量,保留可见性)',
+  `comment_index`         int           DEFAULT NULL COMMENT '← $.CommentIndex',
+  `parent_op`             int           DEFAULT NULL COMMENT '← $.ParentOp',
+  `is_confirmed`          tinyint(1)    NOT NULL COMMENT '← $.IsConfirm;本表唯一可安全物化的布尔(IsEnabled distinct=1;IsActive/MilestoneOp 是可空 tinyint(1),见表注释)',
+  `source_system`         varchar(50)   NOT NULL,
+  `source_biz_key`        varchar(300)  NOT NULL,
+  `source_updated_at`     datetime      DEFAULT NULL COMMENT '🔴 ← $.UpdatedAt(本表审计列叫 CreatedAt/UpdatedAt,不是 CreateTime/UpdateTime);仅 5/38806',
+  `sync_batch_id`         varchar(64)   NOT NULL,
+  `sync_time`             datetime      NOT NULL,
+  `create_time`           datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`           datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 + 100*4 = 808 < 3072
+  UNIQUE KEY `uk_bridge_item_routing_op` (`tenant_id`,`item_code`,`operation_code`),
+  KEY `idx_biro_parent` (`tenant_id`,`item_code`),
+  KEY `idx_biro_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:物料工艺路线工序。源 RoutingOpDetail,FULL REPLACE。父 = dim_item(自然键 item_code,44 孤儿 WARN)。「报工工序」与「启用状态」本轮不可物化:MilestoneOperation 0 填充、MilestoneOp/IsActive 是可空 tinyint(1)。';
+
+-- ── 3. mdp_entity 登记 ────────────────────────────────────────────────────────────────
+-- batch_size 按各自最大租户行数留 3× 余量:ProdLineDetail 5817 → 20000;RoutingOpDetail 32309 → 100000。
+-- AssertNoSourceDuplicateAsync 是 sourceCount >= batchSize 严格判定,必须单页装下。
+-- 🔴 batch_size 是 **entity 级**(S0DimMaterializer 读 entity.BatchSize),不影响其它任何维度。
+SET @src_id = (SELECT `id` FROM `mdp_source` WHERE `source_code` = 'AIDOPDEV_MYSQL' LIMIT 1);
+
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_OP_LINE', 'S0 物料工序产线产能入站', 'TABLE',
+       'ProdLineDetail', 'mdp_stg_s0_item_op_line', 'FULL', NULL, 20000,
+       'Part,Op,Line', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_OP_LINE');
+
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_ROUTING_OP', 'S0 物料工艺路线工序入站', 'TABLE',
+       'RoutingOpDetail', 'mdp_stg_s0_item_routing_op', 'FULL', NULL, 100000,
+       'MaterialCode,OperationCode', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_ROUTING_OP');
+
+-- ── 回滚(注释,非可执行)──
+-- DROP TABLE IF EXISTS `bridge_item_op_line`, `bridge_item_routing_op`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_item_op_line`, `mdp_stg_s0_item_routing_op`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id`=0 AND `entity_code` IN ('S0_ITEM_OP_LINE','S0_ITEM_ROUTING_OP');

+ 95 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.512.verify.sql

@@ -0,0 +1,95 @@
+-- WIP-C3A.verify.sql
+-- 契约(CURRENT.md > F-11):禁 SET @var · 每条返回 1/0 · 一条一断言 · 末条为真 SELECT。
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('mdp_stg_s0_item_op_line','bridge_item_op_line',
+                           'mdp_stg_s0_item_routing_op','bridge_item_routing_op')) = 4;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_op_line'
+                 AND INDEX_NAME = 'uk_bridge_item_op_line' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,item_code,operation_no,line_code';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_routing_op'
+                 AND INDEX_NAME = 'uk_bridge_item_routing_op' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,item_code,operation_code';
+
+-- 父键列名必须逐字是 item_code:OrphanFromWhere 用 p.<col> <=> d.<col> 同名列 join
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('bridge_item_op_line','bridge_item_routing_op')
+        AND COLUMN_NAME = 'item_code') = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('bridge_item_op_line','bridge_item_routing_op')
+        AND COLUMN_NAME IN ('item_num','dim_item_id','parent_id')) = 0;
+
+-- LEGACY_SCOPE 绝不进唯一键
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()
+        AND INDEX_NAME IN ('uk_bridge_item_op_line','uk_bridge_item_routing_op')
+        AND COLUMN_NAME IN ('domain_code','company_id','factory_id','work_center_code','route_code')) = 0;
+
+-- 影子列绝不能出现在目标表
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_line'
+        AND COLUMN_NAME IN ('internal_equipment_type_code')) = 0;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_line'
+        AND COLUMN_NAME = 'internal_equipment_code') = 1;
+
+-- 不可物化的列绝不能出现在目标表
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_routing_op'
+        AND COLUMN_NAME IN ('milestone_operation','milestone_op','is_active','is_enabled',
+                            'routing_code','descr','op')) = 0;
+
+-- Dec 精度必须与源逐位一致:op_line 五列 (18,6)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_line'
+        AND COLUMN_NAME IN ('rate','setup_time','overlap_time','sequence_no','standard_staff_count')
+        AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 6) = 5;
+
+-- routing_op:UDeci* 是 (18,5),run_time 是 (18,6),不得统一
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_routing_op'
+        AND COLUMN_NAME IN ('udeci1','udeci2','udeci3','udeci4','udeci5')
+        AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 5) = 5;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_routing_op'
+        AND COLUMN_NAME = 'run_time' AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 6) = 1;
+
+-- run_crew 在本桥是字符串(源 varchar(8)),不得建成 decimal
+SELECT (SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_line' AND COLUMN_NAME = 'run_crew') = 'varchar';
+
+-- domain_code 在 routing_op 必须可空(RecID=106435 那行 Domain 为 NULL)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_routing_op'
+        AND COLUMN_NAME = 'domain_code' AND IS_NULLABLE = 'YES') = 1;
+
+-- mdp_entity 契约:biz_key_expr 须与 SourceBizKeyColumns 逐字一致(AssertEntityContractAsync 硬比对)
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_OP_LINE'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Part,Op,Line'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'ProdLineDetail'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_op_line'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 20000) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_ROUTING_OP'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'MaterialCode,OperationCode'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'RoutingOpDetail'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_routing_op'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 100000) = 1;
+
+-- batch_size 必须够装下最大租户(AssertNoSourceDuplicateAsync 是 >= 严格判定)
+SELECT (SELECT `batch_size` FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_OP_LINE')
+       > (SELECT MAX(c) FROM (SELECT COUNT(*) c FROM `ProdLineDetail` GROUP BY `tenant_id`) x);
+SELECT (SELECT `batch_size` FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_ROUTING_OP')
+       > (SELECT MAX(c) FROM (SELECT COUNT(*) c FROM `RoutingOpDetail` GROUP BY `tenant_id`) x);
+
+-- 未因本批放大其它 entity 的 batch_size
+SELECT (SELECT COUNT(*) FROM `mdp_entity` WHERE `entity_code` LIKE 'S0\_%'
+        AND `batch_size` NOT IN (5000, 20000, 50000, 100000)) = 0;

+ 142 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.513.sql

@@ -0,0 +1,142 @@
+-- =====================================================================================
+-- WIP-C3B  ·  S0 · C3-B 物料桥:ProductStructureMaster / ItemSubstituteDetail
+--   (占位名,提交那刻 rename 为正式 1.0.<next>.sql)
+--
+--   ProductStructureMaster → mdp_stg_s0_item_bom        → bridge_item_bom
+--   ItemSubstituteDetail   → mdp_stg_s0_item_substitute → bridge_item_substitute
+--
+-- 🔴🔴 legacy 代理键跨租户实证(2026-09-08):
+--    ComponentMaterialId → ItemMaster.RecID  同租户失败 170 / 忽略租户失败 0
+--    ParentMaterialId    → ItemMaster.RecID  同租户失败 2060 / 忽略租户失败 0
+--    那 170 行恰恰全部挂到**别的租户**的 RecID 上 —— 去掉租户条件不报错,只会静默串租户。
+--    同租户可解析的 61866 行里,代理键解出的码与 ComponentItem 61866/61866 完全一致
+--    ⇒ 自然键完全可靠。本批一律走自然键,两个 id 仅作技术属性留痕。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_bom` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_bom` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_bom_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_bom_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:BOM 结构(源 ProductStructureMaster)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_bom` (
+  `id`                        bigint        NOT NULL AUTO_INCREMENT,
+  `tenant_id`                 bigint        NOT NULL,
+  `item_code`                 varchar(50)   NOT NULL COMMENT '业务键 + 父键 ← $.ParentItem(BOM 父件);父 = dim_item.item_code(实测孤儿 0/62036)',
+  `component_item_code`       varchar(50)   NOT NULL COMMENT '业务键 ← $.ComponentItem(子件);对 dim_item 同样零孤儿,但框架单父故不设父',
+  `qty`                       decimal(18,5) DEFAULT NULL COMMENT '← $.Qty',
+  `unit`                      varchar(50)   DEFAULT NULL COMMENT '← $.UM',
+  `scrap`                     decimal(18,5) DEFAULT NULL COMMENT '← $.Scrap',
+  `qty_consumed`              decimal(18,5) DEFAULT NULL COMMENT '← $.QtyConsumed',
+  `qty_exchanged`             decimal(18,5) DEFAULT NULL COMMENT '← $.QtyExchd',
+  `refs`                      varchar(200)  DEFAULT NULL COMMENT '← $.Refs',
+  `operation_no`              int           DEFAULT NULL COMMENT '← $.Op',
+  `company_id`                bigint        NOT NULL COMMENT 'LEGACY_SCOPE ← $.CompanyRefId',
+  `factory_id`                bigint        NOT NULL COMMENT 'LEGACY_SCOPE ← $.FactoryRefId',
+  `legacy_parent_item_id`     bigint        NOT NULL COMMENT '🔴 技术属性 ← $.ParentMaterialId。解析 → ItemMaster.RecID 时**必须带 tenant_id**:忽略租户会 0 失败但静默串租户(同租户失败 2060 行)',
+  `legacy_component_item_id`  bigint        NOT NULL COMMENT '🔴 技术属性 ← $.ComponentMaterialId。同上:忽略租户 0 失败,而那 170 行恰恰全挂到别的租户',
+  `source_system`             varchar(50)   NOT NULL,
+  `source_biz_key`            varchar(300)  NOT NULL,
+  `source_updated_at`         datetime      DEFAULT NULL COMMENT '← $.UpdatedAt(源仅 4/62036,保留系框架约定槽位)',
+  `sync_batch_id`             varchar(64)   NOT NULL,
+  `sync_time`                 datetime      NOT NULL,
+  `create_time`               datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`               datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 50*4 + 50*4 = 408 < 3072
+  UNIQUE KEY `uk_bridge_item_bom` (`tenant_id`,`item_code`,`component_item_code`),
+  KEY `idx_bib_parent` (`tenant_id`,`item_code`),
+  KEY `idx_bib_component` (`tenant_id`,`component_item_code`),
+  KEY `idx_bib_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:BOM 结构。源 ProductStructureMaster,FULL REPLACE。父 = dim_item(自然键 item_code)。IsActive 是 bit(1) 且含 1 行 NULL,双重地雷故不物化;IsEnabled/SequenceNum/StructureType distinct=1、LineOp/Remark/StartEff/EndEff 全空,一并不物化。';
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_substitute` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_substitute` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_substitute_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_substitute_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:替代料(源 ItemSubstituteDetail)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_substitute` (
+  `id`                   bigint        NOT NULL AUTO_INCREMENT,
+  `tenant_id`            bigint        NOT NULL,
+  `parent_item_code`     varchar(100)  NOT NULL COMMENT '业务键 ← $.ParentItem;对 dim_item 零孤儿但框架单父故不设父。实测与 item_code 16 行无一相等,是真实不同信息',
+  `item_code`            varchar(100)  NOT NULL COMMENT '业务键 + 父键 ← $.ItemNum(被替代的料);父 = dim_item.item_code(实测孤儿 0/16)',
+  `substitute_item_code` varchar(100)  NOT NULL COMMENT '业务键 ← $.SubstituteItem;对 dim_item 零孤儿,不设父',
+  `substitute_qty`       decimal(18,6) DEFAULT NULL COMMENT '← $.SubstituteQty',
+  `substitute_type`      varchar(20)   DEFAULT NULL COMMENT '← $.SubstituteType',
+  `remark`               varchar(255)  DEFAULT NULL COMMENT '← $.Remark',
+  `domain_code`          varchar(50)   NOT NULL COMMENT 'LEGACY_SCOPE ← $.Domain;在旧 UK 内但按 D-025 不入身份',
+  `source_system`        varchar(50)   NOT NULL,
+  `source_biz_key`       varchar(300)  NOT NULL,
+  `source_updated_at`    datetime      DEFAULT NULL COMMENT '← $.UpdateTime',
+  `sync_batch_id`        varchar(64)   NOT NULL,
+  `sync_time`            datetime      NOT NULL,
+  `create_time`          datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`          datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 + 100*4 + 100*4 = 1208 < 3072
+  UNIQUE KEY `uk_bridge_item_substitute` (`tenant_id`,`parent_item_code`,`item_code`,`substitute_item_code`),
+  KEY `idx_bis_parent` (`tenant_id`,`item_code`),
+  KEY `idx_bis_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:替代料。源 ItemSubstituteDetail,FULL REPLACE。父 = dim_item(自然键 item_code = 被替代料)。三个 item 引用全部统一到 dim_item。';
+
+SET @src_id = (SELECT `id` FROM `mdp_source` WHERE `source_code` = 'AIDOPDEV_MYSQL' LIMIT 1);
+
+-- batch_size 按最大租户留 3× 余量:ProductStructureMaster 单租户最大 57199 → 200000。
+-- AssertNoSourceDuplicateAsync 是 sourceCount >= batchSize 严格判定,必须单页装下。
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_BOM', 'S0 BOM 结构入站', 'TABLE',
+       'ProductStructureMaster', 'mdp_stg_s0_item_bom', 'FULL', NULL, 200000,
+       'ParentItem,ComponentItem', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_BOM');
+
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_SUBSTITUTE', 'S0 替代料入站', 'TABLE',
+       'ItemSubstituteDetail', 'mdp_stg_s0_item_substitute', 'FULL', NULL, 5000,
+       'ParentItem,ItemNum,SubstituteItem', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_SUBSTITUTE');
+
+-- ── 回滚(注释,非可执行)──
+-- DROP TABLE IF EXISTS `bridge_item_bom`, `bridge_item_substitute`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_item_bom`, `mdp_stg_s0_item_substitute`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id`=0 AND `entity_code` IN ('S0_ITEM_BOM','S0_ITEM_SUBSTITUTE');

+ 74 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.513.verify.sql

@@ -0,0 +1,74 @@
+-- WIP-C3B.verify.sql
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('mdp_stg_s0_item_bom','bridge_item_bom',
+                           'mdp_stg_s0_item_substitute','bridge_item_substitute')) = 4;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_bom'
+                 AND INDEX_NAME = 'uk_bridge_item_bom' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,item_code,component_item_code';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_substitute'
+                 AND INDEX_NAME = 'uk_bridge_item_substitute' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,parent_item_code,item_code,substitute_item_code';
+
+-- 父键列名必须逐字 item_code
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('bridge_item_bom','bridge_item_substitute') AND COLUMN_NAME = 'item_code') = 2;
+
+-- 🔴 legacy 代理键绝不能进唯一键(跨租户泄漏向量)
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()
+        AND INDEX_NAME = 'uk_bridge_item_bom'
+        AND COLUMN_NAME IN ('legacy_parent_item_id','legacy_component_item_id',
+                            'company_id','factory_id')) = 0;
+
+-- 但它们必须作为技术属性存在(供 legacy 对账),且带租户前缀的索引才可安全反查
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_bom'
+        AND COLUMN_NAME IN ('legacy_parent_item_id','legacy_component_item_id')) = 2;
+
+-- 🔴 bit(1) 且含 NULL 的 IsActive 绝不能被物化;其余零信息列同理
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_bom'
+        AND COLUMN_NAME IN ('is_active','is_enabled','sequence_num','structure_type',
+                            'line_op','remark','start_eff','end_eff')) = 0;
+
+-- Dec 精度必须与源逐位一致
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_bom'
+        AND COLUMN_NAME IN ('qty','scrap','qty_consumed','qty_exchanged')
+        AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 5) = 4;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_substitute'
+        AND COLUMN_NAME = 'substitute_qty' AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 6) = 1;
+
+-- 三个 item 引用必须都在替代料桥里
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_substitute'
+        AND COLUMN_NAME IN ('parent_item_code','item_code','substitute_item_code')) = 3;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_BOM'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'ParentItem,ComponentItem'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'ProductStructureMaster'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_bom'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 200000) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_SUBSTITUTE'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'ParentItem,ItemNum,SubstituteItem'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'ItemSubstituteDetail'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_substitute'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 5000) = 1;
+
+-- batch_size 必须够装下最大租户
+SELECT (SELECT `batch_size` FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_BOM')
+       > (SELECT MAX(c) FROM (SELECT COUNT(*) c FROM `ProductStructureMaster` GROUP BY `tenant_id`) x);
+
+-- 源侧身份前提:tenant+ParentItem+ComponentItem 无重复
+SELECT (SELECT COUNT(*) FROM (SELECT 1 FROM `ProductStructureMaster`
+        GROUP BY `tenant_id`,`ParentItem`,`ComponentItem` HAVING COUNT(*) > 1) x) = 0;

+ 133 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.514.sql

@@ -0,0 +1,133 @@
+-- =====================================================================================
+-- WIP-C3C  ·  S0 · C3-C 物料桥:ItemOpCondition / QualitySegmentImage
+--   (占位名,提交那刻 rename 为正式 1.0.<next>.sql)
+--
+--   ItemOpCondition     → mdp_stg_s0_item_op_condition → bridge_item_op_condition
+--   QualitySegmentImage → mdp_stg_s0_item_op_image     → bridge_item_op_image
+--
+-- 两桥的父都是 dim_item,走自然键 item_code(同名列 join,不得叫 item_num)。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_op_condition` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_op_cond` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_op_cond_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_op_cond_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:物料工序生产要素(源 ItemOpCondition)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_op_condition` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`         bigint       NOT NULL,
+  `item_code`         varchar(100) NOT NULL COMMENT '业务键 + 父键 ← $.ItemNum;父 = dim_item.item_code(实测孤儿 0/10)',
+  `code_type`         varchar(50)  NOT NULL COMMENT '业务键 ← $.CodeType;控制器硬编码 Prod、零区分力,保留系防不可逆键收缩',
+  `is_main`           tinyint(1)   NOT NULL COMMENT '🔴 ← $.IsMain。源 tinyint(1) 在 raw_data 里是 JSON BOOLEAN,只能走 BoolTrue:Int 会把 true/false 双双静默压成 0,Str 会得到 ''true'' 而源侧是 ''1''',
+  `op_code`           varchar(100) DEFAULT NULL COMMENT '← $.Op;不在源 UK 内且 DTO 无 Required,故不入身份',
+  `line_code`         varchar(100) DEFAULT NULL COMMENT '← $.Line;软引用 LineMaster,不设父',
+  `typed_code`        varchar(100) DEFAULT NULL COMMENT '← $.Typed',
+  `element_code`      varchar(100) DEFAULT NULL COMMENT '🔴 要素编码 ← $.ItemCode。与 item_code 语义不同,实测两列 0 行相等,5/10 为空',
+  `descr`             varchar(255) DEFAULT NULL COMMENT '← $.Descr',
+  `domain_code`       varchar(50)  DEFAULT NULL COMMENT 'LEGACY_SCOPE ← $.Domain',
+  `source_system`     varchar(50)  NOT NULL,
+  `source_biz_key`    varchar(300) NOT NULL,
+  `source_updated_at` datetime     DEFAULT NULL COMMENT '← $.UpdateTime(源 5/10)',
+  `sync_batch_id`     varchar(64)  NOT NULL,
+  `sync_time`         datetime     NOT NULL,
+  `create_time`       datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`       datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 + 50*4 = 612 < 3072
+  UNIQUE KEY `uk_bridge_item_op_condition` (`tenant_id`,`item_code`,`code_type`),
+  KEY `idx_bioc_parent` (`tenant_id`,`item_code`),
+  KEY `idx_bioc_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:物料工序生产要素。源 ItemOpCondition,FULL REPLACE。父 = dim_item(自然键 item_code)。唯一键严格弱于源 UK(源含 Domain+IsMain);源侧若出现重复由 AssertNoSourceDuplicateAsync 在 purge 前显式拦下,零数据被触碰。';
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_item_op_image` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_item_op_image` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_item_op_image_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_item_op_image_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:物料工序 SOP 文件(源 QualitySegmentImage)';
+
+CREATE TABLE IF NOT EXISTS `bridge_item_op_image` (
+  `id`             bigint        NOT NULL AUTO_INCREMENT,
+  `tenant_id`      bigint        NOT NULL,
+  `item_code`      varchar(100)  NOT NULL COMMENT '业务键 + 父键 ← $.item_num;父 = dim_item.item_code(实测孤儿 0/6)',
+  `op_code`        varchar(100)  NOT NULL COMMENT '业务键 ← $.op',
+  `file_type_code` varchar(100)  NOT NULL COMMENT '业务键 ← $.image_type_id;软引用 ref_sop_file_type.file_type_code(0/6 孤儿),**不设父**(框架单父,主语是文件本身而非其分类)',
+  `line_code`      varchar(100)  DEFAULT NULL COMMENT '← $.line(源 5/6)',
+  `file_path`      varchar(1000) DEFAULT NULL COMMENT '← $.path(源 3/6)',
+  `descr`          varchar(255)  DEFAULT NULL COMMENT '← $.descr',
+  `rev`            varchar(50)   DEFAULT NULL COMMENT '← $.rev',
+  `sort_id`        int           DEFAULT NULL COMMENT '← $.sort_id(源真 int,非 tinyint(1))',
+  `domain_code`    varchar(50)   DEFAULT NULL COMMENT 'LEGACY_SCOPE ← $.domain;租户间口径异构(797=组织雪花号 / 838257186181189=8010,2410)',
+  `source_system`  varchar(50)   NOT NULL,
+  `source_biz_key` varchar(300)  NOT NULL,
+  -- 🔴 刻意无 source_updated_at:源 QualitySegmentImage 没有任何时间列(11 列全清单实测),不得回填
+  `sync_batch_id`  varchar(64)   NOT NULL,
+  `sync_time`      datetime      NOT NULL,
+  `create_time`    datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`    datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 + 100*4 + 100*4 = 1208 < 3072
+  UNIQUE KEY `uk_bridge_item_op_image` (`tenant_id`,`item_code`,`op_code`,`file_type_code`),
+  KEY `idx_bioi_parent` (`tenant_id`,`item_code`),
+  KEY `idx_bioi_filetype` (`tenant_id`,`file_type_code`),
+  KEY `idx_bioi_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 关系桥:物料工序 SOP 文件。源 QualitySegmentImage,FULL REPLACE。父 = dim_item(自然键 item_code)。源表零时间列故无 source_updated_at。';
+
+SET @src_id = (SELECT `id` FROM `mdp_source` WHERE `source_code` = 'AIDOPDEV_MYSQL' LIMIT 1);
+
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_OP_CONDITION', 'S0 物料工序生产要素入站', 'TABLE',
+       'ItemOpCondition', 'mdp_stg_s0_item_op_condition', 'FULL', NULL, 5000,
+       'ItemNum,CodeType', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_OP_CONDITION');
+
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_ITEM_OP_IMAGE', 'S0 物料工序 SOP 文件入站', 'TABLE',
+       'QualitySegmentImage', 'mdp_stg_s0_item_op_image', 'FULL', NULL, 5000,
+       'item_num,op,image_type_id', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_ITEM_OP_IMAGE');
+
+-- ── 回滚(注释,非可执行)──
+-- DROP TABLE IF EXISTS `bridge_item_op_condition`, `bridge_item_op_image`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_item_op_condition`, `mdp_stg_s0_item_op_image`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id`=0 AND `entity_code` IN ('S0_ITEM_OP_CONDITION','S0_ITEM_OP_IMAGE');

+ 68 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.514.verify.sql

@@ -0,0 +1,68 @@
+-- WIP-C3C.verify.sql
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('mdp_stg_s0_item_op_condition','bridge_item_op_condition',
+                           'mdp_stg_s0_item_op_image','bridge_item_op_image')) = 4;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_op_condition'
+                 AND INDEX_NAME = 'uk_bridge_item_op_condition' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,item_code,code_type';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bridge_item_op_image'
+                 AND INDEX_NAME = 'uk_bridge_item_op_image' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,item_code,op_code,file_type_code';
+
+-- 父键列名必须逐字 item_code
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('bridge_item_op_condition','bridge_item_op_image')
+        AND COLUMN_NAME = 'item_code') = 2;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('bridge_item_op_condition','bridge_item_op_image')
+        AND COLUMN_NAME IN ('item_num','dim_item_id')) = 0;
+
+-- is_main 是属性不是键
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()
+        AND INDEX_NAME = 'uk_bridge_item_op_condition' AND COLUMN_NAME = 'is_main') = 0;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_condition' AND COLUMN_NAME = 'is_main') = 1;
+
+-- element_code 必须与 item_code 并存且分离(证明未把要素编码当物料码)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_condition'
+        AND COLUMN_NAME IN ('item_code','element_code')) = 2;
+
+-- 🔴 image 桥绝不能有 source_updated_at(源零时间列)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_image' AND COLUMN_NAME = 'source_updated_at') = 0;
+-- 而 condition 桥有(源 UpdateTime 存在)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'bridge_item_op_condition' AND COLUMN_NAME = 'source_updated_at') = 1;
+
+-- LEGACY_SCOPE 不进唯一键
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()
+        AND INDEX_NAME IN ('uk_bridge_item_op_condition','uk_bridge_item_op_image')
+        AND COLUMN_NAME IN ('domain_code','company_id','factory_id')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_OP_CONDITION'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'ItemNum,CodeType'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'ItemOpCondition'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_op_condition'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 5000) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_ITEM_OP_IMAGE'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'item_num,op,image_type_id'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'QualitySegmentImage'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_item_op_image'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 5000) = 1;
+
+-- 源侧前提:三个业务键分量当前无 NULL/空串(W6 风险的当前状态)
+SELECT (SELECT SUM(item_num IS NULL OR item_num = '' OR op IS NULL OR op = ''
+                   OR image_type_id IS NULL OR image_type_id = '') FROM `QualitySegmentImage`) = 0;
+SELECT (SELECT COUNT(*) FROM (SELECT 1 FROM `ItemOpCondition`
+        GROUP BY `tenant_id`,`ItemNum`,`CodeType` HAVING COUNT(*) > 1) x) = 0;

+ 215 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Dim/S0DimContractTests.cs

@@ -37,7 +37,11 @@ public class S0DimContractTests
                 // C1 扩展
                 // C1 扩展
                 "ITEM",
                 "ITEM",
                 // C3
                 // C3
-                "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL"
+                "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL",
+                // C3-A/C/B 物料桥(全部父 = dim_item)
+                "ITEM_OP_LINE", "ITEM_ROUTING_OP",
+                "ITEM_OP_CONDITION", "ITEM_OP_IMAGE",
+                "ITEM_BOM", "ITEM_SUBSTITUTE"
             ],
             ],
             S0DimCatalog.All.Select(d => d.Key).ToArray());
             S0DimCatalog.All.Select(d => d.Key).ToArray());
         Assert.All(S0DimCatalog.All, d => Assert.Same(d, S0DimCatalog.Find(d.Key)));
         Assert.All(S0DimCatalog.All, d => Assert.Same(d, S0DimCatalog.Find(d.Key)));
@@ -742,6 +746,216 @@ public class S0DimContractTests
             Assert.DoesNotContain("JSON_TYPE", expr, Ord);
             Assert.DoesNotContain("JSON_TYPE", expr, Ord);
     }
     }
 
 
+    // ════════════════════════════════════════════════════════════════════════════
+    // C3-A 物料桥(ProdLineDetail / RoutingOpDetail)
+    // 每条断言都对应一个实测过的取错点;改这些映射前先看 S0DimCatalog 里的类型注释。
+    // ════════════════════════════════════════════════════════════════════════════
+
+    /// <summary>全部 6 张 C3 物料桥 —— 通则断言对每一张都必须成立。</summary>
+    public static TheoryData<string> C3aItemBridges() =>
+    [
+        "ITEM_OP_LINE", "ITEM_ROUTING_OP",
+        "ITEM_OP_CONDITION", "ITEM_OP_IMAGE",
+        "ITEM_BOM", "ITEM_SUBSTITUTE"
+    ];
+
+    [Theory][MemberData(nameof(C3aItemBridges))]
+    public void C3a_bridges_must_parent_dim_item_by_natural_key(string key)
+    {
+        // 父连接走 p.<col> <=> d.<col> **同名列** join(S0DimSqlBuilder.OrphanFromWhere),
+        // 而 dim_item 的身份列叫 item_code —— 子表若命名为 item_num,Validate() 会过、
+        // 运行时 join 报未知列。
+        var d = S0DimCatalog.All.Single(x => x.Key == key);
+        Assert.Equal("dim_item", d.ParentDimTable);
+        Assert.Equal(["item_code"], d.ParentKeyColumns);
+        Assert.Contains("item_code", d.BusinessKeyColumns);
+        // 绝不能用 dim 代理 id 建关系:每轮 FULL REPLACE 后 dim_item.id 都会变
+        Assert.DoesNotContain("dim_item_id", d.Columns.Select(c => c.TargetColumn));
+    }
+
+    [Theory][MemberData(nameof(C3aItemBridges))]
+    public void C3a_identities_must_not_contain_legacy_scope(string key)
+    {
+        var d = S0DimCatalog.All.Single(x => x.Key == key);
+        foreach (var legacy in new[] { "domain_code", "company_id", "factory_id", "work_center_code", "route_code" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+        Assert.Equal("tenant_id", d.BusinessKeyColumns[0]);
+    }
+
+    [Fact]
+    public void ItemOpLine_must_read_real_equipment_column_not_the_dead_shadow()
+    {
+        // 真列 InternalEquipmentCode 有 2430/10646 行有值;
+        // 同表 internal_equipment_type_code 是 0/10646 的死影子列,取错会全 NULL 且不报错。
+        var paths = S0DimCatalog.ItemOpLine.Columns.Select(c => c.JsonPath ?? "").ToList();
+        Assert.Contains("InternalEquipmentCode", paths);
+        Assert.DoesNotContain("internal_equipment_type_code", paths);
+        var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.ItemOpLine);
+        Assert.DoesNotContain("internal_equipment_type_code", sql, Ord);
+    }
+
+    [Fact]
+    public void ItemOpLine_run_crew_must_be_string_not_decimal()
+    {
+        // 🔴 ProdLineDetail.RunCrew 是 varchar(8);LinePost 的同名列源自 LineSkillMaster
+        // 是 decimal(10,3)。同名不同型,照抄会当场类型出错。
+        Assert.Equal(S0DimValueKind.Str,
+            S0DimCatalog.ItemOpLine.Columns.Single(c => c.TargetColumn == "run_crew").Kind);
+        Assert.Equal(S0DimValueKind.Dec,
+            S0DimCatalog.LinePost.Columns.Single(c => c.TargetColumn == "run_crew").Kind);
+    }
+
+    [Fact]
+    public void ItemOpLine_line_code_must_stay_in_identity()
+    {
+        // 去掉 Line 后 tenant+Part+Op 有 197 组 / 394 行重复 —— 不可逆的键收缩
+        Assert.Equal(["tenant_id", "item_code", "operation_no", "line_code"],
+            S0DimCatalog.ItemOpLine.BusinessKeyColumns);
+        Assert.Equal(["Part", "Op", "Line"], S0DimCatalog.ItemOpLine.SourceBizKeyColumns);
+    }
+
+    [Fact]
+    public void ItemRoutingOp_watermark_must_read_UpdatedAt_not_UpdateTime()
+    {
+        // 🔴 本表时间列叫 UpdatedAt/CreatedAt。写成全仓通用的 UpdateTime 不会报错,
+        // JSON 键不存在 → JSON_EXTRACT 返回 SQL NULL → 该列恒 NULL,静默失真。
+        Assert.Equal("UpdatedAt",
+            S0DimCatalog.ItemRoutingOp.Columns.Single(c => c.TargetColumn == "source_updated_at").JsonPath);
+        Assert.DoesNotContain("UpdateTime", S0DimCatalog.ItemRoutingOp.Columns.Select(c => c.JsonPath ?? ""));
+    }
+
+    public static TheoryData<string> RoutingOpForbiddenColumns() =>
+        // 六组新旧列近重复 + 两类不可物化的布尔。取错得到的不是 NULL 而是「看起来合法的错值」。
+        ["RoutingCode", "Descr", "MilestoneOperation", "MilestoneOp", "IsActive", "IsEnabled", "UpdateTime"];
+
+    [Theory][MemberData(nameof(RoutingOpForbiddenColumns))]
+    public void ItemRoutingOp_must_never_read_legacy_or_unmaterializable_columns(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, S0DimCatalog.ItemRoutingOp.Columns.Select(c => c.JsonPath ?? ""));
+    }
+
+    [Fact]
+    public void ItemRoutingOp_std_op_must_be_attribute_with_no_parent()
+    {
+        // StdOp 对 StdOpMaster 孤儿 37112/38746,CRUD 不写、UI 无下拉、码空间不同构。
+        // 它与 item 孤儿(仅 44)是两回事,勿混淆。框架只能挂一个父,父是 dim_item。
+        var d = S0DimCatalog.ItemRoutingOp;
+        Assert.Contains("StdOp", d.Columns.Select(c => c.JsonPath ?? ""));
+        Assert.DoesNotContain("std_op", d.BusinessKeyColumns);
+        Assert.Equal("dim_item", d.ParentDimTable);
+    }
+
+    [Fact]
+    public void ItemRoutingOp_decimal_scales_must_match_source_exactly()
+    {
+        // UDeci1..5 源 decimal(18,5),RunTime 源 decimal(18,6) —— 不得统一,
+        // 精度不一致会让 CAST AS CHAR 后的 checksum 两侧不等。
+        var d = S0DimCatalog.ItemRoutingOp;
+        foreach (var c in new[] { "udeci1", "udeci2", "udeci3", "udeci4", "udeci5" })
+        {
+            var col = d.Columns.Single(x => x.TargetColumn == c);
+            Assert.Equal((18, 5), (col.Precision, col.Scale));
+        }
+        var rt = d.Columns.Single(x => x.TargetColumn == "run_time");
+        Assert.Equal((18, 6), (rt.Precision, rt.Scale));
+    }
+
+    [Fact]
+    public void ItemOpLine_decimal_scales_must_all_be_18_6()
+    {
+        foreach (var c in new[] { "rate", "setup_time", "overlap_time", "sequence_no", "standard_staff_count" })
+        {
+            var col = S0DimCatalog.ItemOpLine.Columns.Single(x => x.TargetColumn == c);
+            Assert.Equal((18, 6), (col.Precision, col.Scale));
+        }
+    }
+
+    [Fact]
+    public void ItemRoutingOp_domain_must_not_be_required()
+    {
+        // RecID=106435 那行 TEST 数据的 Domain 是 NULL;设成 Required 会让该行被 RowGuards
+        // 整行滤掉 → dim_count < stg_count → 对账 FAIL。
+        Assert.False(S0DimCatalog.ItemRoutingOp.Columns.Single(c => c.TargetColumn == "domain_code").Required);
+    }
+
+    [Fact]
+    public void ItemOpCondition_is_main_must_be_booltrue_attribute_never_key()
+    {
+        // 源 tinyint(1) 在 raw_data 里是 JSON BOOLEAN。Int 会把 true/false 双双静默压成 0
+        // (比 bit(1) 地雷更毒:不报错、不为 NULL);Str 会得到 'true' 而源侧 checksum 是 '1'。
+        var d = S0DimCatalog.ItemOpCondition;
+        Assert.Equal(S0DimValueKind.BoolTrue, d.Columns.Single(c => c.TargetColumn == "is_main").Kind);
+        Assert.DoesNotContain("is_main", d.BusinessKeyColumns);
+        Assert.DoesNotContain("IsMain", d.SourceBizKeyColumns);
+    }
+
+    [Fact]
+    public void ItemOpCondition_element_code_must_not_be_confused_with_item_code()
+    {
+        // $.ItemCode 是「要素编码」,$.ItemNum 是「物料编码」,实测两列 0 行相等、前者 5/10 为空。
+        var d = S0DimCatalog.ItemOpCondition;
+        Assert.Equal("ItemCode", d.Columns.Single(c => c.TargetColumn == "element_code").JsonPath);
+        Assert.Equal("ItemNum",  d.Columns.Single(c => c.TargetColumn == "item_code").JsonPath);
+    }
+
+    [Fact]
+    public void ItemOpImage_must_not_declare_watermark_because_source_has_no_time_column()
+    {
+        // QualitySegmentImage 11 列里没有任何时间列(实测)。为字段齐整回填会得到恒 NULL 的假列。
+        Assert.DoesNotContain("source_updated_at",
+            S0DimCatalog.ItemOpImage.Columns.Select(c => c.TargetColumn));
+        // 对照:ItemOpCondition 的源有 UpdateTime,故必须有
+        Assert.Contains("source_updated_at",
+            S0DimCatalog.ItemOpCondition.Columns.Select(c => c.TargetColumn));
+    }
+
+    [Fact]
+    public void ItemBom_legacy_surrogate_ids_must_stay_out_of_identity()
+    {
+        // 🔴 实测跨租户泄漏向量:ComponentMaterialId → ItemMaster.RecID
+        // 同租户失败 170 / 忽略租户失败 0,而那 170 行恰恰全挂到别的租户;
+        // ParentMaterialId 同款(同租户失败 2060)。自然键则 0/62036 零孤儿且 61866/61866 一致。
+        var d = S0DimCatalog.ItemBom;
+        foreach (var legacy in new[] { "legacy_parent_item_id", "legacy_component_item_id", "company_id", "factory_id" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+        Assert.Equal(["ParentItem", "ComponentItem"], d.SourceBizKeyColumns);
+        // 但必须留痕,供 legacy 对账
+        Assert.Contains("legacy_component_item_id", d.Columns.Select(c => c.TargetColumn));
+    }
+
+    [Fact]
+    public void ItemBom_must_not_materialize_nullable_bit_or_zero_information_columns()
+    {
+        // IsActive 是 bit(1) 且含 1 行 NULL —— Int 让对账必挂、BoolTrue 会滤掉那行。
+        // IsEnabled/SequenceNum/StructureType distinct=1;LineOp/Remark/StartEff/EndEff 全库 0 填充。
+        var paths = S0DimCatalog.ItemBom.Columns.Select(c => c.JsonPath ?? "").ToList();
+        foreach (var forbidden in new[]
+                 { "IsActive", "IsEnabled", "SequenceNum", "StructureType", "LineOp", "Remark", "StartEff", "EndEff" })
+            Assert.DoesNotContain(forbidden, paths);
+    }
+
+    [Fact]
+    public void ItemSubstitute_all_three_item_refs_must_target_dim_item_naturally()
+    {
+        // 三个 item 引用全部统一到 dim_item 的自然键;框架单父,取被替代的 ItemNum 作 item_code。
+        var d = S0DimCatalog.ItemSubstitute;
+        var paths = d.Columns.ToDictionary(c => c.TargetColumn, c => c.JsonPath);
+        Assert.Equal("ParentItem",     paths["parent_item_code"]);
+        Assert.Equal("ItemNum",        paths["item_code"]);
+        Assert.Equal("SubstituteItem", paths["substitute_item_code"]);
+        Assert.Equal("dim_item", d.ParentDimTable);
+        Assert.Equal(["item_code"], d.ParentKeyColumns);
+    }
+
+    [Fact]
+    public void ItemSubstitute_parent_item_must_stay_in_identity()
+    {
+        // ParentItem 与 ItemNum 实测 16 行无一相等(distinct 9 vs 14),是真实不同信息。
+        // 全表仅 16 行,「去掉后仍唯一」证明不了可去 —— 去掉即不可逆的键收缩。
+        Assert.Contains("parent_item_code", S0DimCatalog.ItemSubstitute.BusinessKeyColumns);
+        Assert.Equal(["ParentItem", "ItemNum", "SubstituteItem"], S0DimCatalog.ItemSubstitute.SourceBizKeyColumns);
+    }
+
     [Fact]
     [Fact]
     public void SameDb_source_column_must_be_sqlsugar_mappable()
     public void SameDb_source_column_must_be_sqlsugar_mappable()
     {
     {

+ 375 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimCatalog.cs

@@ -713,6 +713,374 @@ public static class S0DimCatalog
             new("source_updated_at",   S0DimValueKind.DateTimeSec, "UpdateTime")
             new("source_updated_at",   S0DimValueKind.DateTimeSec, "UpdateTime")
         ]);
         ]);
 
 
+    /// <summary>
+    /// 物料-工序-产线产能。源 <c>ProdLineDetail</c>,父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + Part + Op + Line</b>(实测租户内重复 0 组)。
+    /// <c>Line</c> **不可省**:去掉后 <c>tenant+Part+Op</c> 有 <b>197 组 / 394 行</b>重复。
+    /// <c>Domain</c> 每租户单值(797→8010、824585161322565→2410、838257186181189→8010),
+    /// 零判别力 ⇒ LEGACY_SCOPE,不入身份(D-025)。</para>
+    ///
+    /// <para>🔴 <b>dual-column 陷阱</b>:真列是 <c>InternalEquipmentCode</c>(2430/10646 有值),
+    /// 同表的 <c>internal_equipment_type_code</c> 是 <b>0/10646 的死影子列</b>。取错不报错、只会全 NULL。</para>
+    ///
+    /// <para>🔴 <c>RunCrew</c> 在本表是 <c>varchar(8)</c>,而 <see cref="LinePost"/> 的同名列源自
+    /// <c>LineSkillMaster</c> 是 <c>decimal(10,3)</c> —— <b>同名不同型</b>,照抄会当场类型出错。</para>
+    ///
+    /// <para><c>Line</c> 有 <b>11 行带尾随空格</b>(<c>A01 </c>×2 / <c>BA01 </c>×6 / <c>DC01-1 </c>×1 /
+    /// <c>S01 </c>×1 / <c>S02 </c>×1)。<c>utf8mb4_0900_ai_ci</c> 是 NO PAD,这是真实业务键差异,
+    /// **原样透传,禁止 TRIM**。</para>
+    ///
+    /// <para><c>Line</c> / <c>Site</c> 都只作软引用**不设父**:<c>Line→dim_line</c> 同租户孤儿
+    /// 8848/10646(租户 824585161322565 的 <c>LineMaster</c> 整体 0 行),<c>Site</c> 更有 5823/10646 为 NULL;
+    /// 且 CRUD 写入路径对二者**零存在性校验**(<c>_lineMasterRep</c>/<c>_workCtrRep</c> 只用于列表回显)。
+    /// 与 <see cref="LinePost"/> 不挂 <c>dim_line</c> 同源。</para>
+    ///
+    /// <para>⚠️ <c>Op</c> 是本仓**首个 <c>Int</c> 类型业务键**。三侧渲染已验证一致:
+    /// C# <c>((int)1002).ToString()</c> / 源侧 <c>CONCAT_WS(…, a.Op)</c> / dim 侧同 —— 均为 <c>'1002'</c>。</para>
+    ///
+    /// <para>租户分布 797=5817 / 824585161322565=4823 / 838257186181189=6,
+    /// 故 <c>batch_size</c> 取 <b>20000</b>(3.4× 头寸;默认 5000 会被
+    /// <c>AssertNoSourceDuplicateAsync</c> 的 <c>sourceCount &gt;= batchSize</c> 直接判失败)。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemOpLine = new(
+        Key: "ITEM_OP_LINE",
+        EntityCode: "S0_ITEM_OP_LINE",
+        SourceTable: "ProdLineDetail",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_op_line",
+        DimTable: "bridge_item_op_line",
+        BusinessKeyColumns: ["tenant_id", "item_code", "operation_no", "line_code"],
+        SourceBizKeyColumns: ["Part", "Op", "Line"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",               S0DimValueKind.TenantIdColumn),
+            new("item_code",               S0DimValueKind.Str,         "Part",   Required: true),
+            new("operation_no",            S0DimValueKind.Int,         "Op",     Required: true),
+            new("line_code",               S0DimValueKind.Str,         "Line",   Required: true),
+            new("domain_code",             S0DimValueKind.Str,         "Domain", Required: true),
+            new("work_center_code",        S0DimValueKind.Str,         "Site"),
+            new("rate",                    S0DimValueKind.Dec,         "Rate",               Precision: 18, Scale: 6),
+            new("setup_time",              S0DimValueKind.Dec,         "SetupTime",          Precision: 18, Scale: 6),
+            new("overlap_time",            S0DimValueKind.Dec,         "OverlapTime",        Precision: 18, Scale: 6),
+            new("sequence_no",             S0DimValueKind.Dec,         "Sequence",           Precision: 18, Scale: 6),
+            new("standard_staff_count",    S0DimValueKind.Dec,         "StandardStaffCount", Precision: 18, Scale: 6),
+            new("skill_code",              S0DimValueKind.Str,         "SkillNo"),
+            // 🔴 真列,**不是**同表 0/10646 的影子列 internal_equipment_type_code
+            new("internal_equipment_code", S0DimValueKind.Str,         "InternalEquipmentCode"),
+            new("mold_type_code",          S0DimValueKind.Str,         "MoldTypeCode"),
+            new("start_marker",            S0DimValueKind.Int,         "Start"),
+            new("is_active",               S0DimValueKind.BoolTrue,    "IsActive"),
+            new("is_confirmed",            S0DimValueKind.BoolTrue,    "IsConfirm"),
+            // 🔴 源 varchar(8),与 dim_line_post.run_crew 的 decimal(10,3) 同名不同型
+            new("run_crew",                S0DimValueKind.Str,         "RunCrew"),
+            new("op_type",                 S0DimValueKind.Str,         "OpType"),
+            new("bom_code",                S0DimValueKind.Str,         "Bom"),
+            new("source_updated_at",       S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
+    /// <summary>
+    /// 物料工艺路线工序。源 <c>RoutingOpDetail</c>(38806 行,S0 最大的桥),父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + MaterialCode + OperationCode</b>(实测重复 0 组)。
+    /// <c>RouteCode</c> 不必要:每租户 <c>COUNT(DISTINCT RouteCode) == COUNT(DISTINCT MaterialCode)</c>
+    /// 且两列逐值相同 38805/38806 行。<c>SortNo</c> 也不必要(近乎常量)。
+    /// 去掉 <c>OperationCode</c> 则塌陷成 <b>2879 组 / 38677 行</b>重复。</para>
+    ///
+    /// <para>🔴 <b>本表的时间列叫 <c>UpdatedAt</c> / <c>CreatedAt</c>,不是全仓通用的 <c>UpdateTime</c></b>。
+    /// 照抄其它 definition 写 <c>UpdateTime</c> 会**静默取到恒 NULL**(JSON 键不存在 →
+    /// <c>JSON_EXTRACT</c> 返回 SQL NULL,不报错)。</para>
+    ///
+    /// <para>🔴 <b>六组新旧列近重复,取错不会得到 NULL 而是「看起来合法的错值」</b>
+    /// (<c>RouteCode</c>/<c>RoutingCode</c>/<c>MaterialCode</c>/<c>RouteName</c> 四列 38805/38806 同值)。
+    /// 真列判据 = CRUD 写入路径:<c>RouteCode</c>(非 <c>RoutingCode</c>)·
+    /// <c>OperationCode</c>(非旧列 <c>Op</c>)· <c>OperationDescription</c>(非 <c>Descr</c>)。</para>
+    ///
+    /// <para><b><c>StdOp</c> 不设父</b>:对 <c>StdOpMaster</c> 孤儿 <b>37112/38746</b>(95.8%),
+    /// <c>AdoS0ReferenceChecker</c> 无 StdOp 校验、CRUD 的 <c>ApplyUpsert</c> 根本不写该列、UI 无下拉,
+    /// 且值域不同构(本表 <c>0010</c>/<c>0020</c> vs <c>StdOpMaster.StdOp</c> 的 <c>1-1/包装</c>,
+    /// 仅 1634/38806 偶然命中)。**它与 item 孤儿是两回事** —— item 孤儿只有 <b>44</b>。</para>
+    ///
+    /// <para>父孤儿 <b>44 行 = ALLOW + WARNING</b>(框架原生:孤儿只告警不阻断):全在租户 797,
+    /// 仅 2 个物料码 <c>A198CABA</c>×18 / <c>918B0238</c>×26,忽略租户后仍是 44
+    /// ⇒ 是被引用主数据**全库整体缺失**,不是跨租户错挂。</para>
+    ///
+    /// <para><b>本轮无法物化的两个业务语义</b>(数据侧问题,非建模问题):
+    /// 「报工工序」—— CRUD 唯一写入的 <c>MilestoneOperation</c> 是 <b>0/38806</b>,
+    /// 数据全在 CRUD 从不写的旧列 <c>MilestoneOp</c>,而后者是**可空 <c>tinyint(1)</c>**(1 行 NULL);
+    /// 「启用状态」—— <c>IsEnabled</c> distinct=1 零信息,<c>IsActive</c> 同为可空 <c>tinyint(1)</c>。
+    /// 可空 tinyint 用 <c>BoolTrue</c> 会滤掉 NULL 行导致行数对账 FAIL、用 <c>Int</c> 会静默写 0,
+    /// 故一律不物化(与 <see cref="Item"/> 排除 <c>DEAControlled</c> 同因)。</para>
+    ///
+    /// <para><c>Ufld2</c>…<c>Ufld9</c>(8 列)· <c>OverlapUnits</c> · <c>PackingQty</c> ·
+    /// <c>ProcessOutDay</c> 全库 distinct=1,零信息,不物化。</para>
+    ///
+    /// <para>租户分布 797=32309 / 824585161322565=4824 / 838257237606469=1398 /
+    /// 838257186181189=156 / 838257212780613=119,故 <c>batch_size</c> 取 <b>100000</b>(3.1× 头寸)。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemRoutingOp = new(
+        Key: "ITEM_ROUTING_OP",
+        EntityCode: "S0_ITEM_ROUTING_OP",
+        SourceTable: "RoutingOpDetail",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_routing_op",
+        DimTable: "bridge_item_routing_op",
+        BusinessKeyColumns: ["tenant_id", "item_code", "operation_code"],
+        SourceBizKeyColumns: ["MaterialCode", "OperationCode"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",             S0DimValueKind.TenantIdColumn),
+            new("item_code",             S0DimValueKind.Str,         "MaterialCode",  Required: true),
+            // 🔴 新列;旧列 Op(int) 是 38805/38806 的近重复,不得取
+            new("operation_code",        S0DimValueKind.Str,         "OperationCode", Required: true),
+            new("route_code",            S0DimValueKind.Str,         "RouteCode"),      // 非旧列 RoutingCode
+            new("route_name",            S0DimValueKind.Str,         "RouteName"),      // 实存码值而非名称
+            new("operation_description", S0DimValueKind.Str,         "OperationDescription"), // 非旧列 Descr
+            new("work_center_code",      S0DimValueKind.Str,         "WorkCenterCode"),
+            new("std_op",                S0DimValueKind.Str,         "StdOp"),          // NO PARENT,见类型注释
+            // Domain 有 1 行 NULL(RecID=106435 的 TEST 行),故不设 Required,否则整行被滤 → 行数对账 FAIL
+            new("domain_code",           S0DimValueKind.Str,         "Domain"),
+            new("company_id",            S0DimValueKind.Int,         "CompanyRefId",  Required: true),
+            new("factory_id",            S0DimValueKind.Int,         "FactoryRefId",  Required: true),
+            new("ufld1",                 S0DimValueKind.Str,         "Ufld1"),
+            new("udeci1",                S0DimValueKind.Dec,         "UDeci1",  Precision: 18, Scale: 5),
+            new("udeci2",                S0DimValueKind.Dec,         "UDeci2",  Precision: 18, Scale: 5),
+            new("udeci3",                S0DimValueKind.Dec,         "UDeci3",  Precision: 18, Scale: 5),
+            new("udeci4",                S0DimValueKind.Dec,         "UDeci4",  Precision: 18, Scale: 5),
+            new("udeci5",                S0DimValueKind.Dec,         "UDeci5",  Precision: 18, Scale: 5),
+            // 🔴 源是 (18,6),与 UDeci* 的 (18,5) 不同,不得统一
+            new("run_time",              S0DimValueKind.Dec,         "RunTime", Precision: 18, Scale: 6),
+            new("outsourced_lead_time",  S0DimValueKind.Int,         "OutsourcedLeadTime"),
+            new("sort_no",               S0DimValueKind.Int,         "SortNo"),
+            new("comment_index",         S0DimValueKind.Int,         "CommentIndex"),
+            new("parent_op",             S0DimValueKind.Int,         "ParentOp"),
+            // 本表唯一可安全物化的布尔:NOT NULL 且 distinct=2
+            new("is_confirmed",          S0DimValueKind.BoolTrue,    "IsConfirm"),
+            // 🔴 源列名是 UpdatedAt,不是 UpdateTime
+            new("source_updated_at",     S0DimValueKind.DateTimeSec, "UpdatedAt")
+        ]);
+
+    /// <summary>
+    /// 物料工序生产要素。源 <c>ItemOpCondition</c>,父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + ItemNum + CodeType</b>(实测重复 0 组)。</para>
+    ///
+    /// <para>🔴 <b><c>IsMain</c> 不入身份 —— 但原因不是框架禁令,而是更下游且无法绕过的一层</b>:
+    /// 源列是 <c>tinyint(1) NOT NULL</c>,在 <c>raw_data</c> 里是 JSON <b>BOOLEAN</b>(非 1/0)。
+    /// 即便放开 <c>Validate()</c> 允许布尔入键仍然会挂 —— staging 的 <c>source_biz_key</c> 由
+    /// <c>mdp_entity.biz_key_expr</c> 驱动、走 C# <c>bool.ToString()</c> 产出 <c>"True"</c>/<c>"False"</c>,
+    /// 而源侧 checksum 渲染的是 <c>'1'</c>/<c>'0'</c>,四向集合比对必然全量不等价。
+    /// 修它要动全实体共享的 <c>MdpStagingWriter.BuildBizKey</c>。
+    /// 旁证:全部 <c>mdp_stg_s0_*</c> 的 <c>source_biz_key</c> 中匹配 <c>True|False</c> 的行数 = <b>0</b>,
+    /// 布尔入键在本仓从未发生过。故 <c>IsMain</c> 落 <b>属性</b>,且**必须用 <c>BoolTrue</c>** ——
+    /// 用 <c>Int</c> 会把 <c>true</c>/<c>false</c> 双双静默压成 0(比 bit(1) 地雷更毒,不报错不为 NULL),
+    /// 用 <c>Str</c> 会得到 <c>'true'</c> 而源侧是 <c>'1'</c>,校验和必挂。</para>
+    ///
+    /// <para><c>CodeType</c> 实测单值 <c>'Prod'</c> 且控制器硬编码写死、无视 DTO 入参,零区分力;
+    /// 保留系防不可逆键收缩(同 <see cref="LinePost"/> 的 <c>job_no</c> 先例),不是因为它有用。</para>
+    ///
+    /// <para>🔴 <c>ItemCode</c>(→ <c>element_code</c>)是**要素编码**,与 <c>ItemNum</c>(物料编码)
+    /// **语义不同**:实测两列相等的行数 = <b>0</b>,且 5/10 为空。任何把它当物料码 join 的代码都是 bug。</para>
+    ///
+    /// <para><c>Op</c> 虽 10/10 填充且 <c>(ItemNum,Op)→RoutingOpDetail</c> 零孤儿,但不在源 UK 内、
+    /// DTO 无 <c>[Required]</c> ⇒ 不入键。**不要因为表名叫 ItemOpCondition 就把 Op 塞进身份。**</para>
+    ///
+    /// <para>⚠️ dim 唯一键严格弱于源 UK(源含 <c>Domain</c> 与 <c>IsMain</c>),且两个控制器
+    /// 均无查重守卫,业务**可以**合法插出同 <c>(tenant,ItemNum,'Prod')</c> 的 IsMain=0/1 两行。
+    /// 届时 <c>AssertNoSourceDuplicateAsync</c>(在 purge **之前**)会显式失败、零数据被触碰 ——
+    /// 失败可见、可定位、不静默。当前实测 0 例。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemOpCondition = new(
+        Key: "ITEM_OP_CONDITION",
+        EntityCode: "S0_ITEM_OP_CONDITION",
+        SourceTable: "ItemOpCondition",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_op_condition",
+        DimTable: "bridge_item_op_condition",
+        BusinessKeyColumns: ["tenant_id", "item_code", "code_type"],
+        SourceBizKeyColumns: ["ItemNum", "CodeType"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",         S0DimValueKind.TenantIdColumn),
+            new("item_code",         S0DimValueKind.Str,         "ItemNum",  Required: true),
+            new("code_type",         S0DimValueKind.Str,         "CodeType", Required: true),
+            // 🔴 必须 BoolTrue:Int 会把 true/false 双双静默压成 0,Str 会得到 'true' 而源侧是 '1'
+            new("is_main",           S0DimValueKind.BoolTrue,    "IsMain"),
+            new("op_code",           S0DimValueKind.Str,         "Op"),
+            new("line_code",         S0DimValueKind.Str,         "Line"),
+            new("typed_code",        S0DimValueKind.Str,         "Typed"),
+            // 🔴 要素编码,**不是** ItemNum(实测两列 0 行相等)
+            new("element_code",      S0DimValueKind.Str,         "ItemCode"),
+            new("descr",             S0DimValueKind.Str,         "Descr"),
+            new("domain_code",       S0DimValueKind.Str,         "Domain"),
+            new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
+    /// <summary>
+    /// 物料工序 SOP 文件。源 <c>QualitySegmentImage</c>,父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + item_num + op + image_type_id</b>(实测重复 0 组)。
+    /// 源 UNIQUE 还含 <c>domain</c>,但它跨租户口径异构(797 用组织雪花号 <c>797403761152069</c>、
+    /// 838257186181189 用 legacy <c>8010</c>/<c>2410</c>),坐实 LEGACY_SCOPE,不入身份。</para>
+    ///
+    /// <para>🔴 <b>源表没有任何时间列</b>(11 列全清单实测),故**不声明 <c>source_updated_at</c>** ——
+    /// 与 <see cref="SopFileType"/> / <see cref="LabelType"/> 同型。**不得为字段齐整而回填。**</para>
+    ///
+    /// <para>⚠️ 源表那个叫 <c>uk_QualitySegmentImage_scope</c> 的索引**不是唯一索引**
+    /// (<c>IsUnique=false</c>);真正的唯一键是 <c>uk_QualitySegmentImage_scope_unique</c>。**别被名字骗了。**</para>
+    ///
+    /// <para><c>image_type_id</c> 软引用 <see cref="SopFileType"/> 的 <c>file_type_code</c>(实测 0 孤儿),
+    /// 但**不设父** —— 框架只能挂一个父,而本桥的主语是「某物料某工序的 SOP 文件」,
+    /// <c>image_type_id</c> 只是该文件的分类(闭集字典)。挂 <c>dim_item</c> 才让
+    /// 「S0 ITEM AUTHORITY = dim_item」在一处可强制、可审计;且 <c>ref_sop_file_type</c>
+    /// 当前只物化了租户 797,选它作父会让别的租户产出纯刷新顺序噪声的 WARN。
+    /// 同 <see cref="LinePostSkill"/> 的 <c>skill_code</c> 先例。</para>
+    ///
+    /// <para>⚠️ <c>item_num</c> / <c>op</c> / <c>image_type_id</c> 在源 DDL 里**均可空**,
+    /// 且 upsert DTO 只有 <c>Domain</c> 带 <c>[Required]</c> ⇒ 业务可合法建 NULL 行 →
+    /// 被 <c>RowGuards</c> 整行过滤 → <c>dim_count &lt; stg_count</c> → 阻断 FAIL。
+    /// 与 <see cref="EmpWorkDuty"/> 同风险类。当前实测 0 NULL / 0 空串。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemOpImage = new(
+        Key: "ITEM_OP_IMAGE",
+        EntityCode: "S0_ITEM_OP_IMAGE",
+        SourceTable: "QualitySegmentImage",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_op_image",
+        DimTable: "bridge_item_op_image",
+        BusinessKeyColumns: ["tenant_id", "item_code", "op_code", "file_type_code"],
+        SourceBizKeyColumns: ["item_num", "op", "image_type_id"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",      S0DimValueKind.TenantIdColumn),
+            new("item_code",      S0DimValueKind.Str, "item_num",      Required: true),
+            new("op_code",        S0DimValueKind.Str, "op",            Required: true),
+            new("file_type_code", S0DimValueKind.Str, "image_type_id", Required: true),
+            new("line_code",      S0DimValueKind.Str, "line"),
+            new("file_path",      S0DimValueKind.Str, "path"),
+            new("descr",          S0DimValueKind.Str, "descr"),
+            new("rev",            S0DimValueKind.Str, "rev"),
+            // 源是真 int(非 tinyint(1)),走 Int 安全
+            new("sort_id",        S0DimValueKind.Int, "sort_id"),
+            new("domain_code",    S0DimValueKind.Str, "domain")
+            // 🔴 刻意无 source_updated_at:源表零时间列,不得回填
+        ]);
+
+    /// <summary>
+    /// BOM 结构(父件-子件用量)。源 <c>ProductStructureMaster</c>(62036 行,S0 最大的表),
+    /// 父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + ParentItem + ComponentItem</b>(实测重复 0 组)。
+    /// <c>LineOp</c> / <c>Op</c> / <c>SequenceNum</c> 加进去**零增益**(仍是 0 组),故不入身份;
+    /// 去掉 <c>ComponentItem</c> 则塌陷成 <b>4341 组 / 60494 行</b>重复。
+    /// 旧 UK 含 <c>CompanyRefId</c>+<c>FactoryRefId</c>,不予继承 —— 身份不加它们已然唯一(D-025)。</para>
+    ///
+    /// <para>🔴🔴 <b>legacy 代理键 <c>ComponentMaterialId</c> / <c>ParentMaterialId</c> 是已实证的跨租户泄漏向量</b>
+    /// (2026-09-08 实测):
+    /// <list type="bullet">
+    /// <item>同租户解析 <c>ComponentMaterialId → ItemMaster.RecID</c>:<b>170 行解析失败</b>;</item>
+    /// <item>忽略租户解析:<b>0 行失败</b> —— 看起来「完美」;</item>
+    /// <item>而这 <b>170 行恰恰全部挂到了别的租户的 <c>RecID</c> 上</b>。</item>
+    /// </list>
+    /// <c>ParentMaterialId</c> 同款,量更大(同租户失败 <b>2060</b>、忽略租户 0)。
+    /// 也就是说:**去掉租户条件不会报错,只会静默串租户**。
+    /// 而同租户能解析的 61866 行里,代理键解出的码与 <c>ComponentItem</c> <b>61866/61866 完全一致</b>
+    /// ⇒ **自然键完全可靠,代理键只带来风险**。故本桥一律走自然键
+    /// <c>item_code</c> / <c>component_item_code</c>,两个 id 仅作 legacy 技术属性留痕,
+    /// **任何消费方解析它们时必须带 <c>tenant_id</c>**。</para>
+    ///
+    /// <para>父连接:<c>ParentItem</c> 与 <c>ComponentItem</c> 对 <c>ItemMaster</c> 的孤儿
+    /// **同租户与忽略租户都是 0/62036**。框架单父,取 <c>ParentItem</c> 作 <c>item_code</c>
+    /// (下游「查某物料的 BOM」天然按父件过滤);<c>ComponentItem</c> 落
+    /// <c>component_item_code</c>,同样零孤儿但不设父。</para>
+    ///
+    /// <para>🔴 <c>IsActive</c> 是 <b><c>bit(1)</c> 且含 1 行 NULL</b> —— 双重地雷,**不物化**:
+    /// 用 <see cref="S0DimValueKind.Int"/> 会让对账每轮必挂(<c>CAST(b'1' AS CHAR)</c> 是裸字节),
+    /// 用 <see cref="S0DimValueKind.BoolTrue"/> 会把那 1 行 NULL 整行滤掉 → 行数对账 FAIL。
+    /// 同理 <c>IsEnabled</c>(distinct=1 零信息)、<c>SequenceNum</c>(distinct=1)、
+    /// <c>StructureType</c>(7.2% 填充但 distinct=1)、<c>LineOp</c>/<c>Remark</c>/<c>StartEff</c>/<c>EndEff</c>
+    /// (全库 0 填充)一并不物化。</para>
+    ///
+    /// <para>⚠️ 单租户最大 <b>57199</b> 行(797),<c>batch_size</c> 取 <b>200000</b>(3.5× 头寸)。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemBom = new(
+        Key: "ITEM_BOM",
+        EntityCode: "S0_ITEM_BOM",
+        SourceTable: "ProductStructureMaster",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_bom",
+        DimTable: "bridge_item_bom",
+        BusinessKeyColumns: ["tenant_id", "item_code", "component_item_code"],
+        SourceBizKeyColumns: ["ParentItem", "ComponentItem"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",              S0DimValueKind.TenantIdColumn),
+            new("item_code",              S0DimValueKind.Str,         "ParentItem",    Required: true),
+            new("component_item_code",    S0DimValueKind.Str,         "ComponentItem", Required: true),
+            new("qty",                    S0DimValueKind.Dec,         "Qty",           Precision: 18, Scale: 5),
+            new("unit",                   S0DimValueKind.Str,         "UM"),
+            new("scrap",                  S0DimValueKind.Dec,         "Scrap",         Precision: 18, Scale: 5),
+            new("qty_consumed",           S0DimValueKind.Dec,         "QtyConsumed",   Precision: 18, Scale: 5),
+            new("qty_exchanged",          S0DimValueKind.Dec,         "QtyExchd",      Precision: 18, Scale: 5),
+            new("refs",                   S0DimValueKind.Str,         "Refs"),
+            new("operation_no",           S0DimValueKind.Int,         "Op"),
+            new("company_id",             S0DimValueKind.Int,         "CompanyRefId",  Required: true),
+            new("factory_id",             S0DimValueKind.Int,         "FactoryRefId",  Required: true),
+            // 🔴 legacy 技术属性,解析时**必须带 tenant_id**(见类型注释的跨租户实证)
+            new("legacy_parent_item_id",    S0DimValueKind.Int,       "ParentMaterialId",    Required: true),
+            new("legacy_component_item_id", S0DimValueKind.Int,       "ComponentMaterialId", Required: true),
+            new("source_updated_at",      S0DimValueKind.DateTimeSec, "UpdatedAt")
+        ]);
+
+    /// <summary>
+    /// 替代料。源 <c>ItemSubstituteDetail</c>,父 = <see cref="Item"/>。
+    ///
+    /// <para><b>身份 = tenant + ParentItem + ItemNum + SubstituteItem</b>(实测重复 0 组)。
+    /// 虽然 <c>tenant+ItemNum+SubstituteItem</c> 当前也是 0 组,但全表仅 <b>16 行</b>,
+    /// 这点样本证明不了 <c>ParentItem</c> 可去;而实测 <c>ParentItem</c> 与 <c>ItemNum</c>
+    /// <b>16 行无一相等</b>(distinct 9 vs 14),是**真实不同的信息**
+    /// —— 去掉即不可逆的键收缩,故保留(同 <see cref="LinePost"/> 的 <c>job_no</c> 先例)。
+    /// <c>Domain</c> 在旧 UK 内,按 D-025 落 LEGACY_SCOPE 不入身份。</para>
+    ///
+    /// <para>三个 item 引用(<c>ParentItem</c> / <c>ItemNum</c> / <c>SubstituteItem</c>)
+    /// **全部统一到 <c>dim_item</c> 的自然键 <c>tenant + item_code</c>**,实测孤儿各 0/16。
+    /// 框架单父,取被替代的 <c>ItemNum</c> 作 <c>item_code</c>(本行的主语是「这个料有哪些替代」);
+    /// 另两个落 <c>parent_item_code</c> / <c>substitute_item_code</c>,同样零孤儿但不设父。</para>
+    /// </summary>
+    public static readonly S0DimDefinition ItemSubstitute = new(
+        Key: "ITEM_SUBSTITUTE",
+        EntityCode: "S0_ITEM_SUBSTITUTE",
+        SourceTable: "ItemSubstituteDetail",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_item_substitute",
+        DimTable: "bridge_item_substitute",
+        BusinessKeyColumns: ["tenant_id", "parent_item_code", "item_code", "substitute_item_code"],
+        SourceBizKeyColumns: ["ParentItem", "ItemNum", "SubstituteItem"],
+        ParentDimTable: "dim_item",
+        ParentKeyColumns: ["item_code"],
+        Columns:
+        [
+            new("tenant_id",            S0DimValueKind.TenantIdColumn),
+            new("parent_item_code",     S0DimValueKind.Str,         "ParentItem",     Required: true),
+            new("item_code",            S0DimValueKind.Str,         "ItemNum",        Required: true),
+            new("substitute_item_code", S0DimValueKind.Str,         "SubstituteItem", Required: true),
+            new("substitute_qty",       S0DimValueKind.Dec,         "SubstituteQty",  Precision: 18, Scale: 6),
+            new("substitute_type",      S0DimValueKind.Str,         "SubstituteType"),
+            new("remark",               S0DimValueKind.Str,         "Remark"),
+            new("domain_code",          S0DimValueKind.Str,         "Domain",         Required: true),
+            new("source_updated_at",    S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
     // ════════════════════════════════════════════════════════════════════════════
     // ════════════════════════════════════════════════════════════════════════════
     // C3 · 关系 / 明细桥接(bridge_*)
     // C3 · 关系 / 明细桥接(bridge_*)
     // ════════════════════════════════════════════════════════════════════════════
     // ════════════════════════════════════════════════════════════════════════════
@@ -872,7 +1240,13 @@ public static class S0DimCatalog
         // C1 扩展:物料(S0 Item 权威;后续 6 张 C3 都要挂它作父,故必须排在 C3 之前)
         // C1 扩展:物料(S0 Item 权威;后续 6 张 C3 都要挂它作父,故必须排在 C3 之前)
         Item,
         Item,
         // C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前)
         // C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前)
-        EmpWorkDuty, LinePost, LinePostSkill
+        EmpWorkDuty, LinePost, LinePostSkill,
+        // C3-A 物料桥(父 dim_item,必须排在 Item 之后)
+        ItemOpLine, ItemRoutingOp,
+        // C3-C 物料桥(父 dim_item)
+        ItemOpCondition, ItemOpImage,
+        // C3-B 物料桥(父 dim_item)
+        ItemBom, ItemSubstitute
     ];
     ];
 
 
     /// <summary>按 Key 取维度(大小写不敏感);不存在返回 null。</summary>
     /// <summary>按 Key 取维度(大小写不敏感);不存在返回 null。</summary>