ソースを参照

feat(s0): 扩展主数据维度到八个,身份口径改按当前业务证据判定

Phase 2 的 Batch 4(Employee / Line)与 Batch 5(Customer / PersonSkill)
在 Phase 1 的通用框架上落地,四个维度全部只新增 S0DimDefinition,零专用服务。

身份口径按「当前业务证据优先」重判,不再继承旧物理唯一键:
- 四个维度身份统一收敛为 tenant_id + 业务码
- Domain / company / factory 一律降为属性(LEGACY_SCOPE),绝不入唯一键
- 关键判据:AdoS0OrgScope.ResolveAsync 仅按租户解析 company/factory 并强制
  stamp,故形如 tenant + FactoryRefId + X 的 CRUD 查重等价于 tenant + X;
  数据侧亦印证 Line/Customer/PersonSkill 的 company/factory 租户内单一取值
- PersonSkill 的消费方早已按 (租户, Code) 解析并把同租户跨厂重码当歧义报错

顺带修掉通用框架两处被新数据形态暴露的错误语义(对所有维度一致生效):
- 孤儿判定把「未建立关系」当成「悬挂引用」:父键为 NULL 表示未声明父,
  不该算孤儿。dim_employee 有 292 行未分配部门,会淹没 97 行真悬挂
- 孤儿告警数字取自被 OrphanSampleLimit 截断的样本条数,97 个会报成 50 个;
  改为单独取不截断的 COUNT,并在超出时标注只外化了多少条样本

另发现 LineMaster.line_describe 是恒空影子列(真列为 Describe),
本仓 dual-column 复刻表由此确认三例,规律一致:PascalCase 为真、
snake_case 存 falsy 值而非 NULL,COALESCE 防御无效。

migration 1.0.506 / 1.0.507,各带结构断言 verify(19 条 / 16 条),
经 AutoVersionUpdate 真路径验证:检测 → 执行 → verify Success → 记账。
YY968XX 2 日 前
コミット
fb02a0eb5c

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.505</AssemblyVersion>
-    <FileVersion>1.0.505</FileVersion>
-    <Version>1.0.505</Version>
+    <AssemblyVersion>1.0.507</AssemblyVersion>
+    <FileVersion>1.0.507</FileVersion>
+    <Version>1.0.507</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -715,6 +715,18 @@
     <None Update="UpdateScripts\1.0.505.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.506.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.506.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.507.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.507.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 180 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.506.sql

@@ -0,0 +1,180 @@
+-- =====================================================================================
+-- 1.0.506  ·  S0 MASTER DATA PLATFORM · PHASE 2 · BATCH 4
+--
+-- 目标:在 Phase 1 已验证的 S0Dim 通用框架上扩展两个 C1 维度,**只加 Definition,不加服务**。
+--   EmployeeMaster → mdp_stg_s0_employee → dim_employee
+--   LineMaster     → mdp_stg_s0_line     → dim_line
+--
+-- 本批边界(与 Phase 1 一致):
+--   · 不建、不改、不删共享表 mdp_stg_location / mdp_stg_employee / mdp_stg_line
+--     (后两张属 DOPDEMORQ,当前 0 行,但不是 S0 的 authority —— 故另建 S0 专用 stg)
+--   · 不写 mdp_source / mdp_field_mapping;不碰 mdp_std_item / mdp_std_supplier
+--   · 不动 S1–S9 既有对象、不动 S0 CRUD、不动前端
+--
+-- 取号:开发期用 WIP 名,提交那刻按 origin/master 实际最大号 +1 改名为 1.0.<n>.sql
+--       (verify 脚本必须同批同号改名,AutoVersionUpdate 用 Path.ChangeExtension 配对)。
+--
+-- 租户契约(P0,实测依据):
+--   · Employee 身份 = tenant_id + Employee   (Domain / company / factory 均为属性,非身份)
+--   · Line     身份 = tenant_id + Line       (Domain / company / factory 均为属性,非身份)
+--
+--   身份口径按「当前业务证据优先」重判(2026-09-07 Semantic Gate),**不继承旧物理唯一键**:
+--     · Employee CRUD 查重 = tenant + FactoryRefId + Employee,不含 Domain;而 FactoryRefId 由
+--       AdoS0OrgScope.ResolveAsync 仅按租户解析并强制 stamp ⇒ 等价于 tenant + Employee。
+--     · Employee 真实 consumer 一律 `ON p.Buyer=e.Employee AND e.tenant_id=p.tenant_id`(5 处),无一处用 Domain。
+--     · Line CRUD 无任何查重守卫;consumer GetLinesAsync 只用 (TenantId, Line)。
+--     · 数据实测:tenant+Employee 租户内重复=0;Line 四个候选租户内重复全部=0,
+--       且每个租户在 LineMaster 只有 1 个 Domain / 1 个 company / 1 个 factory ⇒ 租户内常量、零区分力。
+--   · 租户仍必须入身份:Employee 跨租户碰撞 19 组 / 38 行,Line 20 组 / 80 行;
+--     Domain 与 factory_ref_id=1000 均被多租户复用,都不具备隔离能力。
+-- =====================================================================================
+
+-- ── 一、S0 专用贴源表(2 张)──────────────────────────────────────────────────────
+-- UK 刻意包含 source_system:共享表 mdp_stg_location 的 UK 不含它,导致同名源表在不同源
+-- 之间串键(Phase 1 已实证)。S0 自有表一律补上,即使当前只有一个源。
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_employee` (
+  `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_employee` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_employee_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_employee_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据贴源:雇员(源 EmployeeMaster)';
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_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_line` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_line_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_line_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据贴源:生产线(源 LineMaster)';
+
+-- ── 二、维度表(2 张)────────────────────────────────────────────────────────────
+-- tenant_id 一律 NOT NULL 且为唯一键首列;FULL REPLACE 按 tenant_id 分区删改。
+--
+-- 舍弃的源列(明确记录,避免下次误以为漏了):
+--   EmployeeMaster:Sex / Phone / Email / BirthDate / MaritalStatus / City / Country /
+--                   BusinessPhone / CarId / DefaultWorkLocation —— 个人信息或无主数据语义,
+--                   维度层不承载 PII;CreateUser/CreateTime/UpdateUser/RecID —— 审计列,非主数据属性。
+--   LineMaster:    line_describe(恒空影子列,真列是 Describe)· PickingLocation(0/169 填充) ·
+--                   location3(0/169) · VLocation / MidLocation / location2(各 28/169,
+--                   三个无标准语义的备用库位槽,含义未经业务确认)· CreateUser/CreateTime/UpdateUser/RecID。
+
+CREATE TABLE IF NOT EXISTS `dim_employee` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '技术行号;每轮 FULL 重建即变,禁止外部引用',
+  `tenant_id`         bigint       NOT NULL COMMENT '租户',
+  `company_id`        bigint       NOT NULL COMMENT '← $.company_ref_id;属性,不入身份',
+  `factory_id`        bigint       NOT NULL COMMENT '← $.factory_ref_id;属性,不入身份(1000 被多租户共用,无隔离能力)',
+  `employee_code`     varchar(100) NOT NULL COMMENT '工号(**唯一业务键**)← $.Employee',
+  `domain_code`       varchar(50)  NOT NULL COMMENT 'ATTRIBUTE ← $.Domain;仅供部门父连接与引用校验,**不是身份**',
+  `employee_name`     varchar(200) DEFAULT NULL COMMENT '← $.Name',
+  `department_code`   varchar(100) DEFAULT NULL COMMENT '← $.Department;自由文本软引用 dim_department,无 DB FK;NULL=未分配(不算孤儿)',
+  `job_title`         varchar(100) DEFAULT NULL COMMENT '← $.JobTitle',
+  `work_center_code`  varchar(100) DEFAULT NULL COMMENT '← $.WorkCtr;按业务键引用 dim_work_center,无 DB FK',
+  `employment_status` varchar(50)  DEFAULT NULL COMMENT '← $.EmploymentStatus',
+  `date_employed`     datetime     DEFAULT NULL COMMENT '← $.DateEmployed',
+  `date_terminated`   datetime     DEFAULT NULL COMMENT '← $.DateTerminated',
+  `sys_user_id`       bigint       DEFAULT NULL COMMENT '← $.sys_user_id;稀疏(源 7/1105 非空)。**属性,绝不参与业务键**',
+  `is_active`         tinyint(1)   NOT NULL COMMENT '源 IsActive 透传(raw_data 中为 JSON BOOLEAN)',
+  `source_system`     varchar(50)  NOT NULL,
+  `source_biz_key`    varchar(300) NOT NULL COMMENT '回溯 staging 用',
+  `source_updated_at` datetime     DEFAULT NULL COMMENT '← $.UpdateTime;源 97% 非空,允许 NULL,不得回填假时间',
+  `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`),
+  UNIQUE KEY `uk_dim_employee` (`tenant_id`,`employee_code`),
+  KEY `idx_dim_employee_batch` (`sync_batch_id`),
+  KEY `idx_dim_employee_dept` (`tenant_id`,`domain_code`,`department_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据维度:雇员。源 EmployeeMaster,FULL REPLACE。';
+
+CREATE TABLE IF NOT EXISTS `dim_line` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '技术行号;每轮 FULL 重建即变,禁止外部引用',
+  `tenant_id`         bigint       NOT NULL COMMENT '租户',
+  `company_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.company_ref_id;f(tenant),**不是身份**',
+  `factory_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.factory_ref_id;f(tenant),**不是身份**。⚠️ 绝不可设进 MdpPullContext.FactoryId,见 I-015',
+  `line_code`         varchar(100) NOT NULL COMMENT '产线编码(**唯一业务键**)← $.Line',
+  `domain_code`       varchar(50)  NOT NULL COMMENT 'LEGACY_SCOPE ← $.Domain;租户内常量,**不是身份**',
+  `line_name`         varchar(255) DEFAULT NULL COMMENT '← $.Describe(真列;同表 line_describe 恒空,是影子列)',
+  `line_type`         varchar(50)  DEFAULT NULL COMMENT '← $.LineType',
+  `line_category`     varchar(50)  DEFAULT NULL COMMENT '← $.LineCategory',
+  `workshop`          varchar(50)  DEFAULT NULL COMMENT '← $.Workshop',
+  `location_code`     varchar(50)  DEFAULT NULL COMMENT '← $.Location;按业务键软引用 dim_location,无 DB FK',
+  `is_active`         tinyint(1)   NOT NULL COMMENT '源 IsActive 透传',
+  `source_system`     varchar(50)  NOT NULL,
+  `source_biz_key`    varchar(300) NOT NULL COMMENT '回溯 staging 用',
+  `source_updated_at` datetime     DEFAULT NULL COMMENT '← $.UpdateTime;源 100% 非空',
+  `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`),
+  UNIQUE KEY `uk_dim_line` (`tenant_id`,`line_code`),
+  KEY `idx_dim_line_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据维度:生产线。源 LineMaster,FULL REPLACE。';
+
+-- ── 三、mdp_entity 入站配置(2 条,全 FULL)──────────────────────────────────────
+--
+-- · sync_mode 必须 FULL、incr_column 必须 NULL:Phase 2 既定策略。
+--   即便 Employee UpdateTime 97%、Line 100%,也不切 INCR ——
+--   留 incr_column 会让 BuildSelectSql 生成 "col > @cursor",使水位为 NULL 的行永久不可达
+--   (Employee 实测有 29 行 UpdateTime 为 NULL)。
+-- · biz_key_expr 是【源物理列名】逗号列表,须与 Definition.SourceBizKeyColumns 逐字一致,
+--   否则 S0DimMaterializer.AssertEntityContractAsync 会当场报配置漂移。
+-- · batch_size 必须大于单租户最大行数(Employee 最大 613、Line 最大 109),5000 余量充足。
+
+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_EMPLOYEE', 'S0 雇员主数据入站', 'TABLE',
+       'EmployeeMaster', 'mdp_stg_s0_employee', 'FULL', NULL, 5000,
+       'Employee', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_EMPLOYEE');
+
+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_LINE', 'S0 生产线主数据入站', 'TABLE',
+       'LineMaster', 'mdp_stg_s0_line', 'FULL', NULL, 5000,
+       'Line', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_LINE');
+
+-- ── 回滚(注释,非可执行;执行前请确认无消费者)──────────────────────────────────
+-- DROP TABLE IF EXISTS `dim_line`;
+-- DROP TABLE IF EXISTS `dim_employee`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_line`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_employee`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id` = 0 AND `entity_code` IN ('S0_EMPLOYEE','S0_LINE');

+ 108 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.506.verify.sql

@@ -0,0 +1,108 @@
+-- 1.0.506.verify.sql
+-- 契约(CURRENT.md > F-11,由 AutoVersionUpdate.RunVerifyScriptIfExists 逐条执行取首行首列判真):
+--   ① 禁止 SET @var(返回 null 即判失败)→ 一律内联子查询,每条语句自包含
+--   ② 每条返回「通过为真」的标量(用 = 期望值 得 1/0),不得返回错误条数
+--   ③ 一条语句一个断言,不用 UNION 汇总
+--   ④ 文件以最后一条 SQL 的分号结束,末尾不得残留 comment-only 块
+-- 本文件只做结构级断言:migration 执行时 dim 尚无数据,数据正确性由 S0DimReconciler 在刷新时验证。
+--
+-- 断言强度沿用 Phase 1 Final Review 的补强结论,勿弱化:
+--   · UK 一律断言「精确列序」而非列数(CREATE TABLE IF NOT EXISTS 对已存在表静默 no-op,
+--     只数列数会放过结构漂移)。
+--   · 字符串等值一律加 COLLATE utf8mb4_bin(表默认 utf8mb4_0900_ai_ci 大小写不敏感,
+--     而 biz_key_expr 的大小写正是致命维度:写错即静默退化成 source_row_id)。
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('mdp_stg_s0_employee','mdp_stg_s0_line')) = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('dim_employee','dim_line')) = 2;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_s0_employee'
+                 AND INDEX_NAME = 'uk_stg_s0_employee' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_row_id';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_s0_line'
+                 AND INDEX_NAME = 'uk_stg_s0_line' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_row_id';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employee'
+                 AND INDEX_NAME = 'uk_dim_employee' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,employee_code';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_line'
+                 AND INDEX_NAME = 'uk_dim_line' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,line_code';
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('dim_employee','dim_line')
+          AND COLUMN_NAME = 'tenant_id' AND IS_NULLABLE = 'NO') = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('mdp_stg_s0_employee','mdp_stg_s0_line')
+          AND COLUMN_NAME = 'tenant_id' AND IS_NULLABLE = 'NO') = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('mdp_stg_s0_employee','mdp_stg_s0_line')
+          AND COLUMN_NAME = 'source_biz_key' AND CHARACTER_MAXIMUM_LENGTH = 300) = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employee'
+          AND COLUMN_NAME = 'sys_user_id' AND IS_NULLABLE = 'YES') = 1;
+
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employee'
+          AND NON_UNIQUE = 0 AND COLUMN_NAME = 'sys_user_id') = 0;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employee'
+          AND COLUMN_NAME = 'department_code' AND IS_NULLABLE = 'YES') = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` IN ('S0_EMPLOYEE','S0_LINE')
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL) = 2;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity` e JOIN `mdp_source` s ON s.`id` = e.`source_id`
+        WHERE e.`entity_code` IN ('S0_EMPLOYEE','S0_LINE')
+          AND s.`source_code` = 'AIDOPDEV_MYSQL') = 2;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_EMPLOYEE'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Employee'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'EmployeeMaster'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_employee'
+          AND `batch_size` > 613) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_LINE'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Line'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'LineMaster'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_line'
+          AND `batch_size` > 109) = 1;
+
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employee'
+          AND INDEX_NAME = 'uk_dim_employee'
+          AND COLUMN_NAME IN ('domain_code','company_id','factory_id','sys_user_id')) = 0;
+
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_line'
+          AND INDEX_NAME = 'uk_dim_line'
+          AND COLUMN_NAME IN ('domain_code','company_id','factory_id')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `target_table_name` IN ('mdp_stg_employee','mdp_stg_line','mdp_stg_location')
+          AND `entity_code` LIKE 'S0\_%') = 0;

+ 141 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.507.sql

@@ -0,0 +1,141 @@
+-- =====================================================================================
+-- 1.0.507  ·  S0 MASTER DATA PLATFORM · PHASE 2 · BATCH 5
+--
+--   CustMaster  → mdp_stg_s0_customer     → dim_customer
+--   PersonSkill → mdp_stg_s0_person_skill → dim_person_skill
+--
+-- 身份口径(按「当前业务证据优先」判定,**不继承旧物理唯一键**):
+--   · Customer    身份 = tenant_id + Cust
+--       - CRUD 查重 = tenant + FactoryRefId + Cust,而 FactoryRefId 由 AdoS0OrgScope 仅按租户解析
+--         并强制 stamp(payload 覆盖)⇒ 等价 tenant + Cust;DomainCode 只是可选列表筛选。
+--       - 数据实测:tenant+Cust / +Domain / +factory 三候选租户内重复全部 = 0;
+--         company/factory 每租户单一取值(常量、零区分力)。
+--       - 租户必须入身份:Cust 跨租户碰撞 4 个编码。
+--   · PersonSkill 身份 = tenant_id + Code
+--       - 现有消费方 AdoS0MfgLinePostsController.ResolvePersonSkillCodesAsync 已明确按 (租户, Code)
+--         解析,并把「同租户跨公司/工厂重码」当作歧义**报错**,而非两个不同技能。
+--       - 数据实测:三候选租户内重复全部 = 0;Code 跨租户碰撞 5 个编码。
+--
+-- 🔴 源表 CustMaster 另有不含 tenant_id 的旧唯一键 uk_CustMaster_factory_cust(factory_ref_id, Cust),
+--    且 factory_ref_id=1000 被两租户共用 —— 属源表治理缺陷(KNOWN-ISSUES I-016)。
+--    **本批 NO SOURCE TABLE DDL CHANGE**:不顺手治理它,dim 侧靠自己的 tenant 前缀保证隔离。
+--
+-- 边界:不复用历史 mdp_stg_customer(即便 0 行,它属 DOPDEMORQ/S1,不是 S0 authority);
+--       不碰 mdp_std_item / mdp_std_supplier;不动 S0 CRUD / 前端 / S1–S9 既有对象。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_customer` (
+  `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_customer` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_customer_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_customer_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据贴源:客户(源 CustMaster)';
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_person_skill` (
+  `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_person_skill` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_person_skill_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_person_skill_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据贴源:人员技能字典(源 PersonSkill)';
+
+CREATE TABLE IF NOT EXISTS `dim_customer` (
+  `id`                 bigint       NOT NULL AUTO_INCREMENT COMMENT '技术行号;每轮 FULL 重建即变,禁止外部引用',
+  `tenant_id`          bigint       NOT NULL COMMENT '租户',
+  `customer_code`      varchar(100) NOT NULL COMMENT '客户编码(**唯一业务键**)← $.Cust',
+  `customer_name`      varchar(200) DEFAULT NULL COMMENT 'ATTRIBUTE ← $.SortName(源 1333/1333 填充)',
+  `domain_code`        varchar(50)  DEFAULT NULL COMMENT 'LEGACY_SCOPE ← $.Domain;**不是身份**',
+  `company_id`         bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.company_ref_id;f(tenant),**不是身份**',
+  `factory_id`         bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.factory_ref_id;f(tenant),**不是身份**',
+  `shipping_lead_time` int          DEFAULT NULL COMMENT 'ATTRIBUTE ← $.CustShippingLT(源 1328/1333)',
+  `is_active`          tinyint(1)   NOT NULL COMMENT '源 IsActive 透传',
+  `is_confirmed`       tinyint(1)   NOT NULL COMMENT '源 IsConfirm 透传',
+  `source_system`      varchar(50)  NOT NULL,
+  `source_biz_key`     varchar(300) NOT NULL COMMENT '回溯 staging 用',
+  `source_updated_at`  datetime     DEFAULT NULL COMMENT '← $.UpdateTime;源 1232/1333,允许 NULL',
+  `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`),
+  UNIQUE KEY `uk_dim_customer` (`tenant_id`,`customer_code`),
+  KEY `idx_dim_customer_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据维度:客户。源 CustMaster,FULL REPLACE。';
+
+CREATE TABLE IF NOT EXISTS `dim_person_skill` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '技术行号;每轮 FULL 重建即变,禁止外部引用',
+  `tenant_id`         bigint       NOT NULL COMMENT '租户',
+  `skill_code`        varchar(100) NOT NULL COMMENT '技能编码(**唯一业务键**)← $.Code',
+  `skill_name`        varchar(200) DEFAULT NULL COMMENT 'ATTRIBUTE ← $.Name',
+  `skill_level`       varchar(50)  DEFAULT NULL COMMENT 'ATTRIBUTE ← $.SkillLevel',
+  `remark`            varchar(500) DEFAULT NULL COMMENT 'ATTRIBUTE ← $.Remark',
+  `company_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.CompanyRefId;**不是身份**',
+  `factory_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.FactoryRefId;**不是身份**',
+  `is_active`         tinyint(1)   NOT NULL COMMENT '源 IsEnabled 透传',
+  `source_system`     varchar(50)  NOT NULL,
+  `source_biz_key`    varchar(300) NOT NULL COMMENT '回溯 staging 用',
+  `source_updated_at` datetime     DEFAULT NULL COMMENT '← $.UpdatedAt(维护时间;源 5/46 非空 = 只有改过的行才有值)。不得用 CreatedAt 冒充',
+  `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`),
+  UNIQUE KEY `uk_dim_person_skill` (`tenant_id`,`skill_code`),
+  KEY `idx_dim_person_skill_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 主数据维度:人员技能字典。源 PersonSkill,FULL REPLACE。不设父维度(EmpSkills.SkillNo 与本表 Code 实测 632/650 对不上)。';
+
+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_CUSTOMER', 'S0 客户主数据入站', 'TABLE',
+       'CustMaster', 'mdp_stg_s0_customer', 'FULL', NULL, 5000,
+       'Cust', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_CUSTOMER');
+
+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_PERSON_SKILL', 'S0 人员技能字典入站', 'TABLE',
+       'PersonSkill', 'mdp_stg_s0_person_skill', 'FULL', NULL, 5000,
+       'Code', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_PERSON_SKILL');
+
+-- ── 回滚(注释,非可执行)──────────────────────────────────────────────────────────
+-- DROP TABLE IF EXISTS `dim_person_skill`;
+-- DROP TABLE IF EXISTS `dim_customer`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_person_skill`;
+-- DROP TABLE IF EXISTS `mdp_stg_s0_customer`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id` = 0 AND `entity_code` IN ('S0_CUSTOMER','S0_PERSON_SKILL');

+ 85 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.507.verify.sql

@@ -0,0 +1,85 @@
+-- 1.0.507.verify.sql
+-- 契约见 CURRENT.md > F-11:禁 SET @var · 每条返回 1/0 · 一条一断言 · 末条为真 SELECT。
+-- 断言强度沿用 Phase 1 Final Review 结论:UK 断言精确列序;字符串等值加 COLLATE utf8mb4_bin。
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('mdp_stg_s0_customer','mdp_stg_s0_person_skill')) = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('dim_customer','dim_person_skill')) = 2;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_s0_customer'
+                 AND INDEX_NAME = 'uk_stg_s0_customer' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_row_id';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_stg_s0_person_skill'
+                 AND INDEX_NAME = 'uk_stg_s0_person_skill' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,source_system,source_table,source_row_id';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_customer'
+                 AND INDEX_NAME = 'uk_dim_customer' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,customer_code';
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+               FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_person_skill'
+                 AND INDEX_NAME = 'uk_dim_person_skill' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,skill_code';
+
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_customer'
+          AND INDEX_NAME = 'uk_dim_customer'
+          AND COLUMN_NAME IN ('domain_code','company_id','factory_id')) = 0;
+
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_person_skill'
+          AND INDEX_NAME = 'uk_dim_person_skill'
+          AND COLUMN_NAME IN ('company_id','factory_id')) = 0;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('dim_customer','dim_person_skill')
+          AND COLUMN_NAME = 'tenant_id' AND IS_NULLABLE = 'NO') = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME IN ('mdp_stg_s0_customer','mdp_stg_s0_person_skill')
+          AND COLUMN_NAME = 'tenant_id' AND IS_NULLABLE = 'NO') = 2;
+
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_person_skill'
+          AND COLUMN_NAME = 'source_updated_at' AND IS_NULLABLE = 'YES') = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` IN ('S0_CUSTOMER','S0_PERSON_SKILL')
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL) = 2;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity` e JOIN `mdp_source` s ON s.`id` = e.`source_id`
+        WHERE e.`entity_code` IN ('S0_CUSTOMER','S0_PERSON_SKILL')
+          AND s.`source_code` = 'AIDOPDEV_MYSQL') = 2;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_CUSTOMER'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Cust'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'CustMaster'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_customer'
+          AND `batch_size` > 1267) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_PERSON_SKILL'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Code'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'PersonSkill'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_person_skill'
+          AND `batch_size` > 36) = 1;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `target_table_name` = 'mdp_stg_customer'
+          AND `entity_code` LIKE 'S0\_%') = 0;

+ 212 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Dim/S0DimContractTests.cs

@@ -1,4 +1,5 @@
 using System;
+using System.IO;
 using System.Linq;
 using Admin.NET.Plugin.AiDOP.DataPlatform;
 using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
@@ -22,7 +23,12 @@ public class S0DimContractTests
     public void Catalog_should_be_self_consistent()
     {
         S0DimCatalog.ValidateAll();
-        Assert.Equal(4, S0DimCatalog.All.Count);
+        // 断言内容而非数量:数量断言每加一个维度就要改一次,且改完什么也没守住。
+        // 这里守的是「目录里恰好是这些维度、每个都能被 Find 命中、源系统统一」。
+        Assert.Equal(
+            ["WORK_CENTER", "DEPARTMENT", "LOCATION", "LOCATION_SHELF", "EMPLOYEE", "LINE", "CUSTOMER", "PERSON_SKILL"],
+            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.Equal(S0DimCatalog.LocalSourceSystem, d.SourceSystem));
     }
 
@@ -380,6 +386,210 @@ public class S0DimContractTests
     {
         Assert.Equal(S0DimCatalog.Location.StagingTable, S0DimCatalog.LocationShelf.StagingTable);
         Assert.NotEqual(S0DimCatalog.Location.SourceTable, S0DimCatalog.LocationShelf.SourceTable);
-        Assert.Equal(4, S0DimCatalog.All.Select(d => d.DimTable).Distinct().Count());
+        // 与维度数量解耦:新增维度不该让这条断言失效,它要守的是「dim 表不共用」这个不变量
+        Assert.Equal(S0DimCatalog.All.Count, S0DimCatalog.All.Select(d => d.DimTable).Distinct().Count());
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Phase 2 · Batch 4:EmployeeMaster / LineMaster
+    // ────────────────────────────────────────────────────────────────
+
+    [Fact]
+    public void Employee_identity_must_be_tenant_domain_employee()
+    {
+        var d = S0DimCatalog.Employee;
+        Assert.Equal(["tenant_id", "employee_code"], d.BusinessKeyColumns);
+        Assert.Equal(["Employee"], d.SourceBizKeyColumns);
+        // legacy scope 不得混进身份
+        foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+        Assert.Equal("EmployeeMaster", d.SourceTable);
+        Assert.Equal("mdp_stg_s0_employee", d.StagingTable);
+        Assert.Equal("dim_employee", d.DimTable);
+    }
+
+    [Fact]
+    public void Employee_sys_user_id_must_be_attribute_only_never_business_key()
+    {
+        // 源实测 1105 行中仅 7 行非空(0.6%):把账号 id 当员工身份会让 99.4% 的员工无法标识。
+        var d = S0DimCatalog.Employee;
+        Assert.DoesNotContain("sys_user_id", d.BusinessKeyColumns);
+        Assert.DoesNotContain("sys_user_id", d.SourceBizKeyColumns);
+        Assert.DoesNotContain("sys_user_id", d.ParentKeyColumns!);
+
+        var col = d.Columns.Single(c => c.TargetColumn == "sys_user_id");
+        Assert.False(col.Required);      // 必须可空,否则 1098 行会被 RowGuards 过滤掉
+        Assert.Equal("sys_user_id", col.JsonPath);
+    }
+
+    [Fact]
+    public void Employee_parent_is_department_by_domain_and_department_code()
+    {
+        var d = S0DimCatalog.Employee;
+        Assert.Equal("dim_department", d.ParentDimTable);
+        Assert.Equal(["domain_code", "department_code"], d.ParentKeyColumns);
+        // 父键分量不得 Required:源侧 292 行未分配部门,Required 会把它们整行过滤掉
+        Assert.False(d.Columns.Single(c => c.TargetColumn == "department_code").Required);
+        // 依赖顺序:父必须排在子之前,否则孤儿判定时父维度还没就绪
+        Assert.True(S0DimCatalog.All.ToList().IndexOf(S0DimCatalog.Department)
+                  < S0DimCatalog.All.ToList().IndexOf(d));
+    }
+
+    [Fact]
+    public void Orphan_sql_must_not_report_unassigned_parent_key_as_orphan()
+    {
+        // 「未分配部门」(department_code IS NULL) 不是孤儿,「部门码找不到」才是。
+        // <=> 是 NULL 安全等值,而 dim_department.department_code 为 NOT NULL,
+        // 不加 IS NOT NULL 过滤会把 292 行未分配全部误报成孤儿,淹没 97 个真悬挂。
+        foreach (var sql in new[]
+                 {
+                     S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.Employee, 10),
+                     S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee)
+                 })
+        {
+            Assert.Contains("d.`domain_code` IS NOT NULL", sql, Ord);
+            Assert.Contains("d.`department_code` IS NOT NULL", sql, Ord);
+            Assert.Contains("p.`id` IS NULL", sql, Ord);
+            Assert.Contains("d.`tenant_id` = @TenantId", sql, Ord);
+        }
+    }
+
+    [Fact]
+    public void Orphan_count_sql_must_not_be_truncated_by_sample_limit()
+    {
+        // 告警数字必须来自不截断的 COUNT,而不是被 OrphanSampleLimit 砍过的样本条数。
+        var count = S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee);
+        Assert.Contains("SELECT COUNT(*)", count, Ord);
+        Assert.DoesNotContain("LIMIT", count, Ord);
+    }
+
+    [Fact]
+    public void Line_identity_must_be_tenant_and_line_only_no_legacy_scope()
+    {
+        // 按「当前业务证据优先」重判:CRUD 无查重、consumer 只用 (TenantId, Line)、
+        // 且每租户在 LineMaster 只有 1 个 Domain/company/factory(租户内常量,零区分力)。
+        // 旧物理 UK 含 company+factory,属 legacy scope,不予继承。
+        var d = S0DimCatalog.Line;
+        Assert.Equal(["tenant_id", "line_code"], d.BusinessKeyColumns);
+        Assert.Equal(["Line"], d.SourceBizKeyColumns);
+        foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+        Assert.Null(d.ParentDimTable);
+    }
+
+    [Fact]
+    public void Line_must_read_real_describe_column_not_shadow()
+    {
+        // Describe 169/169 填充;line_describe 0/169——本仓第三例恒空影子列。
+        var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Line);
+        Assert.Contains("$.Describe", sql, Ord);
+        Assert.DoesNotContain("$.line_describe", sql, Ord);
+        Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`Describe`",
+            S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
+        Assert.DoesNotContain($"{S0DimSqlBuilder.SrcAlias}.`line_describe`",
+            S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
+    }
+
+    [Theory]
+    [InlineData("EMPLOYEE")]
+    [InlineData("LINE")]
+    [InlineData("CUSTOMER")]
+    [InlineData("PERSON_SKILL")]
+    public void New_dims_must_carry_tenant_predicate_and_no_upsert(string key)
+    {
+        var d = S0DimCatalog.Find(key)!;
+        var insert = S0DimSqlBuilder.BuildInsertSql(d);
+        Assert.Contains("s.`tenant_id` = @TenantId", insert, Ord);
+        Assert.Contains("s.`source_system` = @SourceSystem", insert, Ord);
+        Assert.Contains("s.`source_table` = @SourceTable", insert, Ord);
+        Assert.Contains("s.`sync_batch_id` = @BatchId", insert, Ord);
+        Assert.DoesNotContain("ON DUPLICATE KEY", insert, Ord);
+
+        var purge = S0DimSqlBuilder.BuildPurgeStagingSql(d);
+        Assert.Contains("s.`tenant_id` = @TenantId", purge, Ord);
+        Assert.DoesNotContain("sync_batch_id", purge, Ord);
+    }
+
+    [Fact]
+    public void PullContext_must_never_set_FactoryId_or_CompanyId()
+    {
+        // I-015:MdpStagingWriter 只识别 factory_id / FactoryId 两个列名。
+        // LineMaster / EmployeeMaster 用的是 factory_ref_id → 解析为 null →
+        // 一旦 ctx.FactoryId > 0,比较基准退化成 1,整批行被 `return 0` 静默丢弃。
+        // 因此 S0 的 pullCtx 必须只按租户取范围,company/factory 只能是源侧身份字段。
+        var src = File.ReadAllText(FindSourceFile("S0DimMaterializer.cs"));
+        var ctxBlock = src[src.IndexOf("new MdpPullContext", Ord)..];
+        ctxBlock = ctxBlock[..ctxBlock.IndexOf("};", Ord)];
+
+        Assert.DoesNotContain("FactoryId", ctxBlock, Ord);
+        Assert.DoesNotContain("CompanyId", ctxBlock, Ord);
+        Assert.Contains("TenantId = tenantId", ctxBlock, Ord);
+        Assert.Contains("RequireMatchingSourceTenant = true", ctxBlock, Ord);
+    }
+
+    private static string FindSourceFile(string fileName)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(dir.FullName,
+                "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName);
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException($"未找到 {fileName}");
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Phase 2 · Batch 5:CustMaster / PersonSkill
+    // ────────────────────────────────────────────────────────────────
+
+    [Theory]
+    [InlineData("CUSTOMER", "customer_code", "Cust")]
+    [InlineData("PERSON_SKILL", "skill_code", "Code")]
+    public void Batch5_identity_must_be_tenant_plus_code_only(string key, string dimCol, string srcCol)
+    {
+        // 身份按当前业务证据判定,不继承旧物理唯一键:
+        //  · Customer   CRUD 查重 = tenant + FactoryRefId(=f(tenant)) + Cust ⇒ 等价 tenant + Cust
+        //  · PersonSkill 现有消费方已按 (租户, Code) 解析,并把同租户跨厂重码当歧义报错
+        var d = S0DimCatalog.Find(key)!;
+        Assert.Equal(["tenant_id", dimCol], d.BusinessKeyColumns);
+        Assert.Equal([srcCol], d.SourceBizKeyColumns);
+        foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+    }
+
+    [Fact]
+    public void PersonSkill_must_not_declare_fake_parent_to_employee_or_skill_bridge()
+    {
+        // EmpSkills.SkillNo 与 PersonSkill.Code 同租户实测 650 行中 632 行对不上,
+        // 不能因为都叫 Skill 就建立虚假父子关系。
+        Assert.Null(S0DimCatalog.PersonSkill.ParentDimTable);
+        Assert.Null(S0DimCatalog.PersonSkill.ParentKeyColumns);
+    }
+
+    [Fact]
+    public void PersonSkill_watermark_must_map_updated_at_not_created_at()
+    {
+        // UpdatedAt 是维护时间(源 5/46 非空 = 只有改过的行才有值);
+        // 不得用 CreatedAt 冒充「最近更新」,那会让每行都显得刚被维护过。
+        var col = S0DimCatalog.PersonSkill.Columns.Single(c => c.TargetColumn == "source_updated_at");
+        Assert.Equal("UpdatedAt", col.JsonPath);
+        Assert.False(col.Required);
+        Assert.DoesNotContain("CreatedAt",
+            S0DimCatalog.PersonSkill.Columns.Select(c => c.JsonPath ?? ""));
+    }
+
+    [Fact]
+    public void Batch4_must_not_reuse_dopdemorq_staging_tables()
+    {
+        // mdp_stg_employee / mdp_stg_line 属 DOPDEMORQ(当前 0 行),不是 S0 的 authority
+        foreach (var d in S0DimCatalog.All)
+        {
+            Assert.NotEqual("mdp_stg_employee", d.StagingTable);
+            Assert.NotEqual("mdp_stg_line", d.StagingTable);
+            Assert.NotEqual("mdp_stg_location", d.StagingTable);
+            Assert.NotEqual("mdp_stg_customer", d.StagingTable);
+        }
     }
 }

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

@@ -140,8 +140,227 @@ public static class S0DimCatalog
             new("source_updated_at", S0DimValueKind.DateTimeSec, "update_time")
         ]);
 
+    /// <summary>
+    /// 雇员。源 <c>EmployeeMaster</c>(29 列,无影子列,2026-09-07 实测确认)。
+    ///
+    /// <para><b>身份 = tenant_id + Employee</b>。<c>Domain</c> 是属性,<b>不是</b>身份 ——
+    /// 这条口径不是从旧物理唯一键推的,而是按「当前业务证据优先」重判的结果(2026-09-07):</para>
+    /// <list type="number">
+    ///   <item>当前 CRUD 查重是 <c>tenant + FactoryRefId + Employee</c>
+    ///         (<c>AdoS0EmployeesController</c>),**根本不含 Domain**;</item>
+    ///   <item>其中 <c>FactoryRefId</c> 由 <c>AdoS0OrgScope.ResolveAsync(ctx, tenantId)</c>
+    ///         **仅按租户**解析并强制 stamp(payload 值一律覆盖),是 tenant 的函数、零区分力,
+    ///         故该查重等价于 <c>tenant + Employee</c>;</item>
+    ///   <item>单条定位走 <c>ByIdScopedAsync(id, tenantId)</c>,不经 Domain;</item>
+    ///   <item>真实 consumer 一致按工号关联:<c>LEFT JOIN EmployeeMaster e
+    ///         ON p.Buyer = e.Employee AND e.tenant_id = p.tenant_id</c>
+    ///         (DemandOrderService / OutsourceOrderService / ProcessOutsourceOrderService 共 5 处),
+    ///         **无一处用 Domain**;</item>
+    ///   <item>数据实测:<c>(tenant_id, Employee)</c> 租户内重复 = <b>0</b>,
+    ///         且不存在「同租户同工号不同 Domain」的行。</item>
+    /// </list>
+    /// <para>租户仍必须入身份:<c>Employee</c> 跨租户碰撞实测 19 组 / 38 行
+    /// (<c>G02795</c> / <c>ZHR01</c> / <c>RQTJ993</c> 等工号被多个租户各自使用)。
+    /// 旧物理唯一键 <c>uk_EmployeeMaster_domain_employee</c> 含 Domain,属 legacy scope,
+    /// 按 Owner 口径「不因存在于旧表/旧 UK 就自动成为 DIM identity」,**不予继承**。</para>
+    ///
+    /// <para>🔴 <c>sys_user_id</c> <b>只是属性,绝不参与业务键</b>:实测 1105 行中仅 <b>7 行</b>非空(0.6%),
+    /// 它是「员工恰好也有系统账号」的可选链接,不是员工身份。把账号 id 当员工主键会让 99.4% 的员工无法标识。</para>
+    ///
+    /// <para>字段适用性分类:<c>employee_code</c>=CORE;<c>domain_code</c>=ATTRIBUTE
+    /// (仍被部门父连接与 CRUD 的部门引用校验 <c>DepartmentExistsInTenantAsync(tenantId, domain, dept)</c> 使用);
+    /// <c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE(f(tenant),保留仅为可追溯到 SysOrg 节点);
+    /// <c>sys_user_id</c>=可选 ATTRIBUTE,永远不入键。</para>
+    /// </summary>
+    public static readonly S0DimDefinition Employee = new(
+        Key: "EMPLOYEE",
+        EntityCode: "S0_EMPLOYEE",
+        SourceTable: "EmployeeMaster",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_employee",
+        DimTable: "dim_employee",
+        BusinessKeyColumns: ["tenant_id", "employee_code"],
+        SourceBizKeyColumns: ["Employee"],
+        ParentDimTable: "dim_department",
+        ParentKeyColumns: ["domain_code", "department_code"],
+        Columns:
+        [
+            new("tenant_id",          S0DimValueKind.TenantIdColumn),
+            new("company_id",         S0DimValueKind.Int,         "company_ref_id", Required: true),
+            new("factory_id",         S0DimValueKind.Int,         "factory_ref_id", Required: true),
+            new("domain_code",        S0DimValueKind.Str,         "Domain",         Required: true),
+            new("employee_code",      S0DimValueKind.Str,         "Employee",       Required: true),
+            new("employee_name",      S0DimValueKind.Str,         "Name"),
+            // 自由文本软引用:源 813 非空中约 97 在 DepartmentMaster 找不到,另 292 行未分配。
+            // 未分配(NULL)不算孤儿,见 S0DimSqlBuilder.OrphanFromWhere。
+            new("department_code",    S0DimValueKind.Str,         "Department"),
+            new("job_title",          S0DimValueKind.Str,         "JobTitle"),
+            new("work_center_code",   S0DimValueKind.Str,         "WorkCtr"),
+            new("employment_status",  S0DimValueKind.Str,         "EmploymentStatus"),
+            new("date_employed",      S0DimValueKind.DateTimeSec, "DateEmployed"),
+            new("date_terminated",    S0DimValueKind.DateTimeSec, "DateTerminated"),
+            // 稀疏(7/1105)。允许 NULL 透传,不得回填,也不得据此过滤行。
+            new("sys_user_id",        S0DimValueKind.Int,         "sys_user_id"),
+            new("is_active",          S0DimValueKind.BoolTrue,    "IsActive"),
+            new("source_updated_at",  S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
+    /// <summary>
+    /// 生产线。源 <c>LineMaster</c>。
+    ///
+    /// <para><b>身份 = tenant_id + Line</b>。company / factory / Domain 全是属性,<b>不是</b>身份 ——
+    /// 同样按「当前业务证据优先」重判(2026-09-07),旧物理唯一键
+    /// <c>uk_LineMaster_scope_line(tenant_id, company_ref_id, factory_ref_id, Line)</c> 不予继承:</para>
+    /// <list type="number">
+    ///   <item>当前 CRUD <b>没有任何查重守卫</b>(<c>AdoS0MfgProductionLinesController</c> 无
+    ///         <c>IsAnyAsync</c>/<c>DuplicateCode</c>),单条定位走 <c>ByIdScopedAsync(id, tenantId)</c>;</item>
+    ///   <item><c>CompanyRefId</c>/<c>FactoryRefId</c> 在控制器里只是**可选查询筛选**
+    ///         (<c>WhereIF(q.CompanyRefId.HasValue, ...)</c>),创建时由
+    ///         <c>AdoS0OrgScope.ResolveAsync</c> 按租户强制 stamp,不是用户输入;</item>
+    ///   <item>真实 consumer <c>S8MasterDataAdapter.GetLinesAsync</c> 是
+    ///         <c>Where(x =&gt; x.TenantId == scope.TenantId).Select(code = x.Line)</c> —— 只有租户 + 产线码;</item>
+    ///   <item>数据实测:四个候选(tenant+Line / +Domain / +factory / +company+factory)
+    ///         租户内重复<b>全部为 0</b>;且**每个租户在本表只有 1 个 Domain、1 个 company、1 个 factory**
+    ///         ⇒ 这三列在租户内是常量,<b>零区分力</b>。</item>
+    /// </list>
+    /// <para>租户仍必须入身份:<c>Line</c> 跨租户碰撞实测 20 组 / 80 行(如 <c>AE01</c> 在 4 个租户都存在)。</para>
+    ///
+    /// <para>字段适用性分类:<c>line_code</c>=CORE;<c>line_name</c>/<c>line_type</c>/<c>line_category</c>/
+    /// <c>workshop</c>/<c>location_code</c>/<c>is_active</c>=ATTRIBUTE;
+    /// <c>domain_code</c>/<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE(租户内常量,保留仅为可追溯)。</para>
+    ///
+    /// <para>🔴 <c>Describe</c> 是真列(169/169 填充、63 distinct);同表的 <c>line_describe</c>
+    /// 是<b>恒空影子列</b>(0/169 填充),取错即静默得到全空的产线名称。
+    /// 这是本仓第三例此型双列表(前两例:LocationMaster、StdOpMaster)。</para>
+    ///
+    /// <para>⚠️ <c>factory_ref_id</c> 参与身份,但**绝不能**设进 <c>MdpPullContext.FactoryId</c>:
+    /// <c>MdpStagingWriter</c> 只识别 <c>factory_id</c>/<c>FactoryId</c> 两个列名,
+    /// 本表叫 <c>factory_ref_id</c> → 解析为 null → 一旦 <c>ctx.FactoryId &gt; 0</c>,
+    /// 比较基准会退化成 1 并让**整批行全部被跳过**(KNOWN-ISSUES I-015)。
+    /// S0 的 pullCtx 只设 TenantId,该分支永不进入。</para>
+    /// </summary>
+    public static readonly S0DimDefinition Line = new(
+        Key: "LINE",
+        EntityCode: "S0_LINE",
+        SourceTable: "LineMaster",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_line",
+        DimTable: "dim_line",
+        BusinessKeyColumns: ["tenant_id", "line_code"],
+        SourceBizKeyColumns: ["Line"],
+        Columns:
+        [
+            new("tenant_id",         S0DimValueKind.TenantIdColumn),
+            new("line_code",         S0DimValueKind.Str,         "Line",           Required: true),
+            new("company_id",        S0DimValueKind.Int,         "company_ref_id", Required: true),
+            new("factory_id",        S0DimValueKind.Int,         "factory_ref_id", Required: true),
+            new("domain_code",       S0DimValueKind.Str,         "Domain",         Required: true),
+            new("line_name",         S0DimValueKind.Str,         "Describe"),
+            new("line_type",         S0DimValueKind.Str,         "LineType"),
+            new("line_category",     S0DimValueKind.Str,         "LineCategory"),
+            new("workshop",          S0DimValueKind.Str,         "Workshop"),
+            new("location_code",     S0DimValueKind.Str,         "Location"),
+            new("is_active",         S0DimValueKind.BoolTrue,    "IsActive"),
+            new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
+    /// <summary>
+    /// 客户。源 <c>CustMaster</c>(32 列,无影子列对;空列极多,只物化有语义且有数据的)。
+    ///
+    /// <para><b>身份 = tenant_id + Cust</b>。同样按「当前业务证据优先」重判:</para>
+    /// <list type="number">
+    ///   <item>CRUD 查重 <c>tenant + FactoryRefId + Cust</c>(<c>AdoS0CustomersController:70,117</c>),
+    ///         而 <c>FactoryRefId</c> 由 <c>AdoS0OrgScope</c> 仅按租户解析并强制 stamp ⇒ 等价 <c>tenant + Cust</c>;</item>
+    ///   <item><c>DomainCode</c> 在控制器里只是**可选列表筛选**(<c>WhereIF(...)</c>),不参与查重;</item>
+    ///   <item>单条定位 <c>ByIdScopedAsync(id, tenantId)</c>;</item>
+    ///   <item>数据实测:三个候选(tenant+Cust / +Domain / +factory)租户内重复**全部为 0**,
+    ///         且 company/factory 在每个租户内均为**单一取值**(常量、零区分力)。</item>
+    /// </list>
+    /// <para>租户必须入身份:<c>Cust</c> 跨租户碰撞实测 <b>4 个编码</b>。</para>
+    ///
+    /// <para>🔴 源表另有一条 <b>不含 tenant_id</b> 的旧唯一键 <c>uk_CustMaster_factory_cust(factory_ref_id, Cust)</c>,
+    /// 且 <c>factory_ref_id=1000</c> 被两个租户共用 —— 属源表治理缺陷(<c>KNOWN-ISSUES I-016</c>),
+    /// **它不能反过来决定 DIM identity**;dim 侧自带 <c>tenant_id</c> 前缀,不受其影响。
+    /// 本批不动源表 DDL。</para>
+    ///
+    /// <para>字段适用性:<c>customer_code</c>=CORE;<c>customer_name</c>/<c>is_active</c>/
+    /// <c>is_confirmed</c>/<c>shipping_lead_time</c>=ATTRIBUTE;
+    /// <c>domain_code</c>/<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE。
+    /// 全空列(address / contact / Curr / Salesperson1 / Terms / cust_class / shipping_address 等 13 列,
+    /// 填充率 0/1333)与近空列(Class 7 · TaxClass 3 · CustFullName 2 · Remarks 1)
+    /// = DROP_FROM_STANDARD,不物化。</para>
+    /// </summary>
+    public static readonly S0DimDefinition Customer = new(
+        Key: "CUSTOMER",
+        EntityCode: "S0_CUSTOMER",
+        SourceTable: "CustMaster",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_customer",
+        DimTable: "dim_customer",
+        BusinessKeyColumns: ["tenant_id", "customer_code"],
+        SourceBizKeyColumns: ["Cust"],
+        Columns:
+        [
+            new("tenant_id",          S0DimValueKind.TenantIdColumn),
+            new("customer_code",      S0DimValueKind.Str,         "Cust",           Required: true),
+            new("customer_name",      S0DimValueKind.Str,         "SortName"),
+            new("domain_code",        S0DimValueKind.Str,         "Domain"),
+            new("company_id",         S0DimValueKind.Int,         "company_ref_id", Required: true),
+            new("factory_id",         S0DimValueKind.Int,         "factory_ref_id", Required: true),
+            new("shipping_lead_time", S0DimValueKind.Int,         "CustShippingLT"),
+            new("is_active",          S0DimValueKind.BoolTrue,    "IsActive"),
+            new("is_confirmed",       S0DimValueKind.BoolTrue,    "IsConfirm"),
+            new("source_updated_at",  S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
+    /// <summary>
+    /// 人员技能字典。源 <c>PersonSkill</c>(11 列)—— 是**技能主数据**,不是关系表。
+    ///
+    /// <para><b>身份 = tenant_id + Code</b>。最强的一条证据来自现有消费方的注释与实现
+    /// (<c>AdoS0MfgLinePostsController.ResolvePersonSkillCodesAsync</c>):
+    /// 「PersonSkill 表没有 Domain 列,故不按 Domain 收窄;产线岗位主表也不带
+    /// CompanyRefId / FactoryRefId,无法据此再收窄,**因此按 (租户, Code) 解析**并对多义显式报错」——
+    /// 即当前业务**已经**把技能码当作租户级字典,并把「同租户跨公司/工厂重码」视为
+    /// <b>歧义错误</b>而非两个不同技能。</para>
+    /// <para>数据佐证:三个候选(tenant+Code / +Factory / +Company+Factory)租户内重复全部为 0,
+    /// company/factory 每租户单一取值。<c>Code</c> 跨租户碰撞 5 个编码 ⇒ 租户必须入身份。</para>
+    ///
+    /// <para>⚠️ <b>不设父维度</b>:<c>EmpSkills.SkillNo</c> 与本表 <c>Code</c> 同租户实测
+    /// 650 行中 <b>632 行对不上</b>(仅 18 命中)—— 两者当前是各自独立的编码体系。
+    /// **不因为都叫 Skill 就建立虚假父子关系。**</para>
+    ///
+    /// <para><c>source_updated_at ← UpdatedAt</c>:源 46 行中 <c>CreatedAt</c> 全非空、
+    /// <c>UpdatedAt</c> 仅 5 行非空 —— 这正是「维护时间」的形态(只有被改过的行才有值),
+    /// 故映射之,NULL 表示自建以来未修改。**不用 CreatedAt 冒充,也不回填。**</para>
+    ///
+    /// <para>字段适用性:<c>skill_code</c>=CORE;<c>skill_name</c>/<c>skill_level</c>/
+    /// <c>remark</c>/<c>is_active</c>=ATTRIBUTE;<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE。</para>
+    /// </summary>
+    public static readonly S0DimDefinition PersonSkill = new(
+        Key: "PERSON_SKILL",
+        EntityCode: "S0_PERSON_SKILL",
+        SourceTable: "PersonSkill",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_person_skill",
+        DimTable: "dim_person_skill",
+        BusinessKeyColumns: ["tenant_id", "skill_code"],
+        SourceBizKeyColumns: ["Code"],
+        Columns:
+        [
+            new("tenant_id",         S0DimValueKind.TenantIdColumn),
+            new("skill_code",        S0DimValueKind.Str,         "Code",         Required: true),
+            new("skill_name",        S0DimValueKind.Str,         "Name"),
+            new("skill_level",       S0DimValueKind.Str,         "SkillLevel"),
+            new("remark",            S0DimValueKind.Str,         "Remark"),
+            new("company_id",        S0DimValueKind.Int,         "CompanyRefId", Required: true),
+            new("factory_id",        S0DimValueKind.Int,         "FactoryRefId", Required: true),
+            new("is_active",         S0DimValueKind.BoolTrue,    "IsEnabled"),
+            new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdatedAt")
+        ]);
+
     /// <summary>全部维度,按依赖顺序(父在前,子在后 —— 孤儿判定需要父维度先就绪)。</summary>
-    public static readonly IReadOnlyList<S0DimDefinition> All = [WorkCenter, Department, Location, LocationShelf];
+    public static readonly IReadOnlyList<S0DimDefinition> All =
+        [WorkCenter, Department, Location, LocationShelf, Employee, Line, Customer, PersonSkill];
 
     /// <summary>按 Key 取维度(大小写不敏感);不存在返回 null。</summary>
     public static S0DimDefinition? Find(string? key) =>

+ 12 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimReconciler.cs

@@ -142,6 +142,11 @@ public sealed class S0DimReconciler : ITransient
         // ── 父子完整性(WARN,不阻断:dim 不得对源撒谎,也不静默丢行)
         if (def.ParentDimTable is not null)
         {
+            // 真实总数与样本分开取:OrphanChildren 受 OrphanSampleLimit 截断,
+            // 直接拿它的 Count 当告警数字会把「97 个孤儿」说成「50 个」——
+            // 静默截断比不报还糟,因为读者会以为那就是全部。
+            r.OrphanChildCount = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildOrphanChildCountSql(def), ps);
+
             var rows = await _db.Ado.GetDataTableAsync(
                 S0DimSqlBuilder.BuildOrphanChildSql(def, OrphanSampleLimit), ps);
             foreach (System.Data.DataRow row in rows.Rows)
@@ -151,8 +156,13 @@ public sealed class S0DimReconciler : ITransient
                     parts.Add(row[c] == DBNull.Value ? "" : row[c].ToString() ?? "");
                 r.OrphanChildren.Add(string.Join(S0DimSqlBuilder.BizKeySeparator, parts));
             }
-            if (r.OrphanChildren.Count > 0)
-                r.Warnings.Add($"{r.OrphanChildren.Count} 行子维度在父维度 {def.ParentDimTable} 中无父行(保留并外化,不丢弃)");
+            if (r.OrphanChildCount > 0)
+            {
+                var truncated = r.OrphanChildCount > r.OrphanChildren.Count
+                    ? $"(仅外化前 {r.OrphanChildren.Count} 条样本)" : "";
+                r.Warnings.Add(
+                    $"{r.OrphanChildCount} 行子维度在父维度 {def.ParentDimTable} 中无父行(保留并外化,不丢弃){truncated}");
+            }
         }
 
         return r;

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimResults.cs

@@ -84,7 +84,13 @@ public sealed class S0DimReconcileResult
     /// <summary>镜像唯一性违例组数(未声明时为 -1 表示不适用)。</summary>
     public int MirrorUniqueViolations { get; set; } = -1;
 
-    /// <summary>孤儿子行业务键(WARN,不阻断;未声明父维度时为空)。</summary>
+    /// <summary>
+    /// 孤儿子行**真实总数**(WARN,不阻断;未声明父维度时为 0)。
+    /// 与 <see cref="OrphanChildren"/> 不同:后者受 <c>OrphanSampleLimit</c> 截断,只是样本。
+    /// </summary>
+    public int OrphanChildCount { get; set; }
+
+    /// <summary>孤儿子行业务键**样本**(最多 <c>OrphanSampleLimit</c> 条)。总数看 <see cref="OrphanChildCount"/>。</summary>
     public List<string> OrphanChildren { get; set; } = [];
 
     /// <summary>阻断级失败原因(为空表示通过)。</summary>

+ 35 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimSqlBuilder.cs

@@ -272,18 +272,44 @@ public static class S0DimSqlBuilder
     }
 
     /// <summary>孤儿子行(父维度中找不到父行)。参数:@TenantId</summary>
-    public static string BuildOrphanChildSql(S0DimDefinition def, int limit)
+    /// <summary>
+    /// 孤儿判定的公共 FROM/WHERE —— 样本查询与计数查询**共用同一份**,避免两处口径漂移。
+    ///
+    /// 「未建立关系」不是孤儿:父键列为 NULL 表示该行压根没声明父(如员工未分配部门),
+    /// 与「声明了父但父不存在」(真悬挂)是两种语义。<c>&lt;=&gt;</c> 是 NULL 安全等值,
+    /// 而父维度的业务键列均为 NOT NULL,故不加 <c>IS NOT NULL</c> 过滤会把全部未分配行
+    /// 误报成孤儿,淹没真正的悬挂引用。对父键全部 Required 的维度(如 LocationShelf)本条件恒真,行为不变。
+    /// </summary>
+    private static string OrphanFromWhere(S0DimDefinition def)
     {
-        if (def.ParentDimTable is null || def.ParentKeyColumns is not { Count: > 0 })
-            throw new InvalidOperationException($"[{def.Key}] 未声明父维度");
-        var on = string.Join(" AND ", def.ParentKeyColumns.Select(c => $"p.{Q(c)} <=> {DimAlias}.{Q(c)}"));
-        var sel = string.Join(", ", def.BusinessKeyColumns.Skip(1).Select(c => $"{DimAlias}.{Q(c)}"));
+        var on = string.Join(" AND ", def.ParentKeyColumns!.Select(c => $"p.{Q(c)} <=> {DimAlias}.{Q(c)}"));
+        var declared = string.Join(" AND ", def.ParentKeyColumns!.Select(c => $"{DimAlias}.{Q(c)} IS NOT NULL"));
         return $"""
-                SELECT {sel} FROM {Q(def.DimTable)} {DimAlias}
-                LEFT JOIN {Q(def.ParentDimTable)} p
+                FROM {Q(def.DimTable)} {DimAlias}
+                LEFT JOIN {Q(def.ParentDimTable!)} p
                   ON p.{Q(S0DimDefinition.TenantColumn)} = {DimAlias}.{Q(S0DimDefinition.TenantColumn)} AND {on}
-                WHERE {DimAlias}.{Q(S0DimDefinition.TenantColumn)} = @TenantId AND p.`id` IS NULL
-                LIMIT {limit}
+                WHERE {DimAlias}.{Q(S0DimDefinition.TenantColumn)} = @TenantId AND {declared} AND p.`id` IS NULL
                 """;
     }
+
+    /// <summary>孤儿子行**样本**(受 limit 截断)。参数:@TenantId</summary>
+    public static string BuildOrphanChildSql(S0DimDefinition def, int limit)
+    {
+        AssertParentDeclared(def);
+        var sel = string.Join(", ", def.BusinessKeyColumns.Skip(1).Select(c => $"{DimAlias}.{Q(c)}"));
+        return $"SELECT {sel} {OrphanFromWhere(def)} LIMIT {limit}";
+    }
+
+    /// <summary>孤儿子行**真实总数**(不截断)。参数:@TenantId</summary>
+    public static string BuildOrphanChildCountSql(S0DimDefinition def)
+    {
+        AssertParentDeclared(def);
+        return $"SELECT COUNT(*) {OrphanFromWhere(def)}";
+    }
+
+    private static void AssertParentDeclared(S0DimDefinition def)
+    {
+        if (def.ParentDimTable is null || def.ParentKeyColumns is not { Count: > 0 })
+            throw new InvalidOperationException($"[{def.Key}] 未声明父维度");
+    }
 }