S0DimCatalog.cs 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280
  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. // ════════════════════════════════════════════════════════════════════════════
  352. // C2 · 参考/配置字典(ref_*)
  353. //
  354. // 与 C1 维度同用一套机制,只是语义是「字典」而非「主数据实体」,故目标表用 ref_ 前缀。
  355. // 身份口径与 C1 完全一致:tenant_id + 业务码;Domain / company / factory 一律 LEGACY_SCOPE。
  356. //
  357. // 🔴 本组落地时实测出的两个框架地雷,写在这里免得下次重踩:
  358. // ① bit(1) 源列用 Int 会让对账每轮必挂(详见 S0DimValueKind.Int 的说明)。
  359. // ② qms 字典的 FSTATUS / FENABLE 是 varchar 且值域含中文与字母
  360. // (`已审核` / `已启用` / `A` / `ENABLED` / `INVALID_STATUS`),
  361. // 用 BoolTrue 会命中可辨识性守卫:4 张表**整表归零**,
  362. // 而 qms_inspectionfreq.FENABLE 恰好存活 8/13 —— 部分静默丢失,最难发现。
  363. // ⇒ 状态列一律 Str 原样透传,语义归一留给下游。
  364. // ════════════════════════════════════════════════════════════════════════════
  365. /// <summary>
  366. /// 标签类型字典。源 <c>BarCodeType</c>。
  367. ///
  368. /// <para>身份 = <c>tenant_id + bar_type</c>。CRUD 查重
  369. /// (<c>AdoS0LabelTypesController</c>)= <c>tenant + FactoryRefId + BarType</c>,
  370. /// 而 <c>FactoryRefId</c> 由 <c>AdoS0OrgScope</c> 仅按租户解析并强制 stamp ⇒ 等价 <c>tenant + bar_type</c>,
  371. /// **不含 Domain**。实测 <c>bar_type</c> 跨租户碰撞 6 个编码 ⇒ 租户必须入键。</para>
  372. ///
  373. /// <para>⚠️ 本表主键叫 <c>rec_id</c>(带下划线),不在 <c>MdpDbPullExecutor.ResolveSourceRowId</c>
  374. /// 的候选列表内(<c>rec_id ≠ recid</c>),且本表无 <c>id</c> 列 ⇒ <c>source_row_id</c> 落 GUID 兜底。
  375. /// 与 <c>LocationShelfMaster</c> 同型,**安全**:物化只认 <c>sync_batch_id</c>,
  376. /// 且 purge 先于 pull,不依赖 source_row_id 稳定性。</para>
  377. /// </summary>
  378. public static readonly S0DimDefinition LabelType = new(
  379. Key: "LABEL_TYPE",
  380. EntityCode: "S0_LABEL_TYPE",
  381. SourceTable: "BarCodeType",
  382. SourceSystem: LocalSourceSystem,
  383. StagingTable: "mdp_stg_s0_label_type",
  384. DimTable: "ref_label_type",
  385. BusinessKeyColumns: ["tenant_id", "label_type_code"],
  386. SourceBizKeyColumns: ["bar_type"],
  387. Columns:
  388. [
  389. new("tenant_id", S0DimValueKind.TenantIdColumn),
  390. new("label_type_code", S0DimValueKind.Str, "bar_type", Required: true),
  391. new("label_class", S0DimValueKind.Str, "class"),
  392. new("input_string", S0DimValueKind.Str, "input_string"),
  393. new("domain_code", S0DimValueKind.Str, "domain_code"),
  394. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  395. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  396. new("source_updated_at", S0DimValueKind.DateTimeSec, "update_time")
  397. ]);
  398. /// <summary>
  399. /// SOP 文件类型字典。源 <c>ImageType</c>(**只有 5 列**)。
  400. ///
  401. /// <para>身份 = <c>tenant_id + image_type_id</c>。控制器
  402. /// <c>AdoS0MfgSopFileTypesController</c> 无查重守卫,定位走 <c>ByIdScopedAsync(id, tenantId)</c>,
  403. /// 且已有显式「去 domain 化」注释(S0-DOMAIN-FILTER-REMOVAL-READONLY-1)。</para>
  404. ///
  405. /// <para>🔴 <b>本表没有任何时间列</b>(实测 5 列 = id/tenant_id/image_type_id/descr/domain),
  406. /// 故**不声明 <c>source_updated_at</c>**,ref 表里也不建该列。
  407. /// 不得为了「标准字段齐整」而回填 —— JsonPath 必须是真实物理列。</para>
  408. ///
  409. /// <para>注意物理列名是 <c>domain</c> 而非 <c>domain_code</c>;且其取值在三个租户间口径异构
  410. /// (<c>S0-F001</c> / 雪花号 / <c>8010</c>),更加坐实它是 LEGACY_SCOPE。</para>
  411. /// </summary>
  412. public static readonly S0DimDefinition SopFileType = new(
  413. Key: "SOP_FILE_TYPE",
  414. EntityCode: "S0_SOP_FILE_TYPE",
  415. SourceTable: "ImageType",
  416. SourceSystem: LocalSourceSystem,
  417. StagingTable: "mdp_stg_s0_sop_file_type",
  418. DimTable: "ref_sop_file_type",
  419. BusinessKeyColumns: ["tenant_id", "file_type_code"],
  420. SourceBizKeyColumns: ["image_type_id"],
  421. Columns:
  422. [
  423. new("tenant_id", S0DimValueKind.TenantIdColumn),
  424. new("file_type_code", S0DimValueKind.Str, "image_type_id", Required: true),
  425. new("file_type_name", S0DimValueKind.Str, "descr"),
  426. new("domain_code", S0DimValueKind.Str, "domain")
  427. ]);
  428. /// <summary>
  429. /// 质量基础类型字典。源 <c>qms_quality_base_type</c>。
  430. ///
  431. /// <para>身份 = <c>tenant_id + type_category + type_code</c>,**两段业务码**。
  432. /// <c>type_code</c> 单列在当前数据里也唯一,但那是运气 —— 代码硬证据:
  433. /// <c>AdoS0QualityBaseDataControllers</c> 的 Create/Update 两处查重均按
  434. /// <c>(TypeCategory, TypeCode)</c>,注释亦写明「(TypeCategory, TypeCode) 全局唯一」。
  435. /// 按「当前业务证据优先」取两段。源表无任何 UK。</para>
  436. ///
  437. /// <para>本表无 Domain / company / factory 列 —— 不设、不发明(同 <c>WorkCtrMaster</c> 先例)。
  438. /// <c>is_active</c> 是 <c>tinyint(1) NOT NULL</c>,在 raw_data 里是 JSON BOOLEAN,可安全走 BoolTrue。</para>
  439. /// </summary>
  440. public static readonly S0DimDefinition QualityBaseType = new(
  441. Key: "QUALITY_BASE_TYPE",
  442. EntityCode: "S0_QUALITY_BASE_TYPE",
  443. SourceTable: "qms_quality_base_type",
  444. SourceSystem: LocalSourceSystem,
  445. StagingTable: "mdp_stg_s0_quality_base_type",
  446. DimTable: "ref_quality_base_type",
  447. BusinessKeyColumns: ["tenant_id", "type_category", "type_code"],
  448. SourceBizKeyColumns: ["type_category", "type_code"],
  449. Columns:
  450. [
  451. new("tenant_id", S0DimValueKind.TenantIdColumn),
  452. new("type_category", S0DimValueKind.Str, "type_category", Required: true),
  453. new("type_code", S0DimValueKind.Str, "type_code", Required: true),
  454. new("short_name", S0DimValueKind.Str, "short_name"),
  455. new("full_name", S0DimValueKind.Str, "full_name"),
  456. new("remark", S0DimValueKind.Str, "remark"),
  457. new("is_active", S0DimValueKind.BoolTrue, "is_active"),
  458. new("source_updated_at", S0DimValueKind.DateTimeSec, "modify_time")
  459. ]);
  460. /// <summary>
  461. /// 抽样方案字典。源 <c>qms_sampscheme</c>。C2-2 qms 字典族共用 <c>mdp_stg_s0_qms_dict</c>,靠 <c>source_table</c> 隔离。
  462. /// <para>🔴 <c>FSTATUS</c>/<c>FENABLE</c> 必须 <c>Str</c>:实测值域 <c>已审核×8 · DISABLED×1 · INVALID_STATUS×1</c>
  463. /// 与 <c>已启用×8 · ENABLED×2</c>,用 BoolTrue 会**整表归零**。</para>
  464. /// </summary>
  465. public static readonly S0DimDefinition SamplingScheme = new(
  466. Key: "SAMPLING_SCHEME",
  467. EntityCode: "S0_SAMPLING_SCHEME",
  468. SourceTable: "qms_sampscheme",
  469. SourceSystem: LocalSourceSystem,
  470. StagingTable: "mdp_stg_s0_qms_dict",
  471. DimTable: "ref_sampling_scheme",
  472. BusinessKeyColumns: ["tenant_id", "scheme_code"],
  473. SourceBizKeyColumns: ["FNUMBER"],
  474. Columns:
  475. [
  476. new("tenant_id", S0DimValueKind.TenantIdColumn),
  477. new("scheme_code", S0DimValueKind.Str, "FNUMBER", Required: true),
  478. new("scheme_name", S0DimValueKind.Str, "FNAME"),
  479. new("sampling_type", S0DimValueKind.Str, "FSAMPLINGTYPE"),
  480. new("inspection_level", S0DimValueKind.Str, "FINSPECTIONLEVEL"),
  481. new("strictness", S0DimValueKind.Str, "FSTRICTNESS"),
  482. new("aql_value", S0DimValueKind.Str, "FAQLVALUE"),
  483. new("inspection_type", S0DimValueKind.Str, "FINSPECTIONTYPE"),
  484. new("status_code", S0DimValueKind.Str, "FSTATUS"),
  485. new("enable_code", S0DimValueKind.Str, "FENABLE"),
  486. new("remark", S0DimValueKind.Str, "FCOMMENT"),
  487. new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME")
  488. ]);
  489. /// <summary>
  490. /// 检验项目字典。源 <c>qms_inspectionitems</c>。
  491. /// <para>⚠️ <c>FCHECKMETHOD</c>/<c>FCHECKBASIS</c> 指向的是**源表代理主键 <c>id</c>**,不是业务键,
  492. /// 故只作普通 Int 属性透传,**不声明父维度**(框架的孤儿判定按业务键列做 join,对不上)。
  493. /// 同 <c>dim_employee.sys_user_id</c> 先例。</para>
  494. /// </summary>
  495. public static readonly S0DimDefinition InspectionItem = new(
  496. Key: "INSPECTION_ITEM",
  497. EntityCode: "S0_INSPECTION_ITEM",
  498. SourceTable: "qms_inspectionitems",
  499. SourceSystem: LocalSourceSystem,
  500. StagingTable: "mdp_stg_s0_qms_dict",
  501. DimTable: "ref_inspection_item",
  502. BusinessKeyColumns: ["tenant_id", "item_code"],
  503. SourceBizKeyColumns: ["FNUMBER"],
  504. Columns:
  505. [
  506. new("tenant_id", S0DimValueKind.TenantIdColumn),
  507. new("item_code", S0DimValueKind.Str, "FNUMBER", Required: true),
  508. new("item_name", S0DimValueKind.Str, "FNAME"),
  509. new("check_method_id", S0DimValueKind.Int, "FCHECKMETHOD"),
  510. new("check_basis_id", S0DimValueKind.Int, "FCHECKBASIS"),
  511. new("status_code", S0DimValueKind.Str, "FSTATUS"),
  512. new("enable_code", S0DimValueKind.Str, "FENABLE"),
  513. new("remark", S0DimValueKind.Str, "FCOMMENT"),
  514. new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME")
  515. ]);
  516. /// <summary>检验方法字典。源 <c>qms_inspection_method</c>。状态列同族一律 <c>Str</c>。</summary>
  517. public static readonly S0DimDefinition InspectionMethod = new(
  518. Key: "INSPECTION_METHOD",
  519. EntityCode: "S0_INSPECTION_METHOD",
  520. SourceTable: "qms_inspection_method",
  521. SourceSystem: LocalSourceSystem,
  522. StagingTable: "mdp_stg_s0_qms_dict",
  523. DimTable: "ref_inspection_method",
  524. BusinessKeyColumns: ["tenant_id", "method_code"],
  525. SourceBizKeyColumns: ["FNUMBER"],
  526. Columns:
  527. [
  528. new("tenant_id", S0DimValueKind.TenantIdColumn),
  529. new("method_code", S0DimValueKind.Str, "FNUMBER", Required: true),
  530. new("method_name", S0DimValueKind.Str, "FNAME"),
  531. new("control_strategy", S0DimValueKind.Str, "FCTRLSTRATEGY"),
  532. new("status_code", S0DimValueKind.Str, "FSTATUS"),
  533. new("enable_code", S0DimValueKind.Str, "FENABLE"),
  534. new("remark", S0DimValueKind.Str, "FCOMMENT"),
  535. new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME")
  536. ]);
  537. /// <summary>
  538. /// 检验频次字典。源 <c>qms_inspectionfreq</c>。
  539. /// <para>🔴 <b>本表与兄弟表有两处不同,照抄会出错</b>:
  540. /// ① 备注列叫 <c>FREMARK</c>,**不是** <c>FCOMMENT</c>;
  541. /// ② <c>FENABLE</c> 的值域是 <c>'1'×8 · ENABLED×5</c> —— 用 BoolTrue 会**存活 8 行、静默丢 5 行**,
  542. /// 是全族唯一不会整表归零、因而最容易被误判成「通过」的一张。</para>
  543. /// </summary>
  544. public static readonly S0DimDefinition InspectionFrequency = new(
  545. Key: "INSPECTION_FREQUENCY",
  546. EntityCode: "S0_INSPECTION_FREQUENCY",
  547. SourceTable: "qms_inspectionfreq",
  548. SourceSystem: LocalSourceSystem,
  549. StagingTable: "mdp_stg_s0_qms_dict",
  550. DimTable: "ref_inspection_frequency",
  551. BusinessKeyColumns: ["tenant_id", "frequency_code"],
  552. SourceBizKeyColumns: ["FNUMBER"],
  553. Columns:
  554. [
  555. new("tenant_id", S0DimValueKind.TenantIdColumn),
  556. new("frequency_code", S0DimValueKind.Str, "FNUMBER", Required: true),
  557. new("frequency_name", S0DimValueKind.Str, "FNAME"),
  558. new("remark", S0DimValueKind.Str, "FREMARK"),
  559. new("status_code", S0DimValueKind.Str, "FSTATUS"),
  560. new("enable_code", S0DimValueKind.Str, "FENABLE"),
  561. new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME")
  562. ]);
  563. /// <summary>
  564. /// 量检具字典。源 <c>qms_inspectioninstru</c>。
  565. /// <para>三个运维列是源表自有的 snake_case 列(非 F 前缀):<c>gauge_category</c> ·
  566. /// <c>calibration_cycle_days</c>(int) · <c>next_calibration_date</c>。</para>
  567. /// <para>⚠️ <c>next_calibration_date</c> 源是 <b>varchar(50)</b> 存日期串(如 <c>2027-02-25</c>),
  568. /// 用 <c>Str</c> 忠实透传而非 <c>DateTimeSec</c>:后者对非 ISO 脏值会让**两侧同时变 NULL**,
  569. /// checksum 照样相等而数据静默丢失。</para>
  570. /// </summary>
  571. public static readonly S0DimDefinition InspectionInstrument = new(
  572. Key: "INSPECTION_INSTRUMENT",
  573. EntityCode: "S0_INSPECTION_INSTRUMENT",
  574. SourceTable: "qms_inspectioninstru",
  575. SourceSystem: LocalSourceSystem,
  576. StagingTable: "mdp_stg_s0_qms_dict",
  577. DimTable: "ref_inspection_instrument",
  578. BusinessKeyColumns: ["tenant_id", "instrument_code"],
  579. SourceBizKeyColumns: ["FNUMBER"],
  580. Columns:
  581. [
  582. new("tenant_id", S0DimValueKind.TenantIdColumn),
  583. new("instrument_code", S0DimValueKind.Str, "FNUMBER", Required: true),
  584. new("instrument_name", S0DimValueKind.Str, "FNAME"),
  585. new("model", S0DimValueKind.Str, "FMODEL"),
  586. new("specification", S0DimValueKind.Str, "FSPECIFICATION"),
  587. new("manufacturer", S0DimValueKind.Str, "FMANUFACTURER"),
  588. new("gauge_category", S0DimValueKind.Str, "gauge_category"),
  589. new("calibration_cycle_days", S0DimValueKind.Int, "calibration_cycle_days"),
  590. new("next_calibration_date", S0DimValueKind.Str, "next_calibration_date"),
  591. new("status_code", S0DimValueKind.Str, "FSTATUS"),
  592. new("enable_code", S0DimValueKind.Str, "FENABLE"),
  593. new("remark", S0DimValueKind.Str, "FCOMMENT"),
  594. new("source_updated_at", S0DimValueKind.DateTimeSec, "FMODIFYTIME")
  595. ]);
  596. /// <summary>
  597. /// 物料。源 <c>ItemMaster</c> —— **S0 的 Item 权威**(2026-09-07 拍板)。
  598. ///
  599. /// <para><b>与 <c>mdp_std_item</c> 的关系已封板,不再讨论</b>:那是 Supply/S3 的**多源**物料目录
  600. /// (实测混入 <c>ic_item</c>,797 租户有 75 个 item_code 只来自 ic_item、在 ItemMaster 里 0 命中),
  601. /// 且物理上无 <c>Domain</c>、无 <c>ItemMaster.RecID</c>、两个最大租户的 factory/company 100% NULL,
  602. /// 无法承担 C3 的父维度职责。<c>dim_item</c> 不是第二份权威,是 <c>ItemMaster</c> 的第一次物化,
  603. /// 二者语义域不相交(消费方实测零重叠)。</para>
  604. ///
  605. /// <para><b>身份 = tenant_id + ItemNum</b>。实测:租户内重复 <b>0</b>、空白 <b>0</b>、非法租户 <b>0</b>;
  606. /// <c>ItemNum</c> 跨租户碰撞 <b>663 码 / 1534 行</b> ⇒ 租户必须入身份。
  607. /// 旧唯一键 <c>uk_ItemMaster_factory_item</c> 含 <c>factory_ref_id</c>,**不予继承** ——
  608. /// 它连 f(tenant) 都不是(租户 838257186181189 有 3 个取值,含 <c>0</c>),
  609. /// 且 <c>8010</c> 被三个租户共用,放进唯一键既无判别力又把身份绑到跨租户共享值上。</para>
  610. ///
  611. /// <para>⚠️ <b>单租户最大 16150 行,超过默认 batch_size 5000</b> ——
  612. /// <c>AssertNoSourceDuplicateAsync</c> 会硬失败。本 entity 的 <c>mdp_entity.batch_size</c> 设 <b>50000</b>。
  613. /// 该值是 **entity 级**(取自 <c>entity.BatchSize</c>),不影响其它任何维度。</para>
  614. ///
  615. /// <para><c>legacy_source_id ← RecID</c> 是**技术属性**,供 C3 的
  616. /// <c>ProductStructureMaster.ComponentMaterialId</c> 这类 legacy 代理键做对账解析。
  617. /// **它绝不是身份,也不得用 dim 的代理 id 建立外键** —— 每轮 FULL REPLACE 后 <c>dim_item.id</c> 都会变。</para>
  618. ///
  619. /// <para>字段取舍规则(75 列 → 只物化符合以下全部条件的):**全库填充率 ≥5% 且全库 distinct ≥2**;
  620. /// 并无条件排除审计列、影子列、<c>bit(1)</c> 列、可空 <c>tinyint(1)</c>。据此排除的典型:
  621. /// <c>expire_alarm_day</c>(0 填充的影子列,真列是 <c>ExpireAlarmDays</c> 22682)·
  622. /// <c>SuppWarranty</c>(<c>bit(1)</c> 且含 2 行 NULL —— 用 Int 对账必挂、用 BoolTrue 会滤掉那 2 行)·
  623. /// <c>DEAControlled</c>(可空 tinyint,2 行 NULL 同样会被 BoolTrue 守卫滤掉)·
  624. /// <c>Length</c>/<c>Size</c>/<c>Height</c>/<c>Width</c>/<c>cust_supplied</c>/<c>enable_bom_version</c>
  625. /// (全库 distinct=1,零信息)· <c>Status</c>(7/22684,且真实状态是 <c>IsActive</c>)。</para>
  626. /// </summary>
  627. public static readonly S0DimDefinition Item = new(
  628. Key: "ITEM",
  629. EntityCode: "S0_ITEM",
  630. SourceTable: "ItemMaster",
  631. SourceSystem: LocalSourceSystem,
  632. StagingTable: "mdp_stg_s0_item",
  633. DimTable: "dim_item",
  634. BusinessKeyColumns: ["tenant_id", "item_code"],
  635. SourceBizKeyColumns: ["ItemNum"],
  636. Columns:
  637. [
  638. new("tenant_id", S0DimValueKind.TenantIdColumn),
  639. // ── CORE
  640. new("item_code", S0DimValueKind.Str, "ItemNum", Required: true),
  641. new("item_name", S0DimValueKind.Str, "Descr", Required: true),
  642. // ── ATTRIBUTE · 描述与分类
  643. new("item_name_2", S0DimValueKind.Str, "Descr1"),
  644. new("unit", S0DimValueKind.Str, "UM"),
  645. new("pur_mfg", S0DimValueKind.Str, "PurMfg"),
  646. new("item_type", S0DimValueKind.Str, "ItemType"),
  647. new("commodity_code", S0DimValueKind.Str, "CommodityCode"),
  648. new("rev", S0DimValueKind.Str, "Rev"),
  649. new("drawing", S0DimValueKind.Str, "Drawing"),
  650. new("pkg_code", S0DimValueKind.Str, "PkgCode"),
  651. new("series", S0DimValueKind.Str, "Series"),
  652. new("emt_type", S0DimValueKind.Str, "EMTType"),
  653. new("po_receipt_status", S0DimValueKind.Str, "PORcptStatus"),
  654. new("warranty_code", S0DimValueKind.Str, "WarrantyCode"),
  655. // ── ATTRIBUTE · 库位与人员
  656. new("default_location", S0DimValueKind.Str, "Location"),
  657. new("buyer", S0DimValueKind.Str, "Buyer"),
  658. new("planner", S0DimValueKind.Str, "Planner"),
  659. // ── ATTRIBUTE · 计量
  660. new("net_weight", S0DimValueKind.Dec, "NetWeight", Precision: 18, Scale: 5),
  661. new("net_weight_um", S0DimValueKind.Str, "NetWeightUM"),
  662. // ── ATTRIBUTE · 计划参数(Dec 精度必须与源逐位一致,否则 CAST AS CHAR 后校验和 FAIL)
  663. new("pur_lead_time", S0DimValueKind.Int, "PurLT"),
  664. new("ins_lead_time", S0DimValueKind.Int, "InsLT"),
  665. new("mfg_lead_time", S0DimValueKind.Int, "MfgLT"),
  666. new("mfg_mttr", S0DimValueKind.Int, "MFGMTTR"),
  667. new("days_between_pm", S0DimValueKind.Int, "DaysBetweenPM"),
  668. new("expire_alarm_days", S0DimValueKind.Int, "ExpireAlarmDays"),
  669. new("min_order_qty", S0DimValueKind.Dec, "MinOrd", Precision: 18, Scale: 5),
  670. new("max_order_qty", S0DimValueKind.Dec, "MaxOrd", Precision: 18, Scale: 5),
  671. new("min_order_qty_sales", S0DimValueKind.Dec, "MinOrdSales", Precision: 18, Scale: 5),
  672. new("order_multiple", S0DimValueKind.Dec, "OrdMult", Precision: 18, Scale: 5),
  673. new("safety_stock", S0DimValueKind.Dec, "SafetyStk", Precision: 18, Scale: 5),
  674. new("stock_turnover", S0DimValueKind.Dec, "StockTurnOver", Precision: 18, Scale: 5),
  675. // ── ATTRIBUTE · 标志位(全部是 tinyint(1) NOT NULL,可安全走 BoolTrue)
  676. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  677. new("is_confirmed", S0DimValueKind.BoolTrue, "IsConfirm"),
  678. new("key_item", S0DimValueKind.BoolTrue, "KeyItem"),
  679. new("inspect_required", S0DimValueKind.BoolTrue, "Inspect"),
  680. new("issue_policy", S0DimValueKind.BoolTrue, "IssuePolicy"),
  681. new("install", S0DimValueKind.BoolTrue, "Install"),
  682. new("lot_serial_control", S0DimValueKind.BoolTrue, "LotSerialControl"),
  683. new("allocate_single_lot", S0DimValueKind.BoolTrue, "AllocateSingleLot"),
  684. new("auto_lot_nums", S0DimValueKind.BoolTrue, "AutoLotNums"),
  685. new("trace_detail", S0DimValueKind.BoolTrue, "TraceDetail"),
  686. new("is_main_master", S0DimValueKind.BoolTrue, "IsMainMas"),
  687. new("mrp_required", S0DimValueKind.BoolTrue, "MRPRequired"),
  688. // ── LEGACY_SCOPE(属性透传,绝不入唯一键)
  689. new("domain_code", S0DimValueKind.Str, "Domain"),
  690. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  691. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  692. // ── 技术属性:供 C3 的 legacy 代理键对账用(见类型注释)
  693. new("legacy_source_id", S0DimValueKind.Int, "RecID", Required: true),
  694. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  695. ]);
  696. /// <summary>
  697. /// 物料-工序-产线产能。源 <c>ProdLineDetail</c>,父 = <see cref="Item"/>。
  698. ///
  699. /// <para><b>身份 = tenant + Part + Op + Line</b>(实测租户内重复 0 组)。
  700. /// <c>Line</c> **不可省**:去掉后 <c>tenant+Part+Op</c> 有 <b>197 组 / 394 行</b>重复。
  701. /// <c>Domain</c> 每租户单值(797→8010、824585161322565→2410、838257186181189→8010),
  702. /// 零判别力 ⇒ LEGACY_SCOPE,不入身份(D-025)。</para>
  703. ///
  704. /// <para>🔴 <b>dual-column 陷阱</b>:真列是 <c>InternalEquipmentCode</c>(2430/10646 有值),
  705. /// 同表的 <c>internal_equipment_type_code</c> 是 <b>0/10646 的死影子列</b>。取错不报错、只会全 NULL。</para>
  706. ///
  707. /// <para>🔴 <c>RunCrew</c> 在本表是 <c>varchar(8)</c>,而 <see cref="LinePost"/> 的同名列源自
  708. /// <c>LineSkillMaster</c> 是 <c>decimal(10,3)</c> —— <b>同名不同型</b>,照抄会当场类型出错。</para>
  709. ///
  710. /// <para><c>Line</c> 有 <b>11 行带尾随空格</b>(<c>A01 </c>×2 / <c>BA01 </c>×6 / <c>DC01-1 </c>×1 /
  711. /// <c>S01 </c>×1 / <c>S02 </c>×1)。<c>utf8mb4_0900_ai_ci</c> 是 NO PAD,这是真实业务键差异,
  712. /// **原样透传,禁止 TRIM**。</para>
  713. ///
  714. /// <para><c>Line</c> / <c>Site</c> 都只作软引用**不设父**:<c>Line→dim_line</c> 同租户孤儿
  715. /// 8848/10646(租户 824585161322565 的 <c>LineMaster</c> 整体 0 行),<c>Site</c> 更有 5823/10646 为 NULL;
  716. /// 且 CRUD 写入路径对二者**零存在性校验**(<c>_lineMasterRep</c>/<c>_workCtrRep</c> 只用于列表回显)。
  717. /// 与 <see cref="LinePost"/> 不挂 <c>dim_line</c> 同源。</para>
  718. ///
  719. /// <para>⚠️ <c>Op</c> 是本仓**首个 <c>Int</c> 类型业务键**。三侧渲染已验证一致:
  720. /// C# <c>((int)1002).ToString()</c> / 源侧 <c>CONCAT_WS(…, a.Op)</c> / dim 侧同 —— 均为 <c>'1002'</c>。</para>
  721. ///
  722. /// <para>租户分布 797=5817 / 824585161322565=4823 / 838257186181189=6,
  723. /// 故 <c>batch_size</c> 取 <b>20000</b>(3.4× 头寸;默认 5000 会被
  724. /// <c>AssertNoSourceDuplicateAsync</c> 的 <c>sourceCount &gt;= batchSize</c> 直接判失败)。</para>
  725. /// </summary>
  726. public static readonly S0DimDefinition ItemOpLine = new(
  727. Key: "ITEM_OP_LINE",
  728. EntityCode: "S0_ITEM_OP_LINE",
  729. SourceTable: "ProdLineDetail",
  730. SourceSystem: LocalSourceSystem,
  731. StagingTable: "mdp_stg_s0_item_op_line",
  732. DimTable: "bridge_item_op_line",
  733. BusinessKeyColumns: ["tenant_id", "item_code", "operation_no", "line_code"],
  734. SourceBizKeyColumns: ["Part", "Op", "Line"],
  735. ParentDimTable: "dim_item",
  736. ParentKeyColumns: ["item_code"],
  737. Columns:
  738. [
  739. new("tenant_id", S0DimValueKind.TenantIdColumn),
  740. new("item_code", S0DimValueKind.Str, "Part", Required: true),
  741. new("operation_no", S0DimValueKind.Int, "Op", Required: true),
  742. new("line_code", S0DimValueKind.Str, "Line", Required: true),
  743. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  744. new("work_center_code", S0DimValueKind.Str, "Site"),
  745. new("rate", S0DimValueKind.Dec, "Rate", Precision: 18, Scale: 6),
  746. new("setup_time", S0DimValueKind.Dec, "SetupTime", Precision: 18, Scale: 6),
  747. new("overlap_time", S0DimValueKind.Dec, "OverlapTime", Precision: 18, Scale: 6),
  748. new("sequence_no", S0DimValueKind.Dec, "Sequence", Precision: 18, Scale: 6),
  749. new("standard_staff_count", S0DimValueKind.Dec, "StandardStaffCount", Precision: 18, Scale: 6),
  750. new("skill_code", S0DimValueKind.Str, "SkillNo"),
  751. // 🔴 真列,**不是**同表 0/10646 的影子列 internal_equipment_type_code
  752. new("internal_equipment_code", S0DimValueKind.Str, "InternalEquipmentCode"),
  753. new("mold_type_code", S0DimValueKind.Str, "MoldTypeCode"),
  754. new("start_marker", S0DimValueKind.Int, "Start"),
  755. new("is_active", S0DimValueKind.BoolTrue, "IsActive"),
  756. new("is_confirmed", S0DimValueKind.BoolTrue, "IsConfirm"),
  757. // 🔴 源 varchar(8),与 dim_line_post.run_crew 的 decimal(10,3) 同名不同型
  758. new("run_crew", S0DimValueKind.Str, "RunCrew"),
  759. new("op_type", S0DimValueKind.Str, "OpType"),
  760. new("bom_code", S0DimValueKind.Str, "Bom"),
  761. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  762. ]);
  763. /// <summary>
  764. /// 物料工艺路线工序。源 <c>RoutingOpDetail</c>(38806 行,S0 最大的桥),父 = <see cref="Item"/>。
  765. ///
  766. /// <para><b>身份 = tenant + MaterialCode + OperationCode</b>(实测重复 0 组)。
  767. /// <c>RouteCode</c> 不必要:每租户 <c>COUNT(DISTINCT RouteCode) == COUNT(DISTINCT MaterialCode)</c>
  768. /// 且两列逐值相同 38805/38806 行。<c>SortNo</c> 也不必要(近乎常量)。
  769. /// 去掉 <c>OperationCode</c> 则塌陷成 <b>2879 组 / 38677 行</b>重复。</para>
  770. ///
  771. /// <para>🔴 <b>本表的时间列叫 <c>UpdatedAt</c> / <c>CreatedAt</c>,不是全仓通用的 <c>UpdateTime</c></b>。
  772. /// 照抄其它 definition 写 <c>UpdateTime</c> 会**静默取到恒 NULL**(JSON 键不存在 →
  773. /// <c>JSON_EXTRACT</c> 返回 SQL NULL,不报错)。</para>
  774. ///
  775. /// <para>🔴 <b>六组新旧列近重复,取错不会得到 NULL 而是「看起来合法的错值」</b>
  776. /// (<c>RouteCode</c>/<c>RoutingCode</c>/<c>MaterialCode</c>/<c>RouteName</c> 四列 38805/38806 同值)。
  777. /// 真列判据 = CRUD 写入路径:<c>RouteCode</c>(非 <c>RoutingCode</c>)·
  778. /// <c>OperationCode</c>(非旧列 <c>Op</c>)· <c>OperationDescription</c>(非 <c>Descr</c>)。</para>
  779. ///
  780. /// <para><b><c>StdOp</c> 不设父</b>:对 <c>StdOpMaster</c> 孤儿 <b>37112/38746</b>(95.8%),
  781. /// <c>AdoS0ReferenceChecker</c> 无 StdOp 校验、CRUD 的 <c>ApplyUpsert</c> 根本不写该列、UI 无下拉,
  782. /// 且值域不同构(本表 <c>0010</c>/<c>0020</c> vs <c>StdOpMaster.StdOp</c> 的 <c>1-1/包装</c>,
  783. /// 仅 1634/38806 偶然命中)。**它与 item 孤儿是两回事** —— item 孤儿只有 <b>44</b>。</para>
  784. ///
  785. /// <para>父孤儿 <b>44 行 = ALLOW + WARNING</b>(框架原生:孤儿只告警不阻断):全在租户 797,
  786. /// 仅 2 个物料码 <c>A198CABA</c>×18 / <c>918B0238</c>×26,忽略租户后仍是 44
  787. /// ⇒ 是被引用主数据**全库整体缺失**,不是跨租户错挂。</para>
  788. ///
  789. /// <para><b>本轮无法物化的两个业务语义</b>(数据侧问题,非建模问题):
  790. /// 「报工工序」—— CRUD 唯一写入的 <c>MilestoneOperation</c> 是 <b>0/38806</b>,
  791. /// 数据全在 CRUD 从不写的旧列 <c>MilestoneOp</c>,而后者是**可空 <c>tinyint(1)</c>**(1 行 NULL);
  792. /// 「启用状态」—— <c>IsEnabled</c> distinct=1 零信息,<c>IsActive</c> 同为可空 <c>tinyint(1)</c>。
  793. /// 可空 tinyint 用 <c>BoolTrue</c> 会滤掉 NULL 行导致行数对账 FAIL、用 <c>Int</c> 会静默写 0,
  794. /// 故一律不物化(与 <see cref="Item"/> 排除 <c>DEAControlled</c> 同因)。</para>
  795. ///
  796. /// <para><c>Ufld2</c>…<c>Ufld9</c>(8 列)· <c>OverlapUnits</c> · <c>PackingQty</c> ·
  797. /// <c>ProcessOutDay</c> 全库 distinct=1,零信息,不物化。</para>
  798. ///
  799. /// <para>租户分布 797=32309 / 824585161322565=4824 / 838257237606469=1398 /
  800. /// 838257186181189=156 / 838257212780613=119,故 <c>batch_size</c> 取 <b>100000</b>(3.1× 头寸)。</para>
  801. /// </summary>
  802. public static readonly S0DimDefinition ItemRoutingOp = new(
  803. Key: "ITEM_ROUTING_OP",
  804. EntityCode: "S0_ITEM_ROUTING_OP",
  805. SourceTable: "RoutingOpDetail",
  806. SourceSystem: LocalSourceSystem,
  807. StagingTable: "mdp_stg_s0_item_routing_op",
  808. DimTable: "bridge_item_routing_op",
  809. BusinessKeyColumns: ["tenant_id", "item_code", "operation_code"],
  810. SourceBizKeyColumns: ["MaterialCode", "OperationCode"],
  811. ParentDimTable: "dim_item",
  812. ParentKeyColumns: ["item_code"],
  813. Columns:
  814. [
  815. new("tenant_id", S0DimValueKind.TenantIdColumn),
  816. new("item_code", S0DimValueKind.Str, "MaterialCode", Required: true),
  817. // 🔴 新列;旧列 Op(int) 是 38805/38806 的近重复,不得取
  818. new("operation_code", S0DimValueKind.Str, "OperationCode", Required: true),
  819. new("route_code", S0DimValueKind.Str, "RouteCode"), // 非旧列 RoutingCode
  820. new("route_name", S0DimValueKind.Str, "RouteName"), // 实存码值而非名称
  821. new("operation_description", S0DimValueKind.Str, "OperationDescription"), // 非旧列 Descr
  822. new("work_center_code", S0DimValueKind.Str, "WorkCenterCode"),
  823. new("std_op", S0DimValueKind.Str, "StdOp"), // NO PARENT,见类型注释
  824. // Domain 有 1 行 NULL(RecID=106435 的 TEST 行),故不设 Required,否则整行被滤 → 行数对账 FAIL
  825. new("domain_code", S0DimValueKind.Str, "Domain"),
  826. new("company_id", S0DimValueKind.Int, "CompanyRefId", Required: true),
  827. new("factory_id", S0DimValueKind.Int, "FactoryRefId", Required: true),
  828. new("ufld1", S0DimValueKind.Str, "Ufld1"),
  829. new("udeci1", S0DimValueKind.Dec, "UDeci1", Precision: 18, Scale: 5),
  830. new("udeci2", S0DimValueKind.Dec, "UDeci2", Precision: 18, Scale: 5),
  831. new("udeci3", S0DimValueKind.Dec, "UDeci3", Precision: 18, Scale: 5),
  832. new("udeci4", S0DimValueKind.Dec, "UDeci4", Precision: 18, Scale: 5),
  833. new("udeci5", S0DimValueKind.Dec, "UDeci5", Precision: 18, Scale: 5),
  834. // 🔴 源是 (18,6),与 UDeci* 的 (18,5) 不同,不得统一
  835. new("run_time", S0DimValueKind.Dec, "RunTime", Precision: 18, Scale: 6),
  836. new("outsourced_lead_time", S0DimValueKind.Int, "OutsourcedLeadTime"),
  837. new("sort_no", S0DimValueKind.Int, "SortNo"),
  838. new("comment_index", S0DimValueKind.Int, "CommentIndex"),
  839. new("parent_op", S0DimValueKind.Int, "ParentOp"),
  840. // 本表唯一可安全物化的布尔:NOT NULL 且 distinct=2
  841. new("is_confirmed", S0DimValueKind.BoolTrue, "IsConfirm"),
  842. // 🔴 源列名是 UpdatedAt,不是 UpdateTime
  843. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdatedAt")
  844. ]);
  845. /// <summary>
  846. /// 物料工序生产要素。源 <c>ItemOpCondition</c>,父 = <see cref="Item"/>。
  847. ///
  848. /// <para><b>身份 = tenant + ItemNum + CodeType</b>(实测重复 0 组)。</para>
  849. ///
  850. /// <para>🔴 <b><c>IsMain</c> 不入身份 —— 但原因不是框架禁令,而是更下游且无法绕过的一层</b>:
  851. /// 源列是 <c>tinyint(1) NOT NULL</c>,在 <c>raw_data</c> 里是 JSON <b>BOOLEAN</b>(非 1/0)。
  852. /// 即便放开 <c>Validate()</c> 允许布尔入键仍然会挂 —— staging 的 <c>source_biz_key</c> 由
  853. /// <c>mdp_entity.biz_key_expr</c> 驱动、走 C# <c>bool.ToString()</c> 产出 <c>"True"</c>/<c>"False"</c>,
  854. /// 而源侧 checksum 渲染的是 <c>'1'</c>/<c>'0'</c>,四向集合比对必然全量不等价。
  855. /// 修它要动全实体共享的 <c>MdpStagingWriter.BuildBizKey</c>。
  856. /// 旁证:全部 <c>mdp_stg_s0_*</c> 的 <c>source_biz_key</c> 中匹配 <c>True|False</c> 的行数 = <b>0</b>,
  857. /// 布尔入键在本仓从未发生过。故 <c>IsMain</c> 落 <b>属性</b>,且**必须用 <c>BoolTrue</c>** ——
  858. /// 用 <c>Int</c> 会把 <c>true</c>/<c>false</c> 双双静默压成 0(比 bit(1) 地雷更毒,不报错不为 NULL),
  859. /// 用 <c>Str</c> 会得到 <c>'true'</c> 而源侧是 <c>'1'</c>,校验和必挂。</para>
  860. ///
  861. /// <para><c>CodeType</c> 实测单值 <c>'Prod'</c> 且控制器硬编码写死、无视 DTO 入参,零区分力;
  862. /// 保留系防不可逆键收缩(同 <see cref="LinePost"/> 的 <c>job_no</c> 先例),不是因为它有用。</para>
  863. ///
  864. /// <para>🔴 <c>ItemCode</c>(→ <c>element_code</c>)是**要素编码**,与 <c>ItemNum</c>(物料编码)
  865. /// **语义不同**:实测两列相等的行数 = <b>0</b>,且 5/10 为空。任何把它当物料码 join 的代码都是 bug。</para>
  866. ///
  867. /// <para><c>Op</c> 虽 10/10 填充且 <c>(ItemNum,Op)→RoutingOpDetail</c> 零孤儿,但不在源 UK 内、
  868. /// DTO 无 <c>[Required]</c> ⇒ 不入键。**不要因为表名叫 ItemOpCondition 就把 Op 塞进身份。**</para>
  869. ///
  870. /// <para>⚠️ dim 唯一键严格弱于源 UK(源含 <c>Domain</c> 与 <c>IsMain</c>),且两个控制器
  871. /// 均无查重守卫,业务**可以**合法插出同 <c>(tenant,ItemNum,'Prod')</c> 的 IsMain=0/1 两行。
  872. /// 届时 <c>AssertNoSourceDuplicateAsync</c>(在 purge **之前**)会显式失败、零数据被触碰 ——
  873. /// 失败可见、可定位、不静默。当前实测 0 例。</para>
  874. /// </summary>
  875. public static readonly S0DimDefinition ItemOpCondition = new(
  876. Key: "ITEM_OP_CONDITION",
  877. EntityCode: "S0_ITEM_OP_CONDITION",
  878. SourceTable: "ItemOpCondition",
  879. SourceSystem: LocalSourceSystem,
  880. StagingTable: "mdp_stg_s0_item_op_condition",
  881. DimTable: "bridge_item_op_condition",
  882. BusinessKeyColumns: ["tenant_id", "item_code", "code_type"],
  883. SourceBizKeyColumns: ["ItemNum", "CodeType"],
  884. ParentDimTable: "dim_item",
  885. ParentKeyColumns: ["item_code"],
  886. Columns:
  887. [
  888. new("tenant_id", S0DimValueKind.TenantIdColumn),
  889. new("item_code", S0DimValueKind.Str, "ItemNum", Required: true),
  890. new("code_type", S0DimValueKind.Str, "CodeType", Required: true),
  891. // 🔴 必须 BoolTrue:Int 会把 true/false 双双静默压成 0,Str 会得到 'true' 而源侧是 '1'
  892. new("is_main", S0DimValueKind.BoolTrue, "IsMain"),
  893. new("op_code", S0DimValueKind.Str, "Op"),
  894. new("line_code", S0DimValueKind.Str, "Line"),
  895. new("typed_code", S0DimValueKind.Str, "Typed"),
  896. // 🔴 要素编码,**不是** ItemNum(实测两列 0 行相等)
  897. new("element_code", S0DimValueKind.Str, "ItemCode"),
  898. new("descr", S0DimValueKind.Str, "Descr"),
  899. new("domain_code", S0DimValueKind.Str, "Domain"),
  900. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  901. ]);
  902. /// <summary>
  903. /// 物料工序 SOP 文件。源 <c>QualitySegmentImage</c>,父 = <see cref="Item"/>。
  904. ///
  905. /// <para><b>身份 = tenant + item_num + op + image_type_id</b>(实测重复 0 组)。
  906. /// 源 UNIQUE 还含 <c>domain</c>,但它跨租户口径异构(797 用组织雪花号 <c>797403761152069</c>、
  907. /// 838257186181189 用 legacy <c>8010</c>/<c>2410</c>),坐实 LEGACY_SCOPE,不入身份。</para>
  908. ///
  909. /// <para>🔴 <b>源表没有任何时间列</b>(11 列全清单实测),故**不声明 <c>source_updated_at</c>** ——
  910. /// 与 <see cref="SopFileType"/> / <see cref="LabelType"/> 同型。**不得为字段齐整而回填。**</para>
  911. ///
  912. /// <para>⚠️ 源表那个叫 <c>uk_QualitySegmentImage_scope</c> 的索引**不是唯一索引**
  913. /// (<c>IsUnique=false</c>);真正的唯一键是 <c>uk_QualitySegmentImage_scope_unique</c>。**别被名字骗了。**</para>
  914. ///
  915. /// <para><c>image_type_id</c> 软引用 <see cref="SopFileType"/> 的 <c>file_type_code</c>(实测 0 孤儿),
  916. /// 但**不设父** —— 框架只能挂一个父,而本桥的主语是「某物料某工序的 SOP 文件」,
  917. /// <c>image_type_id</c> 只是该文件的分类(闭集字典)。挂 <c>dim_item</c> 才让
  918. /// 「S0 ITEM AUTHORITY = dim_item」在一处可强制、可审计;且 <c>ref_sop_file_type</c>
  919. /// 当前只物化了租户 797,选它作父会让别的租户产出纯刷新顺序噪声的 WARN。
  920. /// 同 <see cref="LinePostSkill"/> 的 <c>skill_code</c> 先例。</para>
  921. ///
  922. /// <para>⚠️ <c>item_num</c> / <c>op</c> / <c>image_type_id</c> 在源 DDL 里**均可空**,
  923. /// 且 upsert DTO 只有 <c>Domain</c> 带 <c>[Required]</c> ⇒ 业务可合法建 NULL 行 →
  924. /// 被 <c>RowGuards</c> 整行过滤 → <c>dim_count &lt; stg_count</c> → 阻断 FAIL。
  925. /// 与 <see cref="EmpWorkDuty"/> 同风险类。当前实测 0 NULL / 0 空串。</para>
  926. /// </summary>
  927. public static readonly S0DimDefinition ItemOpImage = new(
  928. Key: "ITEM_OP_IMAGE",
  929. EntityCode: "S0_ITEM_OP_IMAGE",
  930. SourceTable: "QualitySegmentImage",
  931. SourceSystem: LocalSourceSystem,
  932. StagingTable: "mdp_stg_s0_item_op_image",
  933. DimTable: "bridge_item_op_image",
  934. BusinessKeyColumns: ["tenant_id", "item_code", "op_code", "file_type_code"],
  935. SourceBizKeyColumns: ["item_num", "op", "image_type_id"],
  936. ParentDimTable: "dim_item",
  937. ParentKeyColumns: ["item_code"],
  938. Columns:
  939. [
  940. new("tenant_id", S0DimValueKind.TenantIdColumn),
  941. new("item_code", S0DimValueKind.Str, "item_num", Required: true),
  942. new("op_code", S0DimValueKind.Str, "op", Required: true),
  943. new("file_type_code", S0DimValueKind.Str, "image_type_id", Required: true),
  944. new("line_code", S0DimValueKind.Str, "line"),
  945. new("file_path", S0DimValueKind.Str, "path"),
  946. new("descr", S0DimValueKind.Str, "descr"),
  947. new("rev", S0DimValueKind.Str, "rev"),
  948. // 源是真 int(非 tinyint(1)),走 Int 安全
  949. new("sort_id", S0DimValueKind.Int, "sort_id"),
  950. new("domain_code", S0DimValueKind.Str, "domain")
  951. // 🔴 刻意无 source_updated_at:源表零时间列,不得回填
  952. ]);
  953. /// <summary>
  954. /// BOM 结构(父件-子件用量)。源 <c>ProductStructureMaster</c>(62036 行,S0 最大的表),
  955. /// 父 = <see cref="Item"/>。
  956. ///
  957. /// <para><b>身份 = tenant + ParentItem + ComponentItem</b>(实测重复 0 组)。
  958. /// <c>LineOp</c> / <c>Op</c> / <c>SequenceNum</c> 加进去**零增益**(仍是 0 组),故不入身份;
  959. /// 去掉 <c>ComponentItem</c> 则塌陷成 <b>4341 组 / 60494 行</b>重复。
  960. /// 旧 UK 含 <c>CompanyRefId</c>+<c>FactoryRefId</c>,不予继承 —— 身份不加它们已然唯一(D-025)。</para>
  961. ///
  962. /// <para>🔴🔴 <b>legacy 代理键 <c>ComponentMaterialId</c> / <c>ParentMaterialId</c> 是已实证的跨租户泄漏向量</b>
  963. /// (2026-09-08 实测):
  964. /// <list type="bullet">
  965. /// <item>同租户解析 <c>ComponentMaterialId → ItemMaster.RecID</c>:<b>170 行解析失败</b>;</item>
  966. /// <item>忽略租户解析:<b>0 行失败</b> —— 看起来「完美」;</item>
  967. /// <item>而这 <b>170 行恰恰全部挂到了别的租户的 <c>RecID</c> 上</b>。</item>
  968. /// </list>
  969. /// <c>ParentMaterialId</c> 同款,量更大(同租户失败 <b>2060</b>、忽略租户 0)。
  970. /// 也就是说:**去掉租户条件不会报错,只会静默串租户**。
  971. /// 而同租户能解析的 61866 行里,代理键解出的码与 <c>ComponentItem</c> <b>61866/61866 完全一致</b>
  972. /// ⇒ **自然键完全可靠,代理键只带来风险**。故本桥一律走自然键
  973. /// <c>item_code</c> / <c>component_item_code</c>,两个 id 仅作 legacy 技术属性留痕,
  974. /// **任何消费方解析它们时必须带 <c>tenant_id</c>**。</para>
  975. ///
  976. /// <para>父连接:<c>ParentItem</c> 与 <c>ComponentItem</c> 对 <c>ItemMaster</c> 的孤儿
  977. /// **同租户与忽略租户都是 0/62036**。框架单父,取 <c>ParentItem</c> 作 <c>item_code</c>
  978. /// (下游「查某物料的 BOM」天然按父件过滤);<c>ComponentItem</c> 落
  979. /// <c>component_item_code</c>,同样零孤儿但不设父。</para>
  980. ///
  981. /// <para>🔴 <c>IsActive</c> 是 <b><c>bit(1)</c> 且含 1 行 NULL</b> —— 双重地雷,**不物化**:
  982. /// 用 <see cref="S0DimValueKind.Int"/> 会让对账每轮必挂(<c>CAST(b'1' AS CHAR)</c> 是裸字节),
  983. /// 用 <see cref="S0DimValueKind.BoolTrue"/> 会把那 1 行 NULL 整行滤掉 → 行数对账 FAIL。
  984. /// 同理 <c>IsEnabled</c>(distinct=1 零信息)、<c>SequenceNum</c>(distinct=1)、
  985. /// <c>StructureType</c>(7.2% 填充但 distinct=1)、<c>LineOp</c>/<c>Remark</c>/<c>StartEff</c>/<c>EndEff</c>
  986. /// (全库 0 填充)一并不物化。</para>
  987. ///
  988. /// <para>⚠️ 单租户最大 <b>57199</b> 行(797),<c>batch_size</c> 取 <b>200000</b>(3.5× 头寸)。</para>
  989. /// </summary>
  990. public static readonly S0DimDefinition ItemBom = new(
  991. Key: "ITEM_BOM",
  992. EntityCode: "S0_ITEM_BOM",
  993. SourceTable: "ProductStructureMaster",
  994. SourceSystem: LocalSourceSystem,
  995. StagingTable: "mdp_stg_s0_item_bom",
  996. DimTable: "bridge_item_bom",
  997. BusinessKeyColumns: ["tenant_id", "item_code", "component_item_code"],
  998. SourceBizKeyColumns: ["ParentItem", "ComponentItem"],
  999. ParentDimTable: "dim_item",
  1000. ParentKeyColumns: ["item_code"],
  1001. Columns:
  1002. [
  1003. new("tenant_id", S0DimValueKind.TenantIdColumn),
  1004. new("item_code", S0DimValueKind.Str, "ParentItem", Required: true),
  1005. new("component_item_code", S0DimValueKind.Str, "ComponentItem", Required: true),
  1006. new("qty", S0DimValueKind.Dec, "Qty", Precision: 18, Scale: 5),
  1007. new("unit", S0DimValueKind.Str, "UM"),
  1008. new("scrap", S0DimValueKind.Dec, "Scrap", Precision: 18, Scale: 5),
  1009. new("qty_consumed", S0DimValueKind.Dec, "QtyConsumed", Precision: 18, Scale: 5),
  1010. new("qty_exchanged", S0DimValueKind.Dec, "QtyExchd", Precision: 18, Scale: 5),
  1011. new("refs", S0DimValueKind.Str, "Refs"),
  1012. new("operation_no", S0DimValueKind.Int, "Op"),
  1013. new("company_id", S0DimValueKind.Int, "CompanyRefId", Required: true),
  1014. new("factory_id", S0DimValueKind.Int, "FactoryRefId", Required: true),
  1015. // 🔴 legacy 技术属性,解析时**必须带 tenant_id**(见类型注释的跨租户实证)
  1016. new("legacy_parent_item_id", S0DimValueKind.Int, "ParentMaterialId", Required: true),
  1017. new("legacy_component_item_id", S0DimValueKind.Int, "ComponentMaterialId", Required: true),
  1018. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdatedAt")
  1019. ]);
  1020. /// <summary>
  1021. /// 替代料。源 <c>ItemSubstituteDetail</c>,父 = <see cref="Item"/>。
  1022. ///
  1023. /// <para><b>身份 = tenant + ParentItem + ItemNum + SubstituteItem</b>(实测重复 0 组)。
  1024. /// 虽然 <c>tenant+ItemNum+SubstituteItem</c> 当前也是 0 组,但全表仅 <b>16 行</b>,
  1025. /// 这点样本证明不了 <c>ParentItem</c> 可去;而实测 <c>ParentItem</c> 与 <c>ItemNum</c>
  1026. /// <b>16 行无一相等</b>(distinct 9 vs 14),是**真实不同的信息**
  1027. /// —— 去掉即不可逆的键收缩,故保留(同 <see cref="LinePost"/> 的 <c>job_no</c> 先例)。
  1028. /// <c>Domain</c> 在旧 UK 内,按 D-025 落 LEGACY_SCOPE 不入身份。</para>
  1029. ///
  1030. /// <para>三个 item 引用(<c>ParentItem</c> / <c>ItemNum</c> / <c>SubstituteItem</c>)
  1031. /// **全部统一到 <c>dim_item</c> 的自然键 <c>tenant + item_code</c>**,实测孤儿各 0/16。
  1032. /// 框架单父,取被替代的 <c>ItemNum</c> 作 <c>item_code</c>(本行的主语是「这个料有哪些替代」);
  1033. /// 另两个落 <c>parent_item_code</c> / <c>substitute_item_code</c>,同样零孤儿但不设父。</para>
  1034. /// </summary>
  1035. public static readonly S0DimDefinition ItemSubstitute = new(
  1036. Key: "ITEM_SUBSTITUTE",
  1037. EntityCode: "S0_ITEM_SUBSTITUTE",
  1038. SourceTable: "ItemSubstituteDetail",
  1039. SourceSystem: LocalSourceSystem,
  1040. StagingTable: "mdp_stg_s0_item_substitute",
  1041. DimTable: "bridge_item_substitute",
  1042. BusinessKeyColumns: ["tenant_id", "parent_item_code", "item_code", "substitute_item_code"],
  1043. SourceBizKeyColumns: ["ParentItem", "ItemNum", "SubstituteItem"],
  1044. ParentDimTable: "dim_item",
  1045. ParentKeyColumns: ["item_code"],
  1046. Columns:
  1047. [
  1048. new("tenant_id", S0DimValueKind.TenantIdColumn),
  1049. new("parent_item_code", S0DimValueKind.Str, "ParentItem", Required: true),
  1050. new("item_code", S0DimValueKind.Str, "ItemNum", Required: true),
  1051. new("substitute_item_code", S0DimValueKind.Str, "SubstituteItem", Required: true),
  1052. new("substitute_qty", S0DimValueKind.Dec, "SubstituteQty", Precision: 18, Scale: 6),
  1053. new("substitute_type", S0DimValueKind.Str, "SubstituteType"),
  1054. new("remark", S0DimValueKind.Str, "Remark"),
  1055. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  1056. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  1057. ]);
  1058. // ════════════════════════════════════════════════════════════════════════════
  1059. // C3 · 关系 / 明细桥接(bridge_*)
  1060. // ════════════════════════════════════════════════════════════════════════════
  1061. /// <summary>
  1062. /// 员工物料职责桥。源 <c>EmpWorkDutyMaster</c>。
  1063. ///
  1064. /// <para>身份 = <c>tenant_id + Employee + Duty + Location</c>。
  1065. /// <b><c>location_code</c> 不可省</b> —— 实测 <c>tenant+Employee+Duty</c> 有 1 组重复:
  1066. /// 租户 797…229 的 <c>G00308</c> 在同一串 Duty 下有两行,分别是 <c>Location=1001</c>
  1067. /// 与 <c>Location=1000</c>(物料也不同)⇒ 库位是真实的区分维,不是 legacy scope。
  1068. /// 加上 Location 后租户内重复 = 0。</para>
  1069. ///
  1070. /// <para>挂父有业务依据:本表是少数**真正强制父存在性**的 CRUD ——
  1071. /// <c>AdoS0EmpWorkDutiesController</c> 写入前调 <c>EmployeeExistsInTenantAsync</c>。
  1072. /// 实测父孤儿 0/25。父键取 <c>["employee_code"]</c>,与 <c>dim_employee</c> 身份对齐。</para>
  1073. ///
  1074. /// <para>⚠️ CRUD **无查重守卫**且 DTO 的 <c>Duty</c>/<c>Location</c> 是 <c>string?</c> 无 <c>[Required]</c>
  1075. /// ⇒ 业务可以合法建出 NULL 行,而它们是 Required 业务键,会被 RowGuards 整行过滤导致对账 FAIL。
  1076. /// 当前实测 0 NULL / 0 空串,verify 与运行验收都显式断言这一点。</para>
  1077. /// </summary>
  1078. public static readonly S0DimDefinition EmpWorkDuty = new(
  1079. Key: "EMP_WORK_DUTY",
  1080. EntityCode: "S0_EMP_WORK_DUTY",
  1081. SourceTable: "EmpWorkDutyMaster",
  1082. SourceSystem: LocalSourceSystem,
  1083. StagingTable: "mdp_stg_s0_emp_work_duty",
  1084. DimTable: "bridge_employee_work_duty",
  1085. BusinessKeyColumns: ["tenant_id", "employee_code", "duty", "location_code"],
  1086. SourceBizKeyColumns: ["Employee", "Duty", "Location"],
  1087. ParentDimTable: "dim_employee",
  1088. ParentKeyColumns: ["employee_code"],
  1089. Columns:
  1090. [
  1091. new("tenant_id", S0DimValueKind.TenantIdColumn),
  1092. new("employee_code", S0DimValueKind.Str, "Employee", Required: true),
  1093. new("duty", S0DimValueKind.Str, "Duty", Required: true),
  1094. new("location_code", S0DimValueKind.Str, "Location", Required: true),
  1095. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  1096. new("company_id", S0DimValueKind.Int, "company_ref_id", Required: true),
  1097. new("factory_id", S0DimValueKind.Int, "factory_ref_id", Required: true),
  1098. new("line_code", S0DimValueKind.Str, "ProdLine"),
  1099. new("item_num", S0DimValueKind.Str, "ItemNum"),
  1100. new("item_num_from", S0DimValueKind.Str, "ItemNum1"),
  1101. new("item_num_to", S0DimValueKind.Str, "ItemNum2"),
  1102. new("emp_type", S0DimValueKind.Str, "EmpType"),
  1103. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  1104. ]);
  1105. /// <summary>
  1106. /// 产线岗位。源 <c>LineSkillMaster</c>。与 <see cref="LinePostSkill"/> 共用
  1107. /// <c>mdp_stg_s0_line_post</c>,靠 <c>source_table</c> 隔离(同 Location/LocationShelf 范式)。
  1108. ///
  1109. /// <para>身份 = <c>tenant_id + ProdLine + JOBNo</c>,实测租户内重复 0;
  1110. /// <c>Domain</c> 每租户单值 ⇒ LEGACY_SCOPE,不入身份。
  1111. /// <c>JOBNo</c> 当前每租户只有一个取值(<c>102</c>),零区分力,但它是 CRUD 必填的岗位码,
  1112. /// **保留在身份中** —— 去掉会造成不可逆的键收缩,将来多岗位时无法恢复。</para>
  1113. ///
  1114. /// <para><b>不挂父维度</b>,三条理由:① <c>ProdLine → LineMaster.Line</c> 同租户孤儿 28/57,
  1115. /// 且这 28 个码在**任何租户**都不存在(是被引用主数据整体缺失,不是租户错挂);
  1116. /// ② CRUD 对 <c>ProdLine</c> 不做任何存在性校验(对比 EmpWorkDuty 有 <c>EmployeeExistsInTenantAsync</c>);
  1117. /// ③ 与既有范式一致 —— <c>dim_line.location_code</c> / <c>dim_employee.work_center_code</c>
  1118. /// 都是同名软引用、不声明父。挂父只会每轮产出无人能闭环的 WARN。</para>
  1119. ///
  1120. /// <para>🔴 源侧 <c>IX_LineSkillMaster</c> 含 <c>Site</c> 而 Site **0/57 全 NULL**,
  1121. /// MySQL UNIQUE 对 NULL 永不判等 ⇒ **源侧当前对本表没有任何唯一性约束**,
  1122. /// 观测到的 0 重复是无守卫的巧合。dim 的唯一键将是第一道真守卫。</para>
  1123. ///
  1124. /// <para>⚠️ 3 行 <c>ProdLine</c> 带尾随空格(<c>'A01 '</c>/<c>'S01 '</c>/<c>'S02 '</c>)。
  1125. /// <c>utf8mb4_0900_ai_ci</c> 是 NO PAD,尾空格参与比较,是真实业务键差异 —— **原样透传,禁止 TRIM**。</para>
  1126. /// </summary>
  1127. public static readonly S0DimDefinition LinePost = new(
  1128. Key: "LINE_POST",
  1129. EntityCode: "S0_LINE_POST",
  1130. SourceTable: "LineSkillMaster",
  1131. SourceSystem: LocalSourceSystem,
  1132. StagingTable: "mdp_stg_s0_line_post",
  1133. DimTable: "dim_line_post",
  1134. BusinessKeyColumns: ["tenant_id", "line_code", "job_no"],
  1135. SourceBizKeyColumns: ["ProdLine", "JOBNo"],
  1136. Columns:
  1137. [
  1138. new("tenant_id", S0DimValueKind.TenantIdColumn),
  1139. new("line_code", S0DimValueKind.Str, "ProdLine", Required: true),
  1140. new("job_no", S0DimValueKind.Str, "JOBNo", Required: true),
  1141. new("domain_code", S0DimValueKind.Str, "Domain", Required: true),
  1142. new("job_name", S0DimValueKind.Str, "JOBDescr"),
  1143. // 0/57 全空,但它在源 UK 里 —— 保留可见性,否则将来身份被破坏时看不出原因
  1144. new("site", S0DimValueKind.Str, "Site"),
  1145. new("order_by", S0DimValueKind.Int, "Orderby"),
  1146. // Precision/Scale 必须与源 decimal(10,3) 逐位相同:对账两侧都走 CAST(expr AS CHAR),
  1147. // scale 不同会让 '0.000' 与 '0.0' 不等,属性校验和当场 FAIL。
  1148. new("run_crew", S0DimValueKind.Dec, "RunCrew", Precision: 10, Scale: 3),
  1149. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  1150. ]);
  1151. /// <summary>
  1152. /// 产线岗位技能桥。源 <c>LineSkillDetail</c> —— **本仓第 4 张 dual-column 表,且是最危险的一张**。
  1153. ///
  1154. /// <para>🔴 它的四对双列里有三对是「有值但发散」型(影子列 250/261 非空,
  1155. /// 但与真列**没有一行相等**)—— 取错不会得到 NULL,会得到**看起来合法的错值**:
  1156. /// <c>RecID</c>(真) vs <c>id</c>(影) · <c>LineSkillRecID</c>(真) vs <c>line_skill_master_id</c>(影) ·
  1157. /// <c>PersonSkillId</c>(真) vs <c>person_skill_id</c>(影)。
  1158. /// 第四对是恒空型:<c>StartDate</c>(真, 250/261) vs <c>effective_date</c>(影, **0/261**)。
  1159. /// ⚠️ 本 dim 的目标列**取名** <c>effective_date</c>,而它的源物理列是 <c>StartDate</c> ——
  1160. /// 名字与源表那个影子列同名,是本批最容易取错的一处,已加契约测试钉死。</para>
  1161. ///
  1162. /// <para><b>父连接必须走自然键</b>:代理键已被污染 ——
  1163. /// <c>LineSkillRecID → LineSkillMaster.RecID</c> 同租户孤儿 **210/261**,忽略租户后
  1164. /// **175 行解析到别的租户**;<c>PersonSkillId</c> 更甚,**250/261 属于别的租户**。
  1165. /// 自然键 <c>(tenant, ProdLine, JOBNo)</c> 连父三租户**全 0 孤儿**。
  1166. /// 两个 legacy id 只作留痕列物化,**任何 join 都不得使用**。</para>
  1167. ///
  1168. /// <para><c>SkillNo</c> 同租户在 <c>PersonSkill</c> 不命中 210/261 ——
  1169. /// **不因为都叫 Skill 就建父子关系**(与 <see cref="PersonSkill"/> 已有的同款否决一致)。</para>
  1170. /// </summary>
  1171. public static readonly S0DimDefinition LinePostSkill = new(
  1172. Key: "LINE_POST_SKILL",
  1173. EntityCode: "S0_LINE_POST_SKILL",
  1174. SourceTable: "LineSkillDetail",
  1175. SourceSystem: LocalSourceSystem,
  1176. StagingTable: "mdp_stg_s0_line_post",
  1177. DimTable: "bridge_line_post_skill",
  1178. BusinessKeyColumns: ["tenant_id", "line_code", "job_no", "skill_code"],
  1179. SourceBizKeyColumns: ["ProdLine", "JOBNo", "SkillNo"],
  1180. ParentDimTable: "dim_line_post",
  1181. ParentKeyColumns: ["line_code", "job_no"],
  1182. Columns:
  1183. [
  1184. new("tenant_id", S0DimValueKind.TenantIdColumn),
  1185. new("line_code", S0DimValueKind.Str, "ProdLine", Required: true),
  1186. new("job_no", S0DimValueKind.Str, "JOBNo", Required: true),
  1187. new("skill_code", S0DimValueKind.Str, "SkillNo", Required: true),
  1188. // 源可空(虽然 261/261 有值):设 Required 会让未来的 NULL 行被静默滤掉并引发全批 FAIL
  1189. new("domain_code", S0DimValueKind.Str, "Domain"),
  1190. new("site", S0DimValueKind.Str, "Site"),
  1191. // 🔴 源物理列是 StartDate,**不是**同表那个 0/261 的影子列 effective_date
  1192. new("effective_date", S0DimValueKind.DateTimeSec, "StartDate"),
  1193. new("required_level", S0DimValueKind.Str, "required_level"),
  1194. new("remark", S0DimValueKind.Str, "remark"),
  1195. new("is_enabled", S0DimValueKind.BoolTrue, "is_enabled"),
  1196. // 仅留痕,禁止 join —— 见类型注释
  1197. new("legacy_line_skill_rec_id", S0DimValueKind.Int, "LineSkillRecID"),
  1198. new("legacy_person_skill_id", S0DimValueKind.Int, "PersonSkillId"),
  1199. new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
  1200. ]);
  1201. /// <summary>全部维度,按依赖顺序(父在前,子在后 —— 孤儿判定需要父维度先就绪)。</summary>
  1202. public static readonly IReadOnlyList<S0DimDefinition> All =
  1203. [
  1204. // C1 主数据
  1205. WorkCenter, Department, Location, LocationShelf, Employee, Line, Customer, PersonSkill,
  1206. // C2-1 独立字典
  1207. LabelType, SopFileType, QualityBaseType,
  1208. // C2-2 qms 字典族(共用 mdp_stg_s0_qms_dict)
  1209. SamplingScheme, InspectionItem, InspectionMethod, InspectionFrequency, InspectionInstrument,
  1210. // C1 扩展:物料(S0 Item 权威;后续 6 张 C3 都要挂它作父,故必须排在 C3 之前)
  1211. Item,
  1212. // C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前)
  1213. EmpWorkDuty, LinePost, LinePostSkill,
  1214. // C3-A 物料桥(父 dim_item,必须排在 Item 之后)
  1215. ItemOpLine, ItemRoutingOp,
  1216. // C3-C 物料桥(父 dim_item)
  1217. ItemOpCondition, ItemOpImage,
  1218. // C3-B 物料桥(父 dim_item)
  1219. ItemBom, ItemSubstitute
  1220. ];
  1221. /// <summary>按 Key 取维度(大小写不敏感);不存在返回 null。</summary>
  1222. public static S0DimDefinition? Find(string? key) =>
  1223. string.IsNullOrWhiteSpace(key)
  1224. ? null
  1225. : All.FirstOrDefault(d => string.Equals(d.Key, key.Trim(), StringComparison.OrdinalIgnoreCase));
  1226. /// <summary>校验全部声明;任一不合法即抛出(供启动自检与单测使用)。</summary>
  1227. public static void ValidateAll()
  1228. {
  1229. foreach (var d in All) d.Validate();
  1230. var dupDim = All.GroupBy(d => d.DimTable, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
  1231. if (dupDim is not null)
  1232. throw new InvalidOperationException(
  1233. $"dim 表被多个 definition 共用:{dupDim.Key} —— FULL Replace 按 tenant 整表删除,会互相清空");
  1234. var dupEntity = All.GroupBy(d => d.EntityCode, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
  1235. if (dupEntity is not null)
  1236. throw new InvalidOperationException($"entity_code 重复:{dupEntity.Key}");
  1237. // 同一 staging 被多个 definition 共用时,source_table 必须互异,否则 purge/物化会互相污染
  1238. foreach (var g in All.GroupBy(d => d.StagingTable, StringComparer.Ordinal).Where(g => g.Count() > 1))
  1239. {
  1240. var tables = g.Select(d => d.SourceTable).ToList();
  1241. if (tables.Distinct(StringComparer.Ordinal).Count() != tables.Count)
  1242. throw new InvalidOperationException($"staging {g.Key} 被多个 definition 共用且 source_table 重复");
  1243. }
  1244. }
  1245. }