S0DimCatalog.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
  2. /// <summary>
  3. /// S0 主数据维度清单 —— **本模块唯一的业务知识落点**。
  4. ///
  5. /// 新增一个维度 = 在此追加一个 <see cref="S0DimDefinition"/>,
  6. /// 不得在 Materializer / Reconciler 中出现按 Key 分支的逻辑。
  7. ///
  8. /// 🔴 JsonPath 一律是**源表物理列名**,不是实体属性名。改动前先核对 <c>SHOW CREATE TABLE</c>。
  9. /// </summary>
  10. public static class S0DimCatalog
  11. {
  12. /// <summary>本库样板源(<c>mdp_source.source_code</c>),入站为只读 SELECT。</summary>
  13. public const string LocalSourceSystem = "AIDOPDEV_MYSQL";
  14. /// <summary>
  15. /// 工作中心。源 <c>WorkCtrMaster</c>(无 company/factory 列,故 dim 也不设 —— 不为统一而发明数据)。
  16. /// </summary>
  17. public static readonly S0DimDefinition WorkCenter = new(
  18. Key: "WORK_CENTER",
  19. EntityCode: "S0_WORK_CENTER",
  20. SourceTable: "WorkCtrMaster",
  21. SourceSystem: LocalSourceSystem,
  22. StagingTable: "mdp_stg_s0_work_center",
  23. DimTable: "dim_work_center",
  24. BusinessKeyColumns: ["tenant_id", "domain_code", "work_center_code"],
  25. SourceBizKeyColumns: ["Domain", "WorkCtr"],
  26. Columns:
  27. [
  28. new("tenant_id", S0DimValueKind.TenantIdColumn),
  29. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  30. new("work_center_code", S0DimValueKind.Str, "WorkCtr", Required: true),
  31. new("work_center_name", S0DimValueKind.Str, "Descr"),
  32. new("department_code", S0DimValueKind.Str, "Department"),
  33. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  34. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  35. ]);
  36. /// <summary>
  37. /// 部门。源 <c>DepartmentMaster</c>。
  38. /// company/factory 仅作属性透传:实测同一 (tenant, Domain) 下 factory 同时存在 legacy 数值与 SysOrg 雪花两种值,
  39. /// 与身份正交,本阶段不做组织域治理。
  40. /// </summary>
  41. public static readonly S0DimDefinition Department = new(
  42. Key: "DEPARTMENT",
  43. EntityCode: "S0_DEPARTMENT",
  44. SourceTable: "DepartmentMaster",
  45. SourceSystem: LocalSourceSystem,
  46. StagingTable: "mdp_stg_s0_department",
  47. DimTable: "dim_department",
  48. BusinessKeyColumns: ["tenant_id", "domain_code", "department_code"],
  49. SourceBizKeyColumns: ["Domain", "Department"],
  50. Columns:
  51. [
  52. new("tenant_id", S0DimValueKind.TenantIdColumn),
  53. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  54. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  55. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  56. new("department_code", S0DimValueKind.Str, "Department", Required: true),
  57. new("department_name", S0DimValueKind.Str, "Descr"),
  58. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  59. // 源侧 238/239 非空:UATDEMO 的「未分配」行 UpdateTime 恒 NULL。
  60. // 本 definition 走 FULL(mdp_entity.incr_column = NULL),不生成 "UpdateTime > cursor" 谓词,
  61. // 因此该行每轮都能进 stg 与 dim;切 INCR 前必须先落 keyset NullTimePhase 两阶段。
  62. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  63. ]);
  64. /// <summary>
  65. /// 库位。源 <c>LocationMaster</c> —— **dual-column 复刻表**。
  66. ///
  67. /// 🔴 以下 JsonPath 全部是 PascalCase 真列,同表存在同名 snake_case 影子列且**恒空/已发散**,
  68. /// 取错即静默得到空值:
  69. /// <c>Domain</c>(真) vs <c>domain_code</c>(132 行全空串) ·
  70. /// <c>IsActive</c>(真) vs <c>is_active</c>(132 行中仅 1 行与真列相等) ·
  71. /// <c>PhysicalAddress</c>(42 非空) vs <c>physical_address</c>(0 非空) ·
  72. /// <c>CreateUser</c>(119 非空) vs <c>create_user</c>(0 非空) ·
  73. /// <c>RecID</c>(真主键) vs <c>rec_id</c>(恒 0)。
  74. ///
  75. /// 身份取 (tenant, domain_code, location_code):与子表父引用一致,且在源侧索引口径变化时仍成立;
  76. /// 另用 <see cref="S0DimDefinition.MirrorUniqueColumns"/> 镜像当前源 UK (tenant_id, location)。
  77. /// </summary>
  78. public static readonly S0DimDefinition Location = new(
  79. Key: "LOCATION",
  80. EntityCode: "S0_LOCATION",
  81. SourceTable: "LocationMaster",
  82. SourceSystem: LocalSourceSystem,
  83. StagingTable: "mdp_stg_s0_location",
  84. DimTable: "dim_location",
  85. BusinessKeyColumns: ["tenant_id", "domain_code", "location_code"],
  86. SourceBizKeyColumns: ["Domain", "location"],
  87. MirrorUniqueColumns: ["tenant_id", "location_code"],
  88. Columns:
  89. [
  90. new("tenant_id", S0DimValueKind.TenantIdColumn),
  91. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  92. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  93. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  94. new("location_code", S0DimValueKind.Str, "location", Required: true),
  95. new("location_name", S0DimValueKind.Str, "descr"),
  96. new("location_type", S0DimValueKind.Str, "typed"),
  97. new("storer", S0DimValueKind.Str, "storer"),
  98. new("physical_address", S0DimValueKind.Str, "PhysicalAddress"),
  99. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  100. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  101. ]);
  102. /// <summary>
  103. /// 货架(库位子维度)。源 <c>LocationShelfMaster</c>。
  104. ///
  105. /// 与 <see cref="Location"/> 共用 <c>mdp_stg_s0_location</c>,靠 <c>source_table</c> 隔离,
  106. /// 两者是**两个独立 definition**,绝不合并成一次转换。
  107. ///
  108. /// 本表**全列 snake_case,没有影子列** —— 不可套用 LocationMaster 的禁用规则。
  109. /// 另:源 <c>update_time</c> 0/30 非空(无水位)、主键 <c>rec_id</c> 不在通用行标识候选列表中
  110. /// 且每次父表 FULL Replace 都会变,故物化只认 <c>sync_batch_id</c>,不依赖 source_row_id 稳定性。
  111. /// 无 is_active —— 源表没有该列,不发明。
  112. /// </summary>
  113. public static readonly S0DimDefinition LocationShelf = new(
  114. Key: "LOCATION_SHELF",
  115. EntityCode: "S0_LOCATION_SHELF",
  116. SourceTable: "LocationShelfMaster",
  117. SourceSystem: LocalSourceSystem,
  118. StagingTable: "mdp_stg_s0_location",
  119. DimTable: "dim_location_shelf",
  120. BusinessKeyColumns: ["tenant_id", "domain_code", "location_code", "shelf_code"],
  121. SourceBizKeyColumns: ["domain_code", "location", "inv_shelf"],
  122. ParentDimTable: "dim_location",
  123. ParentKeyColumns: ["domain_code", "location_code"],
  124. Columns:
  125. [
  126. new("tenant_id", S0DimValueKind.TenantIdColumn),
  127. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  128. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  129. new("domain_code", S0DimValueKind.Str, "domain_code", Required: true),
  130. new("location_code", S0DimValueKind.Str, "location", Required: true),
  131. new("shelf_code", S0DimValueKind.Str, "inv_shelf", Required: true),
  132. new("shelf_name", S0DimValueKind.Str, "descr"),
  133. new("area", S0DimValueKind.Str, "area"),
  134. // 源恒 NULL(0/30),保留列以便源将来启用;不得为了“看起来有值”而回填
  135. new("source_updated_at", S0DimValueKind.DateTimeSec, "update_time")
  136. ]);
  137. /// <summary>
  138. /// 雇员。源 <c>EmployeeMaster</c>(29 列,无影子列,2026-09-07 实测确认)。
  139. ///
  140. /// <para><b>身份 = tenant_id + Employee</b>。<c>Domain</c> 是属性,<b>不是</b>身份 ——
  141. /// 这条口径不是从旧物理唯一键推的,而是按「当前业务证据优先」重判的结果(2026-09-07):</para>
  142. /// <list type="number">
  143. /// <item>当前 CRUD 查重是 <c>tenant + FactoryRefId + Employee</c>
  144. /// (<c>AdoS0EmployeesController</c>),**根本不含 Domain**;</item>
  145. /// <item>其中 <c>FactoryRefId</c> 由 <c>AdoS0OrgScope.ResolveAsync(ctx, tenantId)</c>
  146. /// **仅按租户**解析并强制 stamp(payload 值一律覆盖),是 tenant 的函数、零区分力,
  147. /// 故该查重等价于 <c>tenant + Employee</c>;</item>
  148. /// <item>单条定位走 <c>ByIdScopedAsync(id, tenantId)</c>,不经 Domain;</item>
  149. /// <item>真实 consumer 一致按工号关联:<c>LEFT JOIN EmployeeMaster e
  150. /// ON p.Buyer = e.Employee AND e.tenant_id = p.tenant_id</c>
  151. /// (DemandOrderService / OutsourceOrderService / ProcessOutsourceOrderService 共 5 处),
  152. /// **无一处用 Domain**;</item>
  153. /// <item>数据实测:<c>(tenant_id, Employee)</c> 租户内重复 = <b>0</b>,
  154. /// 且不存在「同租户同工号不同 Domain」的行。</item>
  155. /// </list>
  156. /// <para>租户仍必须入身份:<c>Employee</c> 跨租户碰撞实测 19 组 / 38 行
  157. /// (<c>G02795</c> / <c>ZHR01</c> / <c>RQTJ993</c> 等工号被多个租户各自使用)。
  158. /// 旧物理唯一键 <c>uk_EmployeeMaster_domain_employee</c> 含 Domain,属 legacy scope,
  159. /// 按 Owner 口径「不因存在于旧表/旧 UK 就自动成为 DIM identity」,**不予继承**。</para>
  160. ///
  161. /// <para>🔴 <c>sys_user_id</c> <b>只是属性,绝不参与业务键</b>:实测 1105 行中仅 <b>7 行</b>非空(0.6%),
  162. /// 它是「员工恰好也有系统账号」的可选链接,不是员工身份。把账号 id 当员工主键会让 99.4% 的员工无法标识。</para>
  163. ///
  164. /// <para>字段适用性分类:<c>employee_code</c>=CORE;<c>domain_code</c>=ATTRIBUTE
  165. /// (仍被部门父连接与 CRUD 的部门引用校验 <c>DepartmentExistsInTenantAsync(tenantId, domain, dept)</c> 使用);
  166. /// <c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE(f(tenant),保留仅为可追溯到 SysOrg 节点);
  167. /// <c>sys_user_id</c>=可选 ATTRIBUTE,永远不入键。</para>
  168. /// </summary>
  169. public static readonly S0DimDefinition Employee = new(
  170. Key: "EMPLOYEE",
  171. EntityCode: "S0_EMPLOYEE",
  172. SourceTable: "EmployeeMaster",
  173. SourceSystem: LocalSourceSystem,
  174. StagingTable: "mdp_stg_s0_employee",
  175. DimTable: "dim_employee",
  176. BusinessKeyColumns: ["tenant_id", "employee_code"],
  177. SourceBizKeyColumns: ["Employee"],
  178. ParentDimTable: "dim_department",
  179. ParentKeyColumns: ["domain_code", "department_code"],
  180. Columns:
  181. [
  182. new("tenant_id", S0DimValueKind.TenantIdColumn),
  183. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  184. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  185. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  186. new("employee_code", S0DimValueKind.Str, "Employee", Required: true),
  187. new("employee_name", S0DimValueKind.Str, "Name"),
  188. // 自由文本软引用:源 813 非空中约 97 在 DepartmentMaster 找不到,另 292 行未分配。
  189. // 未分配(NULL)不算孤儿,见 S0DimSqlBuilder.OrphanFromWhere。
  190. new("department_code", S0DimValueKind.Str, "Department"),
  191. new("job_title", S0DimValueKind.Str, "JobTitle"),
  192. new("work_center_code", S0DimValueKind.Str, "WorkCtr"),
  193. new("employment_status", S0DimValueKind.Str, "EmploymentStatus"),
  194. new("date_employed", S0DimValueKind.DateTimeSec, "DateEmployed"),
  195. new("date_terminated", S0DimValueKind.DateTimeSec, "DateTerminated"),
  196. // 稀疏(7/1105)。允许 NULL 透传,不得回填,也不得据此过滤行。
  197. new("sys_user_id", S0DimValueKind.Int, "sys_user_id"),
  198. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  199. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  200. ]);
  201. /// <summary>
  202. /// 生产线。源 <c>LineMaster</c>。
  203. ///
  204. /// <para><b>身份 = tenant_id + Line</b>。company / factory / Domain 全是属性,<b>不是</b>身份 ——
  205. /// 同样按「当前业务证据优先」重判(2026-09-07),旧物理唯一键
  206. /// <c>uk_LineMaster_scope_line(tenant_id, company_ref_id, factory_ref_id, Line)</c> 不予继承:</para>
  207. /// <list type="number">
  208. /// <item>当前 CRUD <b>没有任何查重守卫</b>(<c>AdoS0MfgProductionLinesController</c> 无
  209. /// <c>IsAnyAsync</c>/<c>DuplicateCode</c>),单条定位走 <c>ByIdScopedAsync(id, tenantId)</c>;</item>
  210. /// <item><c>CompanyRefId</c>/<c>FactoryRefId</c> 在控制器里只是**可选查询筛选**
  211. /// (<c>WhereIF(q.CompanyRefId.HasValue, ...)</c>),创建时由
  212. /// <c>AdoS0OrgScope.ResolveAsync</c> 按租户强制 stamp,不是用户输入;</item>
  213. /// <item>真实 consumer <c>S8MasterDataAdapter.GetLinesAsync</c> 是
  214. /// <c>Where(x =&gt; x.TenantId == scope.TenantId).Select(code = x.Line)</c> —— 只有租户 + 产线码;</item>
  215. /// <item>数据实测:四个候选(tenant+Line / +Domain / +factory / +company+factory)
  216. /// 租户内重复<b>全部为 0</b>;且**每个租户在本表只有 1 个 Domain、1 个 company、1 个 factory**
  217. /// ⇒ 这三列在租户内是常量,<b>零区分力</b>。</item>
  218. /// </list>
  219. /// <para>租户仍必须入身份:<c>Line</c> 跨租户碰撞实测 20 组 / 80 行(如 <c>AE01</c> 在 4 个租户都存在)。</para>
  220. ///
  221. /// <para>字段适用性分类:<c>line_code</c>=CORE;<c>line_name</c>/<c>line_type</c>/<c>line_category</c>/
  222. /// <c>workshop</c>/<c>location_code</c>/<c>is_active</c>=ATTRIBUTE;
  223. /// <c>domain_code</c>/<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE(租户内常量,保留仅为可追溯)。</para>
  224. ///
  225. /// <para>🔴 <c>Describe</c> 是真列(169/169 填充、63 distinct);同表的 <c>line_describe</c>
  226. /// 是<b>恒空影子列</b>(0/169 填充),取错即静默得到全空的产线名称。
  227. /// 这是本仓第三例此型双列表(前两例:LocationMaster、StdOpMaster)。</para>
  228. ///
  229. /// <para>⚠️ <c>factory_ref_id</c> 参与身份,但**绝不能**设进 <c>MdpPullContext.FactoryId</c>:
  230. /// <c>MdpStagingWriter</c> 只识别 <c>factory_id</c>/<c>FactoryId</c> 两个列名,
  231. /// 本表叫 <c>factory_ref_id</c> → 解析为 null → 一旦 <c>ctx.FactoryId &gt; 0</c>,
  232. /// 比较基准会退化成 1 并让**整批行全部被跳过**(KNOWN-ISSUES I-015)。
  233. /// S0 的 pullCtx 只设 TenantId,该分支永不进入。</para>
  234. /// </summary>
  235. public static readonly S0DimDefinition Line = new(
  236. Key: "LINE",
  237. EntityCode: "S0_LINE",
  238. SourceTable: "LineMaster",
  239. SourceSystem: LocalSourceSystem,
  240. StagingTable: "mdp_stg_s0_line",
  241. DimTable: "dim_line",
  242. BusinessKeyColumns: ["tenant_id", "line_code"],
  243. SourceBizKeyColumns: ["Line"],
  244. Columns:
  245. [
  246. new("tenant_id", S0DimValueKind.TenantIdColumn),
  247. new("line_code", S0DimValueKind.Str, "Line", Required: true),
  248. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  249. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  250. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  251. new("line_name", S0DimValueKind.Str, "Describe"),
  252. new("line_type", S0DimValueKind.Str, "LineType"),
  253. new("line_category", S0DimValueKind.Str, "LineCategory"),
  254. new("workshop", S0DimValueKind.Str, "Workshop"),
  255. new("location_code", S0DimValueKind.Str, "Location"),
  256. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  257. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  258. ]);
  259. /// <summary>
  260. /// 客户。源 <c>CustMaster</c>(32 列,无影子列对;空列极多,只物化有语义且有数据的)。
  261. ///
  262. /// <para><b>身份 = tenant_id + Cust</b>。同样按「当前业务证据优先」重判:</para>
  263. /// <list type="number">
  264. /// <item>CRUD 查重 <c>tenant + FactoryRefId + Cust</c>(<c>AdoS0CustomersController:70,117</c>),
  265. /// 而 <c>FactoryRefId</c> 由 <c>AdoS0OrgScope</c> 仅按租户解析并强制 stamp ⇒ 等价 <c>tenant + Cust</c>;</item>
  266. /// <item><c>DomainCode</c> 在控制器里只是**可选列表筛选**(<c>WhereIF(...)</c>),不参与查重;</item>
  267. /// <item>单条定位 <c>ByIdScopedAsync(id, tenantId)</c>;</item>
  268. /// <item>数据实测:三个候选(tenant+Cust / +Domain / +factory)租户内重复**全部为 0**,
  269. /// 且 company/factory 在每个租户内均为**单一取值**(常量、零区分力)。</item>
  270. /// </list>
  271. /// <para>租户必须入身份:<c>Cust</c> 跨租户碰撞实测 <b>4 个编码</b>。</para>
  272. ///
  273. /// <para>🔴 源表另有一条 <b>不含 tenant_id</b> 的旧唯一键 <c>uk_CustMaster_factory_cust(factory_ref_id, Cust)</c>,
  274. /// 且 <c>factory_ref_id=1000</c> 被两个租户共用 —— 属源表治理缺陷(<c>KNOWN-ISSUES I-016</c>),
  275. /// **它不能反过来决定 DIM identity**;dim 侧自带 <c>tenant_id</c> 前缀,不受其影响。
  276. /// 本批不动源表 DDL。</para>
  277. ///
  278. /// <para>字段适用性:<c>customer_code</c>=CORE;<c>customer_name</c>/<c>is_active</c>/
  279. /// <c>is_confirmed</c>/<c>shipping_lead_time</c>=ATTRIBUTE;
  280. /// <c>domain_code</c>/<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE。
  281. /// 全空列(address / contact / Curr / Salesperson1 / Terms / cust_class / shipping_address 等 13 列,
  282. /// 填充率 0/1333)与近空列(Class 7 · TaxClass 3 · CustFullName 2 · Remarks 1)
  283. /// = DROP_FROM_STANDARD,不物化。</para>
  284. /// </summary>
  285. public static readonly S0DimDefinition Customer = new(
  286. Key: "CUSTOMER",
  287. EntityCode: "S0_CUSTOMER",
  288. SourceTable: "CustMaster",
  289. SourceSystem: LocalSourceSystem,
  290. StagingTable: "mdp_stg_s0_customer",
  291. DimTable: "dim_customer",
  292. BusinessKeyColumns: ["tenant_id", "customer_code"],
  293. SourceBizKeyColumns: ["Cust"],
  294. Columns:
  295. [
  296. new("tenant_id", S0DimValueKind.TenantIdColumn),
  297. new("customer_code", S0DimValueKind.Str, "Cust", Required: true),
  298. new("customer_name", S0DimValueKind.Str, "SortName"),
  299. new("domain_code", S0DimValueKind.Str, "Domain"),
  300. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  301. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  302. new("shipping_lead_time", S0DimValueKind.Int, "CustShippingLT"),
  303. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  304. new("is_confirmed", S0DimValueKind.BoolTrue, "IsConfirm"),
  305. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  306. ]);
  307. /// <summary>
  308. /// 人员技能字典。源 <c>PersonSkill</c>(11 列)—— 是**技能主数据**,不是关系表。
  309. ///
  310. /// <para><b>身份 = tenant_id + Code</b>。最强的一条证据来自现有消费方的注释与实现
  311. /// (<c>AdoS0MfgLinePostsController.ResolvePersonSkillCodesAsync</c>):
  312. /// 「PersonSkill 表没有 Domain 列,故不按 Domain 收窄;产线岗位主表也不带
  313. /// CompanyRefId / FactoryRefId,无法据此再收窄,**因此按 (租户, Code) 解析**并对多义显式报错」——
  314. /// 即当前业务**已经**把技能码当作租户级字典,并把「同租户跨公司/工厂重码」视为
  315. /// <b>歧义错误</b>而非两个不同技能。</para>
  316. /// <para>数据佐证:三个候选(tenant+Code / +Factory / +Company+Factory)租户内重复全部为 0,
  317. /// company/factory 每租户单一取值。<c>Code</c> 跨租户碰撞 5 个编码 ⇒ 租户必须入身份。</para>
  318. ///
  319. /// <para>⚠️ <b>不设父维度</b>:<c>EmpSkills.SkillNo</c> 与本表 <c>Code</c> 同租户实测
  320. /// 650 行中 <b>632 行对不上</b>(仅 18 命中)—— 两者当前是各自独立的编码体系。
  321. /// **不因为都叫 Skill 就建立虚假父子关系。**</para>
  322. ///
  323. /// <para><c>source_updated_at ← UpdatedAt</c>:源 46 行中 <c>CreatedAt</c> 全非空、
  324. /// <c>UpdatedAt</c> 仅 5 行非空 —— 这正是「维护时间」的形态(只有被改过的行才有值),
  325. /// 故映射之,NULL 表示自建以来未修改。**不用 CreatedAt 冒充,也不回填。**</para>
  326. ///
  327. /// <para>字段适用性:<c>skill_code</c>=CORE;<c>skill_name</c>/<c>skill_level</c>/
  328. /// <c>remark</c>/<c>is_active</c>=ATTRIBUTE;<c>company_id</c>/<c>factory_id</c>=LEGACY_SCOPE。</para>
  329. /// </summary>
  330. public static readonly S0DimDefinition PersonSkill = new(
  331. Key: "PERSON_SKILL",
  332. EntityCode: "S0_PERSON_SKILL",
  333. SourceTable: "PersonSkill",
  334. SourceSystem: LocalSourceSystem,
  335. StagingTable: "mdp_stg_s0_person_skill",
  336. DimTable: "dim_person_skill",
  337. BusinessKeyColumns: ["tenant_id", "skill_code"],
  338. SourceBizKeyColumns: ["Code"],
  339. Columns:
  340. [
  341. new("tenant_id", S0DimValueKind.TenantIdColumn),
  342. new("skill_code", S0DimValueKind.Str, "Code", Required: true),
  343. new("skill_name", S0DimValueKind.Str, "Name"),
  344. new("skill_level", S0DimValueKind.Str, "SkillLevel"),
  345. new("remark", S0DimValueKind.Str, "Remark"),
  346. new("company_id", S0DimValueKind.Int, "CompanyRefId", Required: true),
  347. new("factory_id", S0DimValueKind.Int, "FactoryRefId", Required: true),
  348. new("is_active", S0DimValueKind.BoolTrue, "IsEnabled"),
  349. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdatedAt")
  350. ]);
  351. /// <summary>全部维度,按依赖顺序(父在前,子在后 —— 孤儿判定需要父维度先就绪)。</summary>
  352. public static readonly IReadOnlyList<S0DimDefinition> All =
  353. [WorkCenter, Department, Location, LocationShelf, Employee, Line, Customer, PersonSkill];
  354. /// <summary>按 Key 取维度(大小写不敏感);不存在返回 null。</summary>
  355. public static S0DimDefinition? Find(string? key) =>
  356. string.IsNullOrWhiteSpace(key)
  357. ? null
  358. : All.FirstOrDefault(d => string.Equals(d.Key, key.Trim(), StringComparison.OrdinalIgnoreCase));
  359. /// <summary>校验全部声明;任一不合法即抛出(供启动自检与单测使用)。</summary>
  360. public static void ValidateAll()
  361. {
  362. foreach (var d in All) d.Validate();
  363. var dupDim = All.GroupBy(d => d.DimTable, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
  364. if (dupDim is not null)
  365. throw new InvalidOperationException(
  366. $"dim 表被多个 definition 共用:{dupDim.Key} —— FULL Replace 按 tenant 整表删除,会互相清空");
  367. var dupEntity = All.GroupBy(d => d.EntityCode, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
  368. if (dupEntity is not null)
  369. throw new InvalidOperationException($"entity_code 重复:{dupEntity.Key}");
  370. // 同一 staging 被多个 definition 共用时,source_table 必须互异,否则 purge/物化会互相污染
  371. foreach (var g in All.GroupBy(d => d.StagingTable, StringComparer.Ordinal).Where(g => g.Count() > 1))
  372. {
  373. var tables = g.Select(d => d.SourceTable).ToList();
  374. if (tables.Distinct(StringComparer.Ordinal).Count() != tables.Count)
  375. throw new InvalidOperationException($"staging {g.Key} 被多个 definition 共用且 source_table 重复");
  376. }
  377. }
  378. }