namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim; /// /// S0 主数据维度清单 —— **本模块唯一的业务知识落点**。 /// /// 新增一个维度 = 在此追加一个 , /// 不得在 Materializer / Reconciler 中出现按 Key 分支的逻辑。 /// /// 🔴 JsonPath 一律是**源表物理列名**,不是实体属性名。改动前先核对 SHOW CREATE TABLE。 /// public static class S0DimCatalog { /// 本库样板源(mdp_source.source_code),入站为只读 SELECT。 public const string LocalSourceSystem = "AIDOPDEV_MYSQL"; /// /// 工作中心。源 WorkCtrMaster(无 company/factory 列,故 dim 也不设 —— 不为统一而发明数据)。 /// public static readonly S0DimDefinition WorkCenter = new( Key: "WORK_CENTER", EntityCode: "S0_WORK_CENTER", SourceTable: "WorkCtrMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_work_center", DimTable: "dim_work_center", BusinessKeyColumns: ["tenant_id", "domain_code", "work_center_code"], SourceBizKeyColumns: ["Domain", "WorkCtr"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("domain_code", S0DimValueKind.Str, "Domain", Required: true), new("work_center_code", S0DimValueKind.Str, "WorkCtr", Required: true), new("work_center_name", S0DimValueKind.Str, "Descr"), new("department_code", S0DimValueKind.Str, "Department"), new("is_active", S0DimValueKind.BoolTrue, "IsActive"), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 部门。源 DepartmentMaster。 /// company/factory 仅作属性透传:实测同一 (tenant, Domain) 下 factory 同时存在 legacy 数值与 SysOrg 雪花两种值, /// 与身份正交,本阶段不做组织域治理。 /// public static readonly S0DimDefinition Department = new( Key: "DEPARTMENT", EntityCode: "S0_DEPARTMENT", SourceTable: "DepartmentMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_department", DimTable: "dim_department", BusinessKeyColumns: ["tenant_id", "domain_code", "department_code"], SourceBizKeyColumns: ["Domain", "Department"], 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("department_code", S0DimValueKind.Str, "Department", Required: true), new("department_name", S0DimValueKind.Str, "Descr"), new("is_active", S0DimValueKind.BoolTrue, "IsActive"), // 源侧 238/239 非空:UATDEMO 的「未分配」行 UpdateTime 恒 NULL。 // 本 definition 走 FULL(mdp_entity.incr_column = NULL),不生成 "UpdateTime > cursor" 谓词, // 因此该行每轮都能进 stg 与 dim;切 INCR 前必须先落 keyset NullTimePhase 两阶段。 new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 库位。源 LocationMaster —— **dual-column 复刻表**。 /// /// 🔴 以下 JsonPath 全部是 PascalCase 真列,同表存在同名 snake_case 影子列且**恒空/已发散**, /// 取错即静默得到空值: /// Domain(真) vs domain_code(132 行全空串) · /// IsActive(真) vs is_active(132 行中仅 1 行与真列相等) · /// PhysicalAddress(42 非空) vs physical_address(0 非空) · /// CreateUser(119 非空) vs create_user(0 非空) · /// RecID(真主键) vs rec_id(恒 0)。 /// /// 身份取 (tenant, domain_code, location_code):与子表父引用一致,且在源侧索引口径变化时仍成立; /// 另用 镜像当前源 UK (tenant_id, location)。 /// public static readonly S0DimDefinition Location = new( Key: "LOCATION", EntityCode: "S0_LOCATION", SourceTable: "LocationMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_location", DimTable: "dim_location", BusinessKeyColumns: ["tenant_id", "domain_code", "location_code"], SourceBizKeyColumns: ["Domain", "location"], MirrorUniqueColumns: ["tenant_id", "location_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("location_code", S0DimValueKind.Str, "location", Required: true), new("location_name", S0DimValueKind.Str, "descr"), new("location_type", S0DimValueKind.Str, "typed"), new("storer", S0DimValueKind.Str, "storer"), new("physical_address", S0DimValueKind.Str, "PhysicalAddress"), new("is_active", S0DimValueKind.BoolTrue, "IsActive"), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 货架(库位子维度)。源 LocationShelfMaster。 /// /// 与 共用 mdp_stg_s0_location,靠 source_table 隔离, /// 两者是**两个独立 definition**,绝不合并成一次转换。 /// /// 本表**全列 snake_case,没有影子列** —— 不可套用 LocationMaster 的禁用规则。 /// 另:源 update_time 0/30 非空(无水位)、主键 rec_id 不在通用行标识候选列表中 /// 且每次父表 FULL Replace 都会变,故物化只认 sync_batch_id,不依赖 source_row_id 稳定性。 /// 无 is_active —— 源表没有该列,不发明。 /// public static readonly S0DimDefinition LocationShelf = new( Key: "LOCATION_SHELF", EntityCode: "S0_LOCATION_SHELF", SourceTable: "LocationShelfMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_location", DimTable: "dim_location_shelf", BusinessKeyColumns: ["tenant_id", "domain_code", "location_code", "shelf_code"], SourceBizKeyColumns: ["domain_code", "location", "inv_shelf"], ParentDimTable: "dim_location", ParentKeyColumns: ["domain_code", "location_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_code", Required: true), new("location_code", S0DimValueKind.Str, "location", Required: true), new("shelf_code", S0DimValueKind.Str, "inv_shelf", Required: true), new("shelf_name", S0DimValueKind.Str, "descr"), new("area", S0DimValueKind.Str, "area"), // 源恒 NULL(0/30),保留列以便源将来启用;不得为了“看起来有值”而回填 new("source_updated_at", S0DimValueKind.DateTimeSec, "update_time") ]); /// /// 雇员。源 EmployeeMaster(29 列,无影子列,2026-09-07 实测确认)。 /// /// 身份 = tenant_id + EmployeeDomain 是属性,不是身份 —— /// 这条口径不是从旧物理唯一键推的,而是按「当前业务证据优先」重判的结果(2026-09-07): /// /// 当前 CRUD 查重是 tenant + FactoryRefId + Employee /// (AdoS0EmployeesController),**根本不含 Domain**; /// 其中 FactoryRefIdAdoS0OrgScope.ResolveAsync(ctx, tenantId) /// **仅按租户**解析并强制 stamp(payload 值一律覆盖),是 tenant 的函数、零区分力, /// 故该查重等价于 tenant + Employee /// 单条定位走 ByIdScopedAsync(id, tenantId),不经 Domain; /// 真实 consumer 一致按工号关联:LEFT JOIN EmployeeMaster e /// ON p.Buyer = e.Employee AND e.tenant_id = p.tenant_id /// (DemandOrderService / OutsourceOrderService / ProcessOutsourceOrderService 共 5 处), /// **无一处用 Domain**; /// 数据实测:(tenant_id, Employee) 租户内重复 = 0, /// 且不存在「同租户同工号不同 Domain」的行。 /// /// 租户仍必须入身份:Employee 跨租户碰撞实测 19 组 / 38 行 /// (G02795 / ZHR01 / RQTJ993 等工号被多个租户各自使用)。 /// 旧物理唯一键 uk_EmployeeMaster_domain_employee 含 Domain,属 legacy scope, /// 按 Owner 口径「不因存在于旧表/旧 UK 就自动成为 DIM identity」,**不予继承**。 /// /// 🔴 sys_user_id 只是属性,绝不参与业务键:实测 1105 行中仅 7 行非空(0.6%), /// 它是「员工恰好也有系统账号」的可选链接,不是员工身份。把账号 id 当员工主键会让 99.4% 的员工无法标识。 /// /// 字段适用性分类:employee_code=CORE;domain_code=ATTRIBUTE /// (仍被部门父连接与 CRUD 的部门引用校验 DepartmentExistsInTenantAsync(tenantId, domain, dept) 使用); /// company_id/factory_id=LEGACY_SCOPE(f(tenant),保留仅为可追溯到 SysOrg 节点); /// sys_user_id=可选 ATTRIBUTE,永远不入键。 /// 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") ]); /// /// 生产线。源 LineMaster。 /// /// 身份 = tenant_id + Line。company / factory / Domain 全是属性,不是身份 —— /// 同样按「当前业务证据优先」重判(2026-09-07),旧物理唯一键 /// uk_LineMaster_scope_line(tenant_id, company_ref_id, factory_ref_id, Line) 不予继承: /// /// 当前 CRUD 没有任何查重守卫AdoS0MfgProductionLinesController 无 /// IsAnyAsync/DuplicateCode),单条定位走 ByIdScopedAsync(id, tenantId) /// CompanyRefId/FactoryRefId 在控制器里只是**可选查询筛选** /// (WhereIF(q.CompanyRefId.HasValue, ...)),创建时由 /// AdoS0OrgScope.ResolveAsync 按租户强制 stamp,不是用户输入; /// 真实 consumer S8MasterDataAdapter.GetLinesAsync 是 /// Where(x => x.TenantId == scope.TenantId).Select(code = x.Line) —— 只有租户 + 产线码; /// 数据实测:四个候选(tenant+Line / +Domain / +factory / +company+factory) /// 租户内重复全部为 0;且**每个租户在本表只有 1 个 Domain、1 个 company、1 个 factory** /// ⇒ 这三列在租户内是常量,零区分力 /// /// 租户仍必须入身份:Line 跨租户碰撞实测 20 组 / 80 行(如 AE01 在 4 个租户都存在)。 /// /// 字段适用性分类:line_code=CORE;line_name/line_type/line_category/ /// workshop/location_code/is_active=ATTRIBUTE; /// domain_code/company_id/factory_id=LEGACY_SCOPE(租户内常量,保留仅为可追溯)。 /// /// 🔴 Describe 是真列(169/169 填充、63 distinct);同表的 line_describe /// 是恒空影子列(0/169 填充),取错即静默得到全空的产线名称。 /// 这是本仓第三例此型双列表(前两例:LocationMaster、StdOpMaster)。 /// /// ⚠️ factory_ref_id 参与身份,但**绝不能**设进 MdpPullContext.FactoryId: /// MdpStagingWriter 只识别 factory_id/FactoryId 两个列名, /// 本表叫 factory_ref_id → 解析为 null → 一旦 ctx.FactoryId > 0, /// 比较基准会退化成 1 并让**整批行全部被跳过**(KNOWN-ISSUES I-015)。 /// S0 的 pullCtx 只设 TenantId,该分支永不进入。 /// 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") ]); /// /// 客户。源 CustMaster(32 列,无影子列对;空列极多,只物化有语义且有数据的)。 /// /// 身份 = tenant_id + Cust。同样按「当前业务证据优先」重判: /// /// CRUD 查重 tenant + FactoryRefId + CustAdoS0CustomersController:70,117), /// 而 FactoryRefIdAdoS0OrgScope 仅按租户解析并强制 stamp ⇒ 等价 tenant + Cust /// DomainCode 在控制器里只是**可选列表筛选**(WhereIF(...)),不参与查重; /// 单条定位 ByIdScopedAsync(id, tenantId) /// 数据实测:三个候选(tenant+Cust / +Domain / +factory)租户内重复**全部为 0**, /// 且 company/factory 在每个租户内均为**单一取值**(常量、零区分力)。 /// /// 租户必须入身份:Cust 跨租户碰撞实测 4 个编码 /// /// 🔴 源表另有一条 不含 tenant_id 的旧唯一键 uk_CustMaster_factory_cust(factory_ref_id, Cust), /// 且 factory_ref_id=1000 被两个租户共用 —— 属源表治理缺陷(KNOWN-ISSUES I-016), /// **它不能反过来决定 DIM identity**;dim 侧自带 tenant_id 前缀,不受其影响。 /// 本批不动源表 DDL。 /// /// 字段适用性:customer_code=CORE;customer_name/is_active/ /// is_confirmed/shipping_lead_time=ATTRIBUTE; /// domain_code/company_id/factory_id=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,不物化。 /// 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") ]); /// /// 人员技能字典。源 PersonSkill(11 列)—— 是**技能主数据**,不是关系表。 /// /// 身份 = tenant_id + Code。最强的一条证据来自现有消费方的注释与实现 /// (AdoS0MfgLinePostsController.ResolvePersonSkillCodesAsync): /// 「PersonSkill 表没有 Domain 列,故不按 Domain 收窄;产线岗位主表也不带 /// CompanyRefId / FactoryRefId,无法据此再收窄,**因此按 (租户, Code) 解析**并对多义显式报错」—— /// 即当前业务**已经**把技能码当作租户级字典,并把「同租户跨公司/工厂重码」视为 /// 歧义错误而非两个不同技能。 /// 数据佐证:三个候选(tenant+Code / +Factory / +Company+Factory)租户内重复全部为 0, /// company/factory 每租户单一取值。Code 跨租户碰撞 5 个编码 ⇒ 租户必须入身份。 /// /// ⚠️ 不设父维度EmpSkills.SkillNo 与本表 Code 同租户实测 /// 650 行中 632 行对不上(仅 18 命中)—— 两者当前是各自独立的编码体系。 /// **不因为都叫 Skill 就建立虚假父子关系。** /// /// source_updated_at ← UpdatedAt:源 46 行中 CreatedAt 全非空、 /// UpdatedAt 仅 5 行非空 —— 这正是「维护时间」的形态(只有被改过的行才有值), /// 故映射之,NULL 表示自建以来未修改。**不用 CreatedAt 冒充,也不回填。** /// /// 字段适用性:skill_code=CORE;skill_name/skill_level/ /// remark/is_active=ATTRIBUTE;company_id/factory_id=LEGACY_SCOPE。 /// 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") ]); /// 全部维度,按依赖顺序(父在前,子在后 —— 孤儿判定需要父维度先就绪)。 public static readonly IReadOnlyList All = [WorkCenter, Department, Location, LocationShelf, Employee, Line, Customer, PersonSkill]; /// 按 Key 取维度(大小写不敏感);不存在返回 null。 public static S0DimDefinition? Find(string? key) => string.IsNullOrWhiteSpace(key) ? null : All.FirstOrDefault(d => string.Equals(d.Key, key.Trim(), StringComparison.OrdinalIgnoreCase)); /// 校验全部声明;任一不合法即抛出(供启动自检与单测使用)。 public static void ValidateAll() { foreach (var d in All) d.Validate(); var dupDim = All.GroupBy(d => d.DimTable, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); if (dupDim is not null) throw new InvalidOperationException( $"dim 表被多个 definition 共用:{dupDim.Key} —— FULL Replace 按 tenant 整表删除,会互相清空"); var dupEntity = All.GroupBy(d => d.EntityCode, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); if (dupEntity is not null) throw new InvalidOperationException($"entity_code 重复:{dupEntity.Key}"); // 同一 staging 被多个 definition 共用时,source_table 必须互异,否则 purge/物化会互相污染 foreach (var g in All.GroupBy(d => d.StagingTable, StringComparer.Ordinal).Where(g => g.Count() > 1)) { var tables = g.Select(d => d.SourceTable).ToList(); if (tables.Distinct(StringComparer.Ordinal).Count() != tables.Count) throw new InvalidOperationException($"staging {g.Key} 被多个 definition 共用且 source_table 重复"); } } }