Parcourir la source

feat(s0): 落地供应商维度,主数据中台化收口

SuppMaster → mdp_stg_s0_supplier → dim_supplier,3575 行 / 5 租户。
至此 S0 共 27 个维度对象、27 张目标表、约 14 万行。

身份取 tenant_id + supplier_code,不继承旧唯一键的 factory_ref_id:
Domain、company_ref_id、factory_ref_id 三者每租户各只有一个取值,是严格的
租户函数、零判别力,且 factory_ref_id=1000 被两个租户共用。Supp 跨租户碰撞
13 个编码 27 行,所以租户必须入身份。

CRUD 里那句 TenantId && FactoryRefId && Supp 的查重守卫不能当作 factory
应当入身份的证据:它的 factoryId 来自 AdoS0OrgScope.ResolveAsync(db, tenantId),
仅凭租户解析并强制盖章、完全忽略 payload,因此语义上就等于 tenant + Supp。
页面确有公司与工厂两个下拉、DTO 也带必填校验,但创建与更新方法从不读取这两个
入参——界面收集不等于参与身份。

真实消费方按租户加编码关联:SrmPurchase.SupplierNumber 对 Supp 命中 4447/4449。
而 srm_purchase.supplier_id 对 SuppMaster.RecID 命中 0/4449(存量是十九位雪花值,
RecID 最大才 3585),说明 RecID 没有任何读取方,故不物化 legacy_source_id。

二十八列只物化九列。十一列近乎全空——CrTerms、Curr、Type、TaxClass、address、
contact、position、contact_info、certification_level 的非空白值是 0/3575,
PurContact 两行、Remark 一行。它们有真实写入路径、不是影子列,只是无人填写,
其中联系人类另属个人信息。APStartDay 等四列虽然极少为 NULL,但应用自身把 0
判为无值,有效填充率只有百分之二。

supplier_name 绝不能设必填:SortName 有二十一行为 NULL,误设会让行级守卫把这些
行滤掉,导致 dim 比 staging 少二十一行、对账阻断,而报错指向对账不指向字段声明,
极难定位。source_updated_at 同理,有十二行为 NULL。

batch_size 取 20000:单租户最大 3185 行,默认 5000 只有 1.57 倍头寸,而源侧重复
探针是大于等于即硬失败。该值是 entity 级,不影响其它维度。

验收:逐租户 source 与 staging 与 dim 三层全等(3185/374/14/1/1),校验和全等,
重复与批次杂质与 tenant<=0 全为零,连刷三次校验和逐位稳定且不影响其它租户,
SuppMaster 行数刷新前后不变。走同库集合式物化。共享入站核心四个文件仍为零改动。
测试 2113 通过、零失败。

另记两项本轮裁决:NbrTypeMaster 因单行源数据业务键缺失而缓做,PriorityCode 判为
本地保留(它是排序规则配置而非字典,没有编码列,且已有设计更好的继任表未接线)。
两者均已证明不构成租户隔离缺口,详见工程记忆 I-023 与 I-024。
YY968XX il y a 1 jour
Parent
commit
734f390841

+ 9 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.514</AssemblyVersion>
-    <FileVersion>1.0.514</FileVersion>
-    <Version>1.0.514</Version>
+    <AssemblyVersion>1.0.515</AssemblyVersion>
+    <FileVersion>1.0.515</FileVersion>
+    <Version>1.0.515</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -763,6 +763,12 @@
     <None Update="UpdateScripts\1.0.514.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.515.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.515.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 78 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.515.sql

@@ -0,0 +1,78 @@
+-- =====================================================================================
+-- WIP-SUPPLIER  ·  S0 · 供应商维度(占位名,提交那刻 rename 为正式 1.0.<next>.sql)
+--
+--   SuppMaster → mdp_stg_s0_supplier → dim_supplier
+--
+-- 身份 = tenant_id + Supp(租户内重复 0 组;Supp 跨租户碰撞 13 码/27 行 ⇒ 租户必须入身份)。
+-- 旧唯一键 uk_SuppMaster_factory_supp 含 factory_ref_id,不予继承:
+--   Domain / company_ref_id / factory_ref_id **每租户各只有 1 个取值**(严格 f(tenant)、零判别力),
+--   且 factory_ref_id=1000 被 797403760988229 与 824585161322565 两租户共用。
+-- CRUD 的 (TenantId && FactoryRefId && Supp) 查重守卫不构成 factory 入身份的证据 ——
+--   其 factoryId 来自 AdoS0OrgScope.ResolveAsync(db, tenantId),仅凭 tenantId 解析并强制盖章、
+--   忽略 payload,故该守卫语义上等价于 tenant + Supp。
+-- 真实消费方按 tenant + 编码关联:SrmPurchase.SupplierNumber == Supp 命中 4447/4449;
+--   而 srm_purchase.supplier_id → SuppMaster.RecID 命中 0/4449 ⇒ RecID 无消费方,不物化。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_s0_supplier` (
+  `id`              bigint       NOT NULL AUTO_INCREMENT,
+  `tenant_id`       bigint       NOT NULL DEFAULT 0,
+  `factory_id`      bigint       DEFAULT NULL,
+  `source_system`   varchar(50)  NOT NULL DEFAULT 'AIDOPDEV_MYSQL',
+  `source_table`    varchar(100) NOT NULL,
+  `source_row_id`   varchar(100) NOT NULL,
+  `source_biz_key`  varchar(300) DEFAULT NULL,
+  `sync_batch_id`   varchar(100) NOT NULL,
+  `sync_time`       datetime     NOT NULL,
+  `process_status`  varchar(20)  NOT NULL DEFAULT 'PENDING',
+  `raw_data`        json         NOT NULL,
+  `process_message` varchar(500) DEFAULT NULL,
+  `update_time`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `create_time`     datetime     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_s0_supplier` (`tenant_id`,`source_system`,`source_table`,`source_row_id`),
+  KEY `idx_stg_s0_supplier_biz` (`source_biz_key`),
+  KEY `idx_stg_s0_supplier_batch` (`tenant_id`,`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S0 贴源:供应商(源 SuppMaster)';
+
+CREATE TABLE IF NOT EXISTS `dim_supplier` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '技术行号;每轮 FULL REPLACE 即变,禁止外部引用',
+  `tenant_id`         bigint       NOT NULL,
+  `supplier_code`     varchar(100) NOT NULL COMMENT '供应商编码(**唯一业务键**)← $.Supp',
+  `supplier_name`     varchar(200) DEFAULT NULL COMMENT '← $.SortName(DDL 注释即「简称」;本表无全称列,不发明第二个名称列)。🔴 21 行 NULL(全在租户 797),绝不能设 Required',
+  `is_active`         tinyint(1)   NOT NULL COMMENT '← $.IsActive(源 tinyint(1) NOT NULL,3462 启用 / 113 停用)',
+  `tax_included`      tinyint(1)   NOT NULL COMMENT '← $.TaxIn(含税;源 tinyint(1) NOT NULL,实测仅 1 行为 1)',
+  `domain_code`       varchar(50)  DEFAULT NULL COMMENT 'LEGACY_SCOPE ← $.Domain;f(tenant),**不是身份**',
+  `company_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.company_ref_id;f(tenant),**不是身份**',
+  `factory_id`        bigint       NOT NULL COMMENT 'LEGACY_SCOPE ← $.factory_ref_id;f(tenant) 且 1000 被两租户共用,**不是身份**',
+  `source_system`     varchar(50)  NOT NULL,
+  `source_biz_key`    varchar(300) NOT NULL,
+  `source_updated_at` datetime     DEFAULT NULL COMMENT '← $.UpdateTime;12 行 NULL(全在租户 838257186181189),故可空',
+  `sync_batch_id`     varchar(64)  NOT NULL,
+  `sync_time`         datetime     NOT NULL,
+  `create_time`       datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time`       datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  -- 索引字节 = 8 + 100*4 = 408 < 3072
+  UNIQUE KEY `uk_dim_supplier` (`tenant_id`,`supplier_code`),
+  KEY `idx_dim_supplier_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S0 主数据维度:供应商。源 SuppMaster,FULL REPLACE。**S0 的 Supplier 权威**,与 Supply/S3 的多源 mdp_std_supplier 语义不同。RecID 无消费方故不物化 legacy_source_id。';
+
+SET @src_id = (SELECT `id` FROM `mdp_source` WHERE `source_code` = 'AIDOPDEV_MYSQL' LIMIT 1);
+
+-- batch_size 20000:单租户最大 3185 行;默认 5000 只有 1.57× 头寸,而
+-- AssertNoSourceDuplicateAsync 是 sourceCount >= batchSize 硬失败。该值是 entity 级,不影响其它维度。
+INSERT INTO `mdp_entity`
+  (`tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+   `source_table_name`,`target_table_name`,`sync_mode`,`incr_column`,`batch_size`,
+   `biz_key_expr`,`status`,`create_time`,`update_time`)
+SELECT 0, @src_id, 'S0_SUPPLIER', 'S0 供应商入站', 'TABLE',
+       'SuppMaster', 'mdp_stg_s0_supplier', 'FULL', NULL, 20000,
+       'Supp', 1, NOW(), NOW()
+WHERE @src_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM `mdp_entity` WHERE `entity_code` = 'S0_SUPPLIER');
+
+-- ── 回滚(注释,非可执行)──
+-- DROP TABLE IF EXISTS `dim_supplier`, `mdp_stg_s0_supplier`;
+-- DELETE FROM `mdp_entity` WHERE `tenant_id`=0 AND `entity_code`='S0_SUPPLIER';

+ 53 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.515.verify.sql

@@ -0,0 +1,53 @@
+-- WIP-SUPPLIER.verify.sql
+
+SELECT (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME IN ('mdp_stg_s0_supplier','dim_supplier')) = 2;
+
+SELECT IFNULL((SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS
+               WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_supplier'
+                 AND INDEX_NAME = 'uk_dim_supplier' AND NON_UNIQUE = 0), '')
+       = 'tenant_id,supplier_code';
+
+-- LEGACY_SCOPE 绝不进唯一键
+SELECT (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'dim_supplier' AND INDEX_NAME = 'uk_dim_supplier'
+        AND COLUMN_NAME IN ('domain_code','company_id','factory_id')) = 0;
+
+-- 🔴 supplier_name 必须可空(SortName 有 21 行 NULL;设 NOT NULL 会让 21 行被滤掉导致对账 FAIL)
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'dim_supplier' AND COLUMN_NAME = 'supplier_name' AND IS_NULLABLE = 'YES') = 1;
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'dim_supplier' AND COLUMN_NAME = 'source_updated_at' AND IS_NULLABLE = 'YES') = 1;
+
+-- RecID 无消费方(srm_purchase.supplier_id 命中 0/4449),故不得物化 legacy_source_id
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'dim_supplier'
+        AND COLUMN_NAME IN ('legacy_source_id','rec_id','supplier_id')) = 0;
+
+-- 全空的 PII 与近空列不得被物化
+SELECT (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
+        AND TABLE_NAME = 'dim_supplier'
+        AND COLUMN_NAME IN ('address','contact','position','contact_info','certification_level',
+                            'pur_contact','remark','cr_terms','curr','tax_class',
+                            'ap_start_day1','ap_end_day1','ap_start_day2','ap_end_day2')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+        WHERE `entity_code` = 'S0_SUPPLIER'
+          AND `biz_key_expr` COLLATE utf8mb4_bin = 'Supp'
+          AND `source_table_name` COLLATE utf8mb4_bin = 'SuppMaster'
+          AND `target_table_name` COLLATE utf8mb4_bin = 'mdp_stg_s0_supplier'
+          AND `status` = 1 AND `sync_mode` = 'FULL' AND `incr_column` IS NULL
+          AND `batch_size` = 20000) = 1;
+
+-- batch_size 必须够装下最大租户(AssertNoSourceDuplicateAsync 是 >= 严格判定)
+SELECT (SELECT `batch_size` FROM `mdp_entity` WHERE `entity_code` = 'S0_SUPPLIER')
+       > (SELECT MAX(c) FROM (SELECT COUNT(*) c FROM `SuppMaster` GROUP BY `tenant_id`) x);
+
+-- 源侧身份前提:tenant + Supp 无重复、无空白、无非法租户
+SELECT (SELECT COUNT(*) FROM (SELECT 1 FROM `SuppMaster` GROUP BY `tenant_id`,`Supp` HAVING COUNT(*) > 1) x) = 0;
+SELECT (SELECT COUNT(*) FROM `SuppMaster` WHERE `Supp` IS NULL OR TRIM(`Supp`) = '') = 0;
+SELECT (SELECT COUNT(*) FROM `SuppMaster` WHERE `tenant_id` IS NULL OR `tenant_id` <= 0) = 0;
+
+-- 未因本批放大其它 entity 的 batch_size
+SELECT (SELECT COUNT(*) FROM `mdp_entity` WHERE `entity_code` LIKE 'S0\_%'
+        AND `batch_size` NOT IN (5000, 20000, 50000, 100000, 200000)) = 0;

+ 52 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Dim/S0DimContractTests.cs

@@ -35,7 +35,7 @@ public class S0DimContractTests
                 "SAMPLING_SCHEME", "INSPECTION_ITEM", "INSPECTION_METHOD",
                 "INSPECTION_FREQUENCY", "INSPECTION_INSTRUMENT",
                 // C1 扩展
-                "ITEM",
+                "SUPPLIER", "ITEM",
                 // C3
                 "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL",
                 // C3-A/C/B 物料桥(全部父 = dim_item)
@@ -1106,6 +1106,57 @@ public class S0DimContractTests
         Assert.Contains("SUM(CASE WHEN g.r_cnt = 0 THEN g.l_cnt ELSE 0 END)", sql, Ord);
     }
 
+    [Fact]
+    public void Supplier_identity_must_be_tenant_and_supplier_code_only()
+    {
+        // 旧 UK uk_SuppMaster_factory_supp 含 factory_ref_id,不予继承:
+        // Domain/company_ref_id/factory_ref_id 每租户各只有 1 个取值(严格 f(tenant)、零判别力),
+        // 且 factory_ref_id=1000 被两个租户共用。CRUD 的 (TenantId && FactoryRefId && Supp) 守卫
+        // 因 factoryId 来自 AdoS0OrgScope.ResolveAsync(db, tenantId) 而语义等价于 tenant + Supp。
+        var d = S0DimCatalog.Supplier;
+        Assert.Equal(["tenant_id", "supplier_code"], d.BusinessKeyColumns);
+        Assert.Equal(["Supp"], d.SourceBizKeyColumns);
+        foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
+            Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
+        Assert.Null(d.ParentDimTable);
+    }
+
+    [Fact]
+    public void Supplier_name_must_not_be_required()
+    {
+        // 🔴 SortName 有 21 行 NULL(全在租户 797)。误设 Required 会让 RowGuards 生成
+        // IS NOT NULL 过滤 → dim_count 3164 < stg_count 3185 → 阻断级对账 FAIL,
+        // 且报错指向对账而非字段声明,极难定位。source_updated_at 同理(12 行 NULL)。
+        var d = S0DimCatalog.Supplier;
+        Assert.False(d.Columns.Single(c => c.TargetColumn == "supplier_name").Required);
+        Assert.False(d.Columns.Single(c => c.TargetColumn == "source_updated_at").Required);
+        Assert.True(d.Columns.Single(c => c.TargetColumn == "supplier_code").Required);
+    }
+
+    [Fact]
+    public void Supplier_must_not_materialize_recid_or_empty_pii_columns()
+    {
+        // RecID 无读取消费方:srm_purchase.supplier_id → SuppMaster.RecID 命中 0/4449,
+        // 真正在用的是 supplier_number → Supp(4447/4449)。
+        // 联系人/地址/职务等 11 列全库非空白值为 0,且属 PII,一律不物化。
+        var paths = S0DimCatalog.Supplier.Columns.Select(c => c.JsonPath ?? "").ToList();
+        foreach (var no in new[] { "RecID", "address", "contact", "position", "contact_info",
+                                   "certification_level", "PurContact", "Remark",
+                                   "CrTerms", "Curr", "Type", "TaxClass" })
+            Assert.DoesNotContain(no, paths);
+        Assert.DoesNotContain("legacy_source_id", S0DimCatalog.Supplier.Columns.Select(c => c.TargetColumn));
+    }
+
+    [Fact]
+    public void Supplier_bool_columns_must_be_booltrue_not_int()
+    {
+        // IsActive / TaxIn 都是 tinyint(1) NOT NULL —— BoolTrue 的可辨识性守卫安全。
+        // 用 Int 会把 JSON BOOLEAN 静默压成 0(见 S0DimValueKind.Int 注释)。
+        var d = S0DimCatalog.Supplier;
+        Assert.Equal(S0DimValueKind.BoolTrue, d.Columns.Single(c => c.TargetColumn == "is_active").Kind);
+        Assert.Equal(S0DimValueKind.BoolTrue, d.Columns.Single(c => c.TargetColumn == "tax_included").Kind);
+    }
+
     [Fact]
     public void Item_identity_must_be_tenant_and_item_code_only()
     {

+ 68 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimCatalog.cs

@@ -612,6 +612,71 @@ public static class S0DimCatalog
             new("source_updated_at",      S0DimValueKind.DateTimeSec, "FMODIFYTIME")
         ]);
 
+    /// <summary>
+    /// 供应商。源 <c>SuppMaster</c> —— **S0 的 Supplier 权威**。
+    ///
+    /// <para><c>mdp_std_supplier</c> 属 Supply/S3 的多源标准层,**不是** S0 主数据权威,
+    /// 与本维度语义域不同(同 <see cref="Item"/> 对 <c>mdp_std_item</c> 的关系)。</para>
+    ///
+    /// <para><b>身份 = tenant_id + Supp</b>。实测租户内重复 <b>0 组</b>;
+    /// <c>Supp</c> 跨租户碰撞 <b>13 码 / 27 行</b> ⇒ 租户必须入身份。
+    /// 旧唯一键 <c>uk_SuppMaster_factory_supp</c> 含 <c>factory_ref_id</c>,**不予继承** ——
+    /// <c>Domain</c> / <c>company_ref_id</c> / <c>factory_ref_id</c> **每租户各只有 1 个取值**
+    /// (严格 f(tenant),零判别力),且 <c>factory_ref_id=1000</c> 被两个租户共用。</para>
+    ///
+    /// <para>CRUD 的查重守卫 <c>TenantId &amp;&amp; FactoryRefId &amp;&amp; Supp</c> **不构成 factory 应入身份的证据**:
+    /// 其 <c>factoryId</c> 来自 <c>AdoS0OrgScope.ResolveAsync(db, tenantId)</c> —— 仅凭 tenantId 解析、
+    /// 强制盖章、忽略 payload,故该守卫语义上等价于 <c>tenant + Supp</c>(D-025)。
+    /// 页面虽有「公司/工厂」下拉且 DTO 带 <c>[Range]</c> 必填,但 <c>CreateAsync</c>/<c>UpdateAsync</c>
+    /// **从不读取**这两个入参 —— UI 收集 ≠ 参与身份。</para>
+    ///
+    /// <para>真实消费方按 <b>tenant + 编码</b> 关联:<c>SrmPurchase.SupplierNumber == Supp</c> 命中
+    /// <b>4447/4449</b>;而 <c>srm_purchase.supplier_id → SuppMaster.RecID</c> 命中 <b>0/4449</b>
+    /// (存量是 19 位雪花值,而 <c>RecID</c> 最大 3585)⇒ <c>RecID</c> 无任何读取消费方,
+    /// **不物化 <c>legacy_source_id</c>**。</para>
+    ///
+    /// <para>🔴 <c>supplier_name</c> **绝不能设 Required**:<c>SortName</c> 有 <b>21 行 NULL</b>(全在租户 797)。
+    /// 误设会让 <c>RowGuards</c> 生成 <c>IS NOT NULL</c> 过滤 → <c>dim_count 3164 &lt; stg_count 3185</c>
+    /// → 阻断级对账 FAIL,且报错指向对账而非字段声明,极难定位。
+    /// 同理 <c>source_updated_at</c> 可空(<c>UpdateTime</c> 有 12 行 NULL,全在租户 838257186181189)。</para>
+    ///
+    /// <para>字段取舍(28 列 → 物化 9 列):本表 <b>11 列近乎全空</b> ——
+    /// <c>CrTerms</c>/<c>Curr</c>/<c>Type</c>/<c>TaxClass</c>/<c>address</c>/<c>contact</c>/
+    /// <c>position</c>/<c>contact_info</c>/<c>certification_level</c> 非空白值 <b>0/3575</b>;
+    /// <c>PurContact</c> 2 行、<c>Remark</c> 1 行。它们有真实 CRUD 写入路径、**不是影子列**,只是无人填;
+    /// 其中联系人/联系方式/职务另属 PII,即便将来填充也须重新评审。
+    /// <c>APStartDay1</c> 等 4 列虽 99.97% 非 NULL,但应用自身
+    /// (<c>FormatApDay</c> 的 <c>value == 0 → ""</c>)把 0 判为无值,有效填充率仅 2.01%/0.196%。</para>
+    ///
+    /// <para><c>IsActive</c> / <c>TaxIn</c> 都是 <c>tinyint(1) NOT NULL</c>(非 <c>bit(1)</c>、非可空),
+    /// <see cref="S0DimValueKind.BoolTrue"/> 的可辨识性守卫安全。
+    /// 单租户最大 3185 行,<c>batch_size</c> 取 <b>20000</b> —— 默认 5000 只有 1.57× 头寸,
+    /// 而 <c>AssertNoSourceDuplicateAsync</c> 是 <c>sourceCount &gt;= batchSize</c> 硬失败。</para>
+    /// </summary>
+    public static readonly S0DimDefinition Supplier = new(
+        Key: "SUPPLIER",
+        EntityCode: "S0_SUPPLIER",
+        SourceTable: "SuppMaster",
+        SourceSystem: LocalSourceSystem,
+        StagingTable: "mdp_stg_s0_supplier",
+        DimTable: "dim_supplier",
+        BusinessKeyColumns: ["tenant_id", "supplier_code"],
+        SourceBizKeyColumns: ["Supp"],
+        Columns:
+        [
+            new("tenant_id",         S0DimValueKind.TenantIdColumn),
+            new("supplier_code",     S0DimValueKind.Str,         "Supp",     Required: true),
+            // 🔴 21 行 NULL,绝不能 Required(本表没有全称列,SortName 的 DDL 注释即「简称」,不发明第二个名称列)
+            new("supplier_name",     S0DimValueKind.Str,         "SortName"),
+            new("is_active",         S0DimValueKind.BoolTrue,    "IsActive"),
+            new("tax_included",      S0DimValueKind.BoolTrue,    "TaxIn"),
+            // ── LEGACY_SCOPE(属性透传,绝不入唯一键;三者均为 f(tenant))
+            new("domain_code",       S0DimValueKind.Str,         "Domain"),
+            new("company_id",        S0DimValueKind.Int,         "company_ref_id", Required: true),
+            new("factory_id",        S0DimValueKind.Int,         "factory_ref_id", Required: true),
+            new("source_updated_at", S0DimValueKind.DateTimeSec, "UpdateTime")
+        ]);
+
     /// <summary>
     /// 物料。源 <c>ItemMaster</c> —— **S0 的 Item 权威**(2026-09-07 拍板)。
     ///
@@ -1237,8 +1302,9 @@ public static class S0DimCatalog
         LabelType, SopFileType, QualityBaseType,
         // C2-2 qms 字典族(共用 mdp_stg_s0_qms_dict)
         SamplingScheme, InspectionItem, InspectionMethod, InspectionFrequency, InspectionInstrument,
-        // C1 扩展:物料(S0 Item 权威;后续 6 张 C3 都要挂它作父,故必须排在 C3 之前)
-        Item,
+        // C1 扩展:供应商(S0 Supplier 权威)与物料(S0 Item 权威;
+        // 后续 6 张 C3 都要挂 Item 作父,故 Item 必须排在 C3 之前)
+        Supplier, Item,
         // C3 关系桥(EmpWorkDuty 的父 dim_employee 在前;LinePostSkill 的父 LinePost 紧邻其前)
         EmpWorkDuty, LinePost, LinePostSkill,
         // C3-A 物料桥(父 dim_item,必须排在 Item 之后)