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") ]); // ════════════════════════════════════════════════════════════════════════════ // C2 · 参考/配置字典(ref_*) // // 与 C1 维度同用一套机制,只是语义是「字典」而非「主数据实体」,故目标表用 ref_ 前缀。 // 身份口径与 C1 完全一致:tenant_id + 业务码;Domain / company / factory 一律 LEGACY_SCOPE。 // // 🔴 本组落地时实测出的两个框架地雷,写在这里免得下次重踩: // ① bit(1) 源列用 Int 会让对账每轮必挂(详见 S0DimValueKind.Int 的说明)。 // ② qms 字典的 FSTATUS / FENABLE 是 varchar 且值域含中文与字母 // (`已审核` / `已启用` / `A` / `ENABLED` / `INVALID_STATUS`), // 用 BoolTrue 会命中可辨识性守卫:4 张表**整表归零**, // 而 qms_inspectionfreq.FENABLE 恰好存活 8/13 —— 部分静默丢失,最难发现。 // ⇒ 状态列一律 Str 原样透传,语义归一留给下游。 // ════════════════════════════════════════════════════════════════════════════ /// /// 标签类型字典。源 BarCodeType。 /// /// 身份 = tenant_id + bar_type。CRUD 查重 /// (AdoS0LabelTypesController)= tenant + FactoryRefId + BarType, /// 而 FactoryRefIdAdoS0OrgScope 仅按租户解析并强制 stamp ⇒ 等价 tenant + bar_type, /// **不含 Domain**。实测 bar_type 跨租户碰撞 6 个编码 ⇒ 租户必须入键。 /// /// ⚠️ 本表主键叫 rec_id(带下划线),不在 MdpDbPullExecutor.ResolveSourceRowId /// 的候选列表内(rec_id ≠ recid),且本表无 id 列 ⇒ source_row_id 落 GUID 兜底。 /// 与 LocationShelfMaster 同型,**安全**:物化只认 sync_batch_id, /// 且 purge 先于 pull,不依赖 source_row_id 稳定性。 /// public static readonly S0DimDefinition LabelType = new( Key: "LABEL_TYPE", EntityCode: "S0_LABEL_TYPE", SourceTable: "BarCodeType", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_label_type", DimTable: "ref_label_type", BusinessKeyColumns: ["tenant_id", "label_type_code"], SourceBizKeyColumns: ["bar_type"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("label_type_code", S0DimValueKind.Str, "bar_type", Required: true), new("label_class", S0DimValueKind.Str, "class"), new("input_string", S0DimValueKind.Str, "input_string"), new("domain_code", S0DimValueKind.Str, "domain_code"), new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true), new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true), new("source_updated_at", S0DimValueKind.DateTimeSec, "update_time") ]); /// /// SOP 文件类型字典。源 ImageType(**只有 5 列**)。 /// /// 身份 = tenant_id + image_type_id。控制器 /// AdoS0MfgSopFileTypesController 无查重守卫,定位走 ByIdScopedAsync(id, tenantId), /// 且已有显式「去 domain 化」注释(S0-DOMAIN-FILTER-REMOVAL-READONLY-1)。 /// /// 🔴 本表没有任何时间列(实测 5 列 = id/tenant_id/image_type_id/descr/domain), /// 故**不声明 source_updated_at**,ref 表里也不建该列。 /// 不得为了「标准字段齐整」而回填 —— JsonPath 必须是真实物理列。 /// /// 注意物理列名是 domain 而非 domain_code;且其取值在三个租户间口径异构 /// (S0-F001 / 雪花号 / 8010),更加坐实它是 LEGACY_SCOPE。 /// public static readonly S0DimDefinition SopFileType = new( Key: "SOP_FILE_TYPE", EntityCode: "S0_SOP_FILE_TYPE", SourceTable: "ImageType", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_sop_file_type", DimTable: "ref_sop_file_type", BusinessKeyColumns: ["tenant_id", "file_type_code"], SourceBizKeyColumns: ["image_type_id"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("file_type_code", S0DimValueKind.Str, "image_type_id", Required: true), new("file_type_name", S0DimValueKind.Str, "descr"), new("domain_code", S0DimValueKind.Str, "domain") ]); /// /// 质量基础类型字典。源 qms_quality_base_type。 /// /// 身份 = tenant_id + type_category + type_code,**两段业务码**。 /// type_code 单列在当前数据里也唯一,但那是运气 —— 代码硬证据: /// AdoS0QualityBaseDataControllers 的 Create/Update 两处查重均按 /// (TypeCategory, TypeCode),注释亦写明「(TypeCategory, TypeCode) 全局唯一」。 /// 按「当前业务证据优先」取两段。源表无任何 UK。 /// /// 本表无 Domain / company / factory 列 —— 不设、不发明(同 WorkCtrMaster 先例)。 /// is_activetinyint(1) NOT NULL,在 raw_data 里是 JSON BOOLEAN,可安全走 BoolTrue。 /// public static readonly S0DimDefinition QualityBaseType = new( Key: "QUALITY_BASE_TYPE", EntityCode: "S0_QUALITY_BASE_TYPE", SourceTable: "qms_quality_base_type", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_quality_base_type", DimTable: "ref_quality_base_type", BusinessKeyColumns: ["tenant_id", "type_category", "type_code"], SourceBizKeyColumns: ["type_category", "type_code"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("type_category", S0DimValueKind.Str, "type_category", Required: true), new("type_code", S0DimValueKind.Str, "type_code", Required: true), new("short_name", S0DimValueKind.Str, "short_name"), new("full_name", S0DimValueKind.Str, "full_name"), new("remark", S0DimValueKind.Str, "remark"), new("is_active", S0DimValueKind.BoolTrue, "is_active"), new("source_updated_at", S0DimValueKind.DateTimeSec, "modify_time") ]); /// /// 抽样方案字典。源 qms_sampscheme。C2-2 qms 字典族共用 mdp_stg_s0_qms_dict,靠 source_table 隔离。 /// 🔴 FSTATUS/FENABLE 必须 Str:实测值域 已审核×8 · DISABLED×1 · INVALID_STATUS×1 /// 与 已启用×8 · ENABLED×2,用 BoolTrue 会**整表归零**。 /// public static readonly S0DimDefinition SamplingScheme = new( Key: "SAMPLING_SCHEME", EntityCode: "S0_SAMPLING_SCHEME", SourceTable: "qms_sampscheme", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_qms_dict", DimTable: "ref_sampling_scheme", BusinessKeyColumns: ["tenant_id", "scheme_code"], SourceBizKeyColumns: ["FNUMBER"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("scheme_code", S0DimValueKind.Str, "FNUMBER", Required: true), new("scheme_name", S0DimValueKind.Str, "FNAME"), new("sampling_type", S0DimValueKind.Str, "FSAMPLINGTYPE"), new("inspection_level", S0DimValueKind.Str, "FINSPECTIONLEVEL"), new("strictness", S0DimValueKind.Str, "FSTRICTNESS"), new("aql_value", S0DimValueKind.Str, "FAQLVALUE"), new("inspection_type", S0DimValueKind.Str, "FINSPECTIONTYPE"), new("status_code", S0DimValueKind.Str, "FSTATUS"), new("enable_code", S0DimValueKind.Str, "FENABLE"), new("remark", S0DimValueKind.Str, "FCOMMENT"), new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME") ]); /// /// 检验项目字典。源 qms_inspectionitems。 /// ⚠️ FCHECKMETHOD/FCHECKBASIS 指向的是**源表代理主键 id**,不是业务键, /// 故只作普通 Int 属性透传,**不声明父维度**(框架的孤儿判定按业务键列做 join,对不上)。 /// 同 dim_employee.sys_user_id 先例。 /// public static readonly S0DimDefinition InspectionItem = new( Key: "INSPECTION_ITEM", EntityCode: "S0_INSPECTION_ITEM", SourceTable: "qms_inspectionitems", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_qms_dict", DimTable: "ref_inspection_item", BusinessKeyColumns: ["tenant_id", "item_code"], SourceBizKeyColumns: ["FNUMBER"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("item_code", S0DimValueKind.Str, "FNUMBER", Required: true), new("item_name", S0DimValueKind.Str, "FNAME"), new("check_method_id", S0DimValueKind.Int, "FCHECKMETHOD"), new("check_basis_id", S0DimValueKind.Int, "FCHECKBASIS"), new("status_code", S0DimValueKind.Str, "FSTATUS"), new("enable_code", S0DimValueKind.Str, "FENABLE"), new("remark", S0DimValueKind.Str, "FCOMMENT"), new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME") ]); /// 检验方法字典。源 qms_inspection_method。状态列同族一律 Str public static readonly S0DimDefinition InspectionMethod = new( Key: "INSPECTION_METHOD", EntityCode: "S0_INSPECTION_METHOD", SourceTable: "qms_inspection_method", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_qms_dict", DimTable: "ref_inspection_method", BusinessKeyColumns: ["tenant_id", "method_code"], SourceBizKeyColumns: ["FNUMBER"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("method_code", S0DimValueKind.Str, "FNUMBER", Required: true), new("method_name", S0DimValueKind.Str, "FNAME"), new("control_strategy", S0DimValueKind.Str, "FCTRLSTRATEGY"), new("status_code", S0DimValueKind.Str, "FSTATUS"), new("enable_code", S0DimValueKind.Str, "FENABLE"), new("remark", S0DimValueKind.Str, "FCOMMENT"), new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME") ]); /// /// 检验频次字典。源 qms_inspectionfreq。 /// 🔴 本表与兄弟表有两处不同,照抄会出错: /// ① 备注列叫 FREMARK,**不是** FCOMMENT; /// ② FENABLE 的值域是 '1'×8 · ENABLED×5 —— 用 BoolTrue 会**存活 8 行、静默丢 5 行**, /// 是全族唯一不会整表归零、因而最容易被误判成「通过」的一张。 /// public static readonly S0DimDefinition InspectionFrequency = new( Key: "INSPECTION_FREQUENCY", EntityCode: "S0_INSPECTION_FREQUENCY", SourceTable: "qms_inspectionfreq", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_qms_dict", DimTable: "ref_inspection_frequency", BusinessKeyColumns: ["tenant_id", "frequency_code"], SourceBizKeyColumns: ["FNUMBER"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("frequency_code", S0DimValueKind.Str, "FNUMBER", Required: true), new("frequency_name", S0DimValueKind.Str, "FNAME"), new("remark", S0DimValueKind.Str, "FREMARK"), new("status_code", S0DimValueKind.Str, "FSTATUS"), new("enable_code", S0DimValueKind.Str, "FENABLE"), new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME") ]); /// /// 量检具字典。源 qms_inspectioninstru。 /// 三个运维列是源表自有的 snake_case 列(非 F 前缀):gauge_category · /// calibration_cycle_days(int) · next_calibration_date /// ⚠️ next_calibration_date 源是 varchar(50) 存日期串(如 2027-02-25), /// 用 Str 忠实透传而非 DateTimeSec:后者对非 ISO 脏值会让**两侧同时变 NULL**, /// checksum 照样相等而数据静默丢失。 /// public static readonly S0DimDefinition InspectionInstrument = new( Key: "INSPECTION_INSTRUMENT", EntityCode: "S0_INSPECTION_INSTRUMENT", SourceTable: "qms_inspectioninstru", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_qms_dict", DimTable: "ref_inspection_instrument", BusinessKeyColumns: ["tenant_id", "instrument_code"], SourceBizKeyColumns: ["FNUMBER"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("instrument_code", S0DimValueKind.Str, "FNUMBER", Required: true), new("instrument_name", S0DimValueKind.Str, "FNAME"), new("model", S0DimValueKind.Str, "FMODEL"), new("specification", S0DimValueKind.Str, "FSPECIFICATION"), new("manufacturer", S0DimValueKind.Str, "FMANUFACTURER"), new("gauge_category", S0DimValueKind.Str, "gauge_category"), new("calibration_cycle_days", S0DimValueKind.Int, "calibration_cycle_days"), new("next_calibration_date", S0DimValueKind.Str, "next_calibration_date"), new("status_code", S0DimValueKind.Str, "FSTATUS"), new("enable_code", S0DimValueKind.Str, "FENABLE"), new("remark", S0DimValueKind.Str, "FCOMMENT"), new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME") ]); /// /// 供应商。源 SuppMaster —— **S0 的 Supplier 权威**。 /// /// mdp_std_supplier 属 Supply/S3 的多源标准层,**不是** S0 主数据权威, /// 与本维度语义域不同(同 mdp_std_item 的关系)。 /// /// 身份 = tenant_id + Supp。实测租户内重复 0 组; /// Supp 跨租户碰撞 13 码 / 27 行 ⇒ 租户必须入身份。 /// 旧唯一键 uk_SuppMaster_factory_suppfactory_ref_id,**不予继承** —— /// Domain / company_ref_id / factory_ref_id **每租户各只有 1 个取值** /// (严格 f(tenant),零判别力),且 factory_ref_id=1000 被两个租户共用。 /// /// CRUD 的查重守卫 TenantId && FactoryRefId && Supp **不构成 factory 应入身份的证据**: /// 其 factoryId 来自 AdoS0OrgScope.ResolveAsync(db, tenantId) —— 仅凭 tenantId 解析、 /// 强制盖章、忽略 payload,故该守卫语义上等价于 tenant + Supp(D-025)。 /// 页面虽有「公司/工厂」下拉且 DTO 带 [Range] 必填,但 CreateAsync/UpdateAsync /// **从不读取**这两个入参 —— UI 收集 ≠ 参与身份。 /// /// 真实消费方按 tenant + 编码 关联:SrmPurchase.SupplierNumber == Supp 命中 /// 4447/4449;而 srm_purchase.supplier_id → SuppMaster.RecID 命中 0/4449 /// (存量是 19 位雪花值,而 RecID 最大 3585)⇒ RecID 无任何读取消费方, /// **不物化 legacy_source_id**。 /// /// 🔴 supplier_name **绝不能设 Required**:SortName21 行 NULL(全在租户 797)。 /// 误设会让 RowGuards 生成 IS NOT NULL 过滤 → dim_count 3164 < stg_count 3185 /// → 阻断级对账 FAIL,且报错指向对账而非字段声明,极难定位。 /// 同理 source_updated_at 可空(UpdateTime 有 12 行 NULL,全在租户 838257186181189)。 /// /// 字段取舍(28 列 → 物化 9 列):本表 11 列近乎全空 —— /// CrTerms/Curr/Type/TaxClass/address/contact/ /// position/contact_info/certification_level 非空白值 0/3575; /// PurContact 2 行、Remark 1 行。它们有真实 CRUD 写入路径、**不是影子列**,只是无人填; /// 其中联系人/联系方式/职务另属 PII,即便将来填充也须重新评审。 /// APStartDay1 等 4 列虽 99.97% 非 NULL,但应用自身 /// (FormatApDayvalue == 0 → "")把 0 判为无值,有效填充率仅 2.01%/0.196%。 /// /// IsActive / TaxIn 都是 tinyint(1) NOT NULL(非 bit(1)、非可空), /// 的可辨识性守卫安全。 /// 单租户最大 3185 行,batch_size20000 —— 默认 5000 只有 1.57× 头寸, /// 而 AssertNoSourceDuplicateAsyncsourceCount >= batchSize 硬失败。 /// public static readonly S0DimDefinition Supplier = new( Key: "SUPPLIER", EntityCode: "S0_SUPPLIER", SourceTable: "SuppMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_supplier", DimTable: "dim_supplier", BusinessKeyColumns: ["tenant_id", "supplier_code"], SourceBizKeyColumns: ["Supp"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("supplier_code", S0DimValueKind.Str, "Supp", Required: true), // 🔴 21 行 NULL,绝不能 Required(本表没有全称列,SortName 的 DDL 注释即「简称」,不发明第二个名称列) new("supplier_name", S0DimValueKind.Str, "SortName"), new("is_active", S0DimValueKind.BoolTrue, "IsActive"), new("tax_included", S0DimValueKind.BoolTrue, "TaxIn"), // ── LEGACY_SCOPE(属性透传,绝不入唯一键;三者均为 f(tenant)) 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("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 物料。源 ItemMaster —— **S0 的 Item 权威**(2026-09-07 拍板)。 /// /// mdp_std_item 的关系已封板,不再讨论:那是 Supply/S3 的**多源**物料目录 /// (实测混入 ic_item,797 租户有 75 个 item_code 只来自 ic_item、在 ItemMaster 里 0 命中), /// 且物理上无 Domain、无 ItemMaster.RecID、两个最大租户的 factory/company 100% NULL, /// 无法承担 C3 的父维度职责。dim_item 不是第二份权威,是 ItemMaster 的第一次物化, /// 二者语义域不相交(消费方实测零重叠)。 /// /// 身份 = tenant_id + ItemNum。实测:租户内重复 0、空白 0、非法租户 0; /// ItemNum 跨租户碰撞 663 码 / 1534 行 ⇒ 租户必须入身份。 /// 旧唯一键 uk_ItemMaster_factory_itemfactory_ref_id,**不予继承** —— /// 它连 f(tenant) 都不是(租户 838257186181189 有 3 个取值,含 0), /// 且 8010 被三个租户共用,放进唯一键既无判别力又把身份绑到跨租户共享值上。 /// /// ⚠️ 单租户最大 16150 行,超过默认 batch_size 5000 —— /// AssertNoSourceDuplicateAsync 会硬失败。本 entity 的 mdp_entity.batch_size50000。 /// 该值是 **entity 级**(取自 entity.BatchSize),不影响其它任何维度。 /// /// legacy_source_id ← RecID 是**技术属性**,供 C3 的 /// ProductStructureMaster.ComponentMaterialId 这类 legacy 代理键做对账解析。 /// **它绝不是身份,也不得用 dim 的代理 id 建立外键** —— 每轮 FULL REPLACE 后 dim_item.id 都会变。 /// /// 字段取舍规则(75 列 → 只物化符合以下全部条件的):**全库填充率 ≥5% 且全库 distinct ≥2**; /// 并无条件排除审计列、影子列、bit(1) 列、可空 tinyint(1)。据此排除的典型: /// expire_alarm_day(0 填充的影子列,真列是 ExpireAlarmDays 22682)· /// SuppWarrantybit(1) 且含 2 行 NULL —— 用 Int 对账必挂、用 BoolTrue 会滤掉那 2 行)· /// DEAControlled(可空 tinyint,2 行 NULL 同样会被 BoolTrue 守卫滤掉)· /// Length/Size/Height/Width/cust_supplied/enable_bom_version /// (全库 distinct=1,零信息)· Status(7/22684,且真实状态是 IsActive)。 /// public static readonly S0DimDefinition Item = new( Key: "ITEM", EntityCode: "S0_ITEM", SourceTable: "ItemMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_item", DimTable: "dim_item", BusinessKeyColumns: ["tenant_id", "item_code"], SourceBizKeyColumns: ["ItemNum"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), // ── CORE new("item_code", S0DimValueKind.Str, "ItemNum", Required: true), new("item_name", S0DimValueKind.Str, "Descr", Required: true), // ── ATTRIBUTE · 描述与分类 new("item_name_2", S0DimValueKind.Str, "Descr1"), new("unit", S0DimValueKind.Str, "UM"), new("pur_mfg", S0DimValueKind.Str, "PurMfg"), new("item_type", S0DimValueKind.Str, "ItemType"), new("commodity_code", S0DimValueKind.Str, "CommodityCode"), new("rev", S0DimValueKind.Str, "Rev"), new("drawing", S0DimValueKind.Str, "Drawing"), new("pkg_code", S0DimValueKind.Str, "PkgCode"), new("series", S0DimValueKind.Str, "Series"), new("emt_type", S0DimValueKind.Str, "EMTType"), new("po_receipt_status", S0DimValueKind.Str, "PORcptStatus"), new("warranty_code", S0DimValueKind.Str, "WarrantyCode"), // ── ATTRIBUTE · 库位与人员 new("default_location", S0DimValueKind.Str, "Location"), new("buyer", S0DimValueKind.Str, "Buyer"), new("planner", S0DimValueKind.Str, "Planner"), // ── ATTRIBUTE · 计量 new("net_weight", S0DimValueKind.Dec, "NetWeight", Precision: 18, Scale: 5), new("net_weight_um", S0DimValueKind.Str, "NetWeightUM"), // ── ATTRIBUTE · 计划参数(Dec 精度必须与源逐位一致,否则 CAST AS CHAR 后校验和 FAIL) new("pur_lead_time", S0DimValueKind.Int, "PurLT"), new("ins_lead_time", S0DimValueKind.Int, "InsLT"), new("mfg_lead_time", S0DimValueKind.Int, "MfgLT"), new("mfg_mttr", S0DimValueKind.Int, "MFGMTTR"), new("days_between_pm", S0DimValueKind.Int, "DaysBetweenPM"), new("expire_alarm_days", S0DimValueKind.Int, "ExpireAlarmDays"), new("min_order_qty", S0DimValueKind.Dec, "MinOrd", Precision: 18, Scale: 5), new("max_order_qty", S0DimValueKind.Dec, "MaxOrd", Precision: 18, Scale: 5), new("min_order_qty_sales", S0DimValueKind.Dec, "MinOrdSales", Precision: 18, Scale: 5), new("order_multiple", S0DimValueKind.Dec, "OrdMult", Precision: 18, Scale: 5), new("safety_stock", S0DimValueKind.Dec, "SafetyStk", Precision: 18, Scale: 5), new("stock_turnover", S0DimValueKind.Dec, "StockTurnOver", Precision: 18, Scale: 5), // ── ATTRIBUTE · 标志位(全部是 tinyint(1) NOT NULL,可安全走 BoolTrue) new("is_active", S0DimValueKind.BoolTrue, "IsActive"), new("is_confirmed", S0DimValueKind.BoolTrue, "IsConfirm"), new("key_item", S0DimValueKind.BoolTrue, "KeyItem"), new("inspect_required", S0DimValueKind.BoolTrue, "Inspect"), new("issue_policy", S0DimValueKind.BoolTrue, "IssuePolicy"), new("install", S0DimValueKind.BoolTrue, "Install"), new("lot_serial_control", S0DimValueKind.BoolTrue, "LotSerialControl"), new("allocate_single_lot", S0DimValueKind.BoolTrue, "AllocateSingleLot"), new("auto_lot_nums", S0DimValueKind.BoolTrue, "AutoLotNums"), new("trace_detail", S0DimValueKind.BoolTrue, "TraceDetail"), new("is_main_master", S0DimValueKind.BoolTrue, "IsMainMas"), new("mrp_required", S0DimValueKind.BoolTrue, "MRPRequired"), // ── LEGACY_SCOPE(属性透传,绝不入唯一键) 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), // ── 技术属性:供 C3 的 legacy 代理键对账用(见类型注释) new("legacy_source_id", S0DimValueKind.Int, "RecID", Required: true), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 物料-工序-产线产能。源 ProdLineDetail,父 = 。 /// /// 身份 = tenant + Part + Op + Line(实测租户内重复 0 组)。 /// Line **不可省**:去掉后 tenant+Part+Op197 组 / 394 行重复。 /// Domain 每租户单值(797→8010、824585161322565→2410、838257186181189→8010), /// 零判别力 ⇒ LEGACY_SCOPE,不入身份(D-025)。 /// /// 🔴 dual-column 陷阱:真列是 InternalEquipmentCode(2430/10646 有值), /// 同表的 internal_equipment_type_code0/10646 的死影子列。取错不报错、只会全 NULL。 /// /// 🔴 RunCrew 在本表是 varchar(8),而 的同名列源自 /// LineSkillMasterdecimal(10,3) —— 同名不同型,照抄会当场类型出错。 /// /// Line11 行带尾随空格A01 ×2 / BA01 ×6 / DC01-1 ×1 / /// S01 ×1 / S02 ×1)。utf8mb4_0900_ai_ci 是 NO PAD,这是真实业务键差异, /// **原样透传,禁止 TRIM**。 /// /// Line / Site 都只作软引用**不设父**:Line→dim_line 同租户孤儿 /// 8848/10646(租户 824585161322565 的 LineMaster 整体 0 行),Site 更有 5823/10646 为 NULL; /// 且 CRUD 写入路径对二者**零存在性校验**(_lineMasterRep/_workCtrRep 只用于列表回显)。 /// 与 不挂 dim_line 同源。 /// /// ⚠️ Op 是本仓**首个 Int 类型业务键**。三侧渲染已验证一致: /// C# ((int)1002).ToString() / 源侧 CONCAT_WS(…, a.Op) / dim 侧同 —— 均为 '1002' /// /// 租户分布 797=5817 / 824585161322565=4823 / 838257186181189=6, /// 故 batch_size20000(3.4× 头寸;默认 5000 会被 /// AssertNoSourceDuplicateAsyncsourceCount >= batchSize 直接判失败)。 /// 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") ]); /// /// 物料工艺路线工序。源 RoutingOpDetail(38806 行,S0 最大的桥),父 = 。 /// /// 身份 = tenant + MaterialCode + OperationCode(实测重复 0 组)。 /// RouteCode 不必要:每租户 COUNT(DISTINCT RouteCode) == COUNT(DISTINCT MaterialCode) /// 且两列逐值相同 38805/38806 行。SortNo 也不必要(近乎常量)。 /// 去掉 OperationCode 则塌陷成 2879 组 / 38677 行重复。 /// /// 🔴 本表的时间列叫 UpdatedAt / CreatedAt,不是全仓通用的 UpdateTime。 /// 照抄其它 definition 写 UpdateTime 会**静默取到恒 NULL**(JSON 键不存在 → /// JSON_EXTRACT 返回 SQL NULL,不报错)。 /// /// 🔴 六组新旧列近重复,取错不会得到 NULL 而是「看起来合法的错值」 /// (RouteCode/RoutingCode/MaterialCode/RouteName 四列 38805/38806 同值)。 /// 真列判据 = CRUD 写入路径:RouteCode(非 RoutingCode)· /// OperationCode(非旧列 Op)· OperationDescription(非 Descr)。 /// /// StdOp 不设父:对 StdOpMaster 孤儿 37112/38746(95.8%), /// AdoS0ReferenceChecker 无 StdOp 校验、CRUD 的 ApplyUpsert 根本不写该列、UI 无下拉, /// 且值域不同构(本表 0010/0020 vs StdOpMaster.StdOp1-1/包装, /// 仅 1634/38806 偶然命中)。**它与 item 孤儿是两回事** —— item 孤儿只有 44 /// /// 父孤儿 44 行 = ALLOW + WARNING(框架原生:孤儿只告警不阻断):全在租户 797, /// 仅 2 个物料码 A198CABA×18 / 918B0238×26,忽略租户后仍是 44 /// ⇒ 是被引用主数据**全库整体缺失**,不是跨租户错挂。 /// /// 本轮无法物化的两个业务语义(数据侧问题,非建模问题): /// 「报工工序」—— CRUD 唯一写入的 MilestoneOperation0/38806, /// 数据全在 CRUD 从不写的旧列 MilestoneOp,而后者是**可空 tinyint(1)**(1 行 NULL); /// 「启用状态」—— IsEnabled distinct=1 零信息,IsActive 同为可空 tinyint(1)。 /// 可空 tinyint 用 BoolTrue 会滤掉 NULL 行导致行数对账 FAIL、用 Int 会静默写 0, /// 故一律不物化(与 排除 DEAControlled 同因)。 /// /// Ufld2Ufld9(8 列)· OverlapUnits · PackingQty · /// ProcessOutDay 全库 distinct=1,零信息,不物化。 /// /// 租户分布 797=32309 / 824585161322565=4824 / 838257237606469=1398 / /// 838257186181189=156 / 838257212780613=119,故 batch_size100000(3.1× 头寸)。 /// 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") ]); /// /// 物料工序生产要素。源 ItemOpCondition,父 = 。 /// /// 身份 = tenant + ItemNum + CodeType(实测重复 0 组)。 /// /// 🔴 IsMain 不入身份 —— 但原因不是框架禁令,而是更下游且无法绕过的一层: /// 源列是 tinyint(1) NOT NULL,在 raw_data 里是 JSON BOOLEAN(非 1/0)。 /// 即便放开 Validate() 允许布尔入键仍然会挂 —— staging 的 source_biz_key 由 /// mdp_entity.biz_key_expr 驱动、走 C# bool.ToString() 产出 "True"/"False", /// 而源侧 checksum 渲染的是 '1'/'0',四向集合比对必然全量不等价。 /// 修它要动全实体共享的 MdpStagingWriter.BuildBizKey。 /// 旁证:全部 mdp_stg_s0_*source_biz_key 中匹配 True|False 的行数 = 0, /// 布尔入键在本仓从未发生过。故 IsMain属性,且**必须用 BoolTrue** —— /// 用 Int 会把 true/false 双双静默压成 0(比 bit(1) 地雷更毒,不报错不为 NULL), /// 用 Str 会得到 'true' 而源侧是 '1',校验和必挂。 /// /// CodeType 实测单值 'Prod' 且控制器硬编码写死、无视 DTO 入参,零区分力; /// 保留系防不可逆键收缩(同 job_no 先例),不是因为它有用。 /// /// 🔴 ItemCode(→ element_code)是**要素编码**,与 ItemNum(物料编码) /// **语义不同**:实测两列相等的行数 = 0,且 5/10 为空。任何把它当物料码 join 的代码都是 bug。 /// /// Op 虽 10/10 填充且 (ItemNum,Op)→RoutingOpDetail 零孤儿,但不在源 UK 内、 /// DTO 无 [Required] ⇒ 不入键。**不要因为表名叫 ItemOpCondition 就把 Op 塞进身份。** /// /// ⚠️ dim 唯一键严格弱于源 UK(源含 DomainIsMain),且两个控制器 /// 均无查重守卫,业务**可以**合法插出同 (tenant,ItemNum,'Prod') 的 IsMain=0/1 两行。 /// 届时 AssertNoSourceDuplicateAsync(在 purge **之前**)会显式失败、零数据被触碰 —— /// 失败可见、可定位、不静默。当前实测 0 例。 /// 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") ]); /// /// 物料工序 SOP 文件。源 QualitySegmentImage,父 = 。 /// /// 身份 = tenant + item_num + op + image_type_id(实测重复 0 组)。 /// 源 UNIQUE 还含 domain,但它跨租户口径异构(797 用组织雪花号 797403761152069、 /// 838257186181189 用 legacy 8010/2410),坐实 LEGACY_SCOPE,不入身份。 /// /// 🔴 源表没有任何时间列(11 列全清单实测),故**不声明 source_updated_at** —— /// 与 / 同型。**不得为字段齐整而回填。** /// /// ⚠️ 源表那个叫 uk_QualitySegmentImage_scope 的索引**不是唯一索引** /// (IsUnique=false);真正的唯一键是 uk_QualitySegmentImage_scope_unique。**别被名字骗了。** /// /// image_type_id 软引用 file_type_code(实测 0 孤儿), /// 但**不设父** —— 框架只能挂一个父,而本桥的主语是「某物料某工序的 SOP 文件」, /// image_type_id 只是该文件的分类(闭集字典)。挂 dim_item 才让 /// 「S0 ITEM AUTHORITY = dim_item」在一处可强制、可审计;且 ref_sop_file_type /// 当前只物化了租户 797,选它作父会让别的租户产出纯刷新顺序噪声的 WARN。 /// 同 skill_code 先例。 /// /// ⚠️ item_num / op / image_type_id 在源 DDL 里**均可空**, /// 且 upsert DTO 只有 Domain[Required] ⇒ 业务可合法建 NULL 行 → /// 被 RowGuards 整行过滤 → dim_count < stg_count → 阻断 FAIL。 /// 与 同风险类。当前实测 0 NULL / 0 空串。 /// 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:源表零时间列,不得回填 ]); /// /// BOM 结构(父件-子件用量)。源 ProductStructureMaster(62036 行,S0 最大的表), /// 父 = 。 /// /// 身份 = tenant + ParentItem + ComponentItem(实测重复 0 组)。 /// LineOp / Op / SequenceNum 加进去**零增益**(仍是 0 组),故不入身份; /// 去掉 ComponentItem 则塌陷成 4341 组 / 60494 行重复。 /// 旧 UK 含 CompanyRefId+FactoryRefId,不予继承 —— 身份不加它们已然唯一(D-025)。 /// /// 🔴🔴 legacy 代理键 ComponentMaterialId / ParentMaterialId 是已实证的跨租户泄漏向量 /// (2026-09-08 实测): /// /// 同租户解析 ComponentMaterialId → ItemMaster.RecID170 行解析失败 /// 忽略租户解析:0 行失败 —— 看起来「完美」; /// 而这 170 行恰恰全部挂到了别的租户的 RecID /// /// ParentMaterialId 同款,量更大(同租户失败 2060、忽略租户 0)。 /// 也就是说:**去掉租户条件不会报错,只会静默串租户**。 /// 而同租户能解析的 61866 行里,代理键解出的码与 ComponentItem 61866/61866 完全一致 /// ⇒ **自然键完全可靠,代理键只带来风险**。故本桥一律走自然键 /// item_code / component_item_code,两个 id 仅作 legacy 技术属性留痕, /// **任何消费方解析它们时必须带 tenant_id**。 /// /// 父连接:ParentItemComponentItemItemMaster 的孤儿 /// **同租户与忽略租户都是 0/62036**。框架单父,取 ParentItemitem_code /// (下游「查某物料的 BOM」天然按父件过滤);ComponentItem 落 /// component_item_code,同样零孤儿但不设父。 /// /// 🔴 IsActivebit(1) 且含 1 行 NULL —— 双重地雷,**不物化**: /// 用 会让对账每轮必挂(CAST(b'1' AS CHAR) 是裸字节), /// 用 会把那 1 行 NULL 整行滤掉 → 行数对账 FAIL。 /// 同理 IsEnabled(distinct=1 零信息)、SequenceNum(distinct=1)、 /// StructureType(7.2% 填充但 distinct=1)、LineOp/Remark/StartEff/EndEff /// (全库 0 填充)一并不物化。 /// /// ⚠️ 单租户最大 57199 行(797),batch_size200000(3.5× 头寸)。 /// 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") ]); /// /// 替代料。源 ItemSubstituteDetail,父 = 。 /// /// 身份 = tenant + ParentItem + ItemNum + SubstituteItem(实测重复 0 组)。 /// 虽然 tenant+ItemNum+SubstituteItem 当前也是 0 组,但全表仅 16 行, /// 这点样本证明不了 ParentItem 可去;而实测 ParentItemItemNum /// 16 行无一相等(distinct 9 vs 14),是**真实不同的信息** /// —— 去掉即不可逆的键收缩,故保留(同 job_no 先例)。 /// Domain 在旧 UK 内,按 D-025 落 LEGACY_SCOPE 不入身份。 /// /// 三个 item 引用(ParentItem / ItemNum / SubstituteItem) /// **全部统一到 dim_item 的自然键 tenant + item_code**,实测孤儿各 0/16。 /// 框架单父,取被替代的 ItemNumitem_code(本行的主语是「这个料有哪些替代」); /// 另两个落 parent_item_code / substitute_item_code,同样零孤儿但不设父。 /// 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_*) // ════════════════════════════════════════════════════════════════════════════ /// /// 员工物料职责桥。源 EmpWorkDutyMaster。 /// /// 身份 = tenant_id + Employee + Duty + Location。 /// location_code 不可省 —— 实测 tenant+Employee+Duty 有 1 组重复: /// 租户 797…229 的 G00308 在同一串 Duty 下有两行,分别是 Location=1001 /// 与 Location=1000(物料也不同)⇒ 库位是真实的区分维,不是 legacy scope。 /// 加上 Location 后租户内重复 = 0。 /// /// 挂父有业务依据:本表是少数**真正强制父存在性**的 CRUD —— /// AdoS0EmpWorkDutiesController 写入前调 EmployeeExistsInTenantAsync。 /// 实测父孤儿 0/25。父键取 ["employee_code"],与 dim_employee 身份对齐。 /// /// ⚠️ CRUD **无查重守卫**且 DTO 的 Duty/Locationstring?[Required] /// ⇒ 业务可以合法建出 NULL 行,而它们是 Required 业务键,会被 RowGuards 整行过滤导致对账 FAIL。 /// 当前实测 0 NULL / 0 空串,verify 与运行验收都显式断言这一点。 /// public static readonly S0DimDefinition EmpWorkDuty = new( Key: "EMP_WORK_DUTY", EntityCode: "S0_EMP_WORK_DUTY", SourceTable: "EmpWorkDutyMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_emp_work_duty", DimTable: "bridge_employee_work_duty", BusinessKeyColumns: ["tenant_id", "employee_code", "duty", "location_code"], SourceBizKeyColumns: ["Employee", "Duty", "Location"], ParentDimTable: "dim_employee", ParentKeyColumns: ["employee_code"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("employee_code", S0DimValueKind.Str, "Employee", Required: true), new("duty", S0DimValueKind.Str, "Duty", Required: true), new("location_code", S0DimValueKind.Str, "Location", Required: true), new("domain_code", S0DimValueKind.Str, "Domain", Required: true), new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true), new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true), new("line_code", S0DimValueKind.Str, "ProdLine"), new("item_num", S0DimValueKind.Str, "ItemNum"), new("item_num_from", S0DimValueKind.Str, "ItemNum1"), new("item_num_to", S0DimValueKind.Str, "ItemNum2"), new("emp_type", S0DimValueKind.Str, "EmpType"), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 产线岗位。源 LineSkillMaster。与 共用 /// mdp_stg_s0_line_post,靠 source_table 隔离(同 Location/LocationShelf 范式)。 /// /// 身份 = tenant_id + ProdLine + JOBNo,实测租户内重复 0; /// Domain 每租户单值 ⇒ LEGACY_SCOPE,不入身份。 /// JOBNo 当前每租户只有一个取值(102),零区分力,但它是 CRUD 必填的岗位码, /// **保留在身份中** —— 去掉会造成不可逆的键收缩,将来多岗位时无法恢复。 /// /// 不挂父维度,三条理由:① ProdLine → LineMaster.Line 同租户孤儿 28/57, /// 且这 28 个码在**任何租户**都不存在(是被引用主数据整体缺失,不是租户错挂); /// ② CRUD 对 ProdLine 不做任何存在性校验(对比 EmpWorkDuty 有 EmployeeExistsInTenantAsync); /// ③ 与既有范式一致 —— dim_line.location_code / dim_employee.work_center_code /// 都是同名软引用、不声明父。挂父只会每轮产出无人能闭环的 WARN。 /// /// 🔴 源侧 IX_LineSkillMasterSite 而 Site **0/57 全 NULL**, /// MySQL UNIQUE 对 NULL 永不判等 ⇒ **源侧当前对本表没有任何唯一性约束**, /// 观测到的 0 重复是无守卫的巧合。dim 的唯一键将是第一道真守卫。 /// /// ⚠️ 3 行 ProdLine 带尾随空格('A01 '/'S01 '/'S02 ')。 /// utf8mb4_0900_ai_ci 是 NO PAD,尾空格参与比较,是真实业务键差异 —— **原样透传,禁止 TRIM**。 /// public static readonly S0DimDefinition LinePost = new( Key: "LINE_POST", EntityCode: "S0_LINE_POST", SourceTable: "LineSkillMaster", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_line_post", DimTable: "dim_line_post", BusinessKeyColumns: ["tenant_id", "line_code", "job_no"], SourceBizKeyColumns: ["ProdLine", "JOBNo"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("line_code", S0DimValueKind.Str, "ProdLine", Required: true), new("job_no", S0DimValueKind.Str, "JOBNo", Required: true), new("domain_code", S0DimValueKind.Str, "Domain", Required: true), new("job_name", S0DimValueKind.Str, "JOBDescr"), // 0/57 全空,但它在源 UK 里 —— 保留可见性,否则将来身份被破坏时看不出原因 new("site", S0DimValueKind.Str, "Site"), new("order_by", S0DimValueKind.Int, "Orderby"), // Precision/Scale 必须与源 decimal(10,3) 逐位相同:对账两侧都走 CAST(expr AS CHAR), // scale 不同会让 '0.000' 与 '0.0' 不等,属性校验和当场 FAIL。 new("run_crew", S0DimValueKind.Dec, "RunCrew", Precision: 10, Scale: 3), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// /// 产线岗位技能桥。源 LineSkillDetail —— **本仓第 4 张 dual-column 表,且是最危险的一张**。 /// /// 🔴 它的四对双列里有三对是「有值但发散」型(影子列 250/261 非空, /// 但与真列**没有一行相等**)—— 取错不会得到 NULL,会得到**看起来合法的错值**: /// RecID(真) vs id(影) · LineSkillRecID(真) vs line_skill_master_id(影) · /// PersonSkillId(真) vs person_skill_id(影)。 /// 第四对是恒空型:StartDate(真, 250/261) vs effective_date(影, **0/261**)。 /// ⚠️ 本 dim 的目标列**取名** effective_date,而它的源物理列是 StartDate —— /// 名字与源表那个影子列同名,是本批最容易取错的一处,已加契约测试钉死。 /// /// 父连接必须走自然键:代理键已被污染 —— /// LineSkillRecID → LineSkillMaster.RecID 同租户孤儿 **210/261**,忽略租户后 /// **175 行解析到别的租户**;PersonSkillId 更甚,**250/261 属于别的租户**。 /// 自然键 (tenant, ProdLine, JOBNo) 连父三租户**全 0 孤儿**。 /// 两个 legacy id 只作留痕列物化,**任何 join 都不得使用**。 /// /// SkillNo 同租户在 PersonSkill 不命中 210/261 —— /// **不因为都叫 Skill 就建父子关系**(与 已有的同款否决一致)。 /// public static readonly S0DimDefinition LinePostSkill = new( Key: "LINE_POST_SKILL", EntityCode: "S0_LINE_POST_SKILL", SourceTable: "LineSkillDetail", SourceSystem: LocalSourceSystem, StagingTable: "mdp_stg_s0_line_post", DimTable: "bridge_line_post_skill", BusinessKeyColumns: ["tenant_id", "line_code", "job_no", "skill_code"], SourceBizKeyColumns: ["ProdLine", "JOBNo", "SkillNo"], ParentDimTable: "dim_line_post", ParentKeyColumns: ["line_code", "job_no"], Columns: [ new("tenant_id", S0DimValueKind.TenantIdColumn), new("line_code", S0DimValueKind.Str, "ProdLine", Required: true), new("job_no", S0DimValueKind.Str, "JOBNo", Required: true), new("skill_code", S0DimValueKind.Str, "SkillNo", Required: true), // 源可空(虽然 261/261 有值):设 Required 会让未来的 NULL 行被静默滤掉并引发全批 FAIL new("domain_code", S0DimValueKind.Str, "Domain"), new("site", S0DimValueKind.Str, "Site"), // 🔴 源物理列是 StartDate,**不是**同表那个 0/261 的影子列 effective_date new("effective_date", S0DimValueKind.DateTimeSec, "StartDate"), new("required_level", S0DimValueKind.Str, "required_level"), new("remark", S0DimValueKind.Str, "remark"), new("is_enabled", S0DimValueKind.BoolTrue, "is_enabled"), // 仅留痕,禁止 join —— 见类型注释 new("legacy_line_skill_rec_id", S0DimValueKind.Int, "LineSkillRecID"), new("legacy_person_skill_id", S0DimValueKind.Int, "PersonSkillId"), new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime") ]); /// 全部维度,按依赖顺序(父在前,子在后 —— 孤儿判定需要父维度先就绪)。 public static readonly IReadOnlyList All = [ // C1 主数据 WorkCenter, Department, Location, LocationShelf, Employee, Line, Customer, PersonSkill, // C2-1 独立字典 LabelType, SopFileType, QualityBaseType, // C2-2 qms 字典族(共用 mdp_stg_s0_qms_dict) SamplingScheme, InspectionItem, InspectionMethod, InspectionFrequency, InspectionInstrument, // C1 扩展:供应商(S0 Supplier 权威)与物料(S0 Item 权威; // 后续 6 张 C3 都要挂 Item 作父,故 Item 必须排在 C3 之前) Supplier, Item, // C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前) EmpWorkDuty, LinePost, LinePostSkill, // C3-A 物料桥(父 dim_item,必须排在 Item 之后) ItemOpLine, ItemRoutingOp, // C3-C 物料桥(父 dim_item) ItemOpCondition, ItemOpImage, // C3-B 物料桥(父 dim_item) ItemBom, ItemSubstitute ]; /// 按 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 重复"); } } }