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 + Employee。Domain 是属性,不是身份 ——
/// 这条口径不是从旧物理唯一键推的,而是按「当前业务证据优先」重判的结果(2026-09-07):
///
/// - 当前 CRUD 查重是 tenant + FactoryRefId + Employee
/// (AdoS0EmployeesController),**根本不含 Domain**;
/// - 其中 FactoryRefId 由 AdoS0OrgScope.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 + Cust(AdoS0CustomersController:70,117),
/// 而 FactoryRefId 由 AdoS0OrgScope 仅按租户解析并强制 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,
/// 而 FactoryRefId 由 AdoS0OrgScope 仅按租户解析并强制 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_active 是 tinyint(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")
]);
///
/// 物料。源 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_item 含 factory_ref_id,**不予继承** ——
/// 它连 f(tenant) 都不是(租户 838257186181189 有 3 个取值,含 0),
/// 且 8010 被三个租户共用,放进唯一键既无判别力又把身份绑到跨租户共享值上。
///
/// ⚠️ 单租户最大 16150 行,超过默认 batch_size 5000 ——
/// AssertNoSourceDuplicateAsync 会硬失败。本 entity 的 mdp_entity.batch_size 设 50000。
/// 该值是 **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)·
/// SuppWarranty(bit(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")
]);
// ════════════════════════════════════════════════════════════════════════════
// 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/Location 是 string? 无 [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_LineSkillMaster 含 Site 而 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 Item 权威;后续 6 张 C3 都要挂它作父,故必须排在 C3 之前)
Item,
// C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前)
EmpWorkDuty, LinePost, LinePostSkill
];
/// 按 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 重复");
}
}
}