澄清货源约束作用域,修正回填货源总数为 161 条

- 6.4 要点 1 补充 CHECK 约束的作用域说明,并加四种情形的对照表:
  该约束作用于货源行而非物料,既无供应商也无制造商的物料(70 条老数据)
  因为没有货源行而不受影响,被挡的只是「建了货源行却说不出是谁的货」
- 7.4 标题去掉「(待解决)」,改为「回源后拆分成多行」——这不是设计缺口,
  而是已定方案里的一个执行步骤,并写明拆分到多行的具体做法
- 核准 32 条多供应商物料的供应商家数分布为 {2: 32}(每条恰好 2 家),
  据此把回填货源总数由 129 修正为 161(涉及 3.2、D10、6.2、7.1、7.2、7.4)
This commit is contained in:
文睿 张 2026-10-15 15:18:51 +08:00
parent 1a4bd1d9e8
commit ba0bde3c33

View File

@ -62,7 +62,7 @@
- 有供应商的物料:121 行;有制造商的物料:10 行。 - 有供应商的物料:121 行;有制造商的物料:10 行。
- 两者都有的 2 行中,供应商与制造商是**同一个伙伴**(原厂直销)。 - 两者都有的 2 行中,供应商与制造商是**同一个伙伴**(原厂直销)。
- 回填时去重后共 **129 条**货源记录(121 + 10 − 2)。 - 按「每物料一条货源」计,去重后为 129 条(121 + 10 − 2);再加上 7.4 所述 32 条被截断的多供应商(每家一条),回填后共 **161 条**货源记录。
两个直接推论:多制造商目前几乎没有数据基础(10/199),改造的迁移压力很小;"只登记供应商、不填制造商"是主流场景(119 行),因此**货源的制造商维度必须可空**。 两个直接推论:多制造商目前几乎没有数据基础(10/199),改造的迁移压力很小;"只登记供应商、不填制造商"是主流场景(119 行),因此**货源的制造商维度必须可空**。
@ -112,7 +112,7 @@
| D7 | 公司与物料一致性 | **全部放到应用层校验**,数据库只保留单列外键,不引入新的复合外键 | | D7 | 公司与物料一致性 | **全部放到应用层校验**,数据库只保留单列外键,不引入新的复合外键 |
| D8 | `status` 取值 | `active` / `obsolete`,与物料及 BOM 版本一致 | | D8 | `status` 取值 | `active` / `obsolete`,与物料及 BOM 版本一致 |
| D9 | `uk_organization_partners_org_id` | 删除。它是为本次废弃的复合外键而建,此后无任何消费者 | | D9 | `uk_organization_partners_org_id` | 删除。它是为本次废弃的复合外键而建,此后无任何消费者 |
| D10 | `init.sql` 数据改造方式 | **临时表中转**:199 行物料原样插入临时表后分流,避免手工搬运 129 条货源 | | D10 | `init.sql` 数据改造方式 | **临时表中转**:199 行物料原样插入临时表后分流,避免手工搬运 161 条货源 |
关于 D6 的依据:物料本身即软停用(`UPDATE materials SET status = 'obsolete'`,`internal/repository/material.go:510`;恢复见 `:545`),其唯一键 `uk_materials_org_code` **不含 `status`**,即作废的物料仍占着物料码、只能恢复不能重建;查询侧也早已习惯带 status 过滤(`internal/repository/material.go:261-264`)。货源沿用同一模型是自洽的。 关于 D6 的依据:物料本身即软停用(`UPDATE materials SET status = 'obsolete'`,`internal/repository/material.go:510`;恢复见 `:545`),其唯一键 `uk_materials_org_code` **不含 `status`**,即作废的物料仍占着物料码、只能恢复不能重建;查询侧也早已习惯带 status 过滤(`internal/repository/material.go:261-264`)。货源沿用同一模型是自洽的。
@ -189,7 +189,7 @@ CREATE TABLE material_suppliers (
| `effective_from` / `effective_to` | **删除**(对应 D2) | | `effective_from` / `effective_to` | **删除**(对应 D2) |
| `created_at` / `updated_at` | 新增,与其他表一致 | | `created_at` / `updated_at` | 新增,与其他表一致 |
`price` / `currency` 必须改可空的原因很直接:回填的 129 条老数据没有任何价格信息,硬性要求非空则迁不动。 `price` / `currency` 必须改可空的原因很直接:回填的 161 条老数据没有任何价格信息,硬性要求非空则迁不动。
### 6.3 `materials`(删列) ### 6.3 `materials`(删列)
@ -205,7 +205,18 @@ CREATE TABLE material_suppliers (
**要点 1 — 用生成列解决 NULL 让唯一键失效。** 唯一键 `(organization_id, material_id, supplier_id, material_manufacturer_id)` 挡不住重复,因为 SQL 中 `NULL <> NULL`,同一物料的 `(A, NULL)` 可以插入多行。而"只登记供应商不填制造商"是主流场景(119/199)。解法是把参与唯一键的列包成 `COALESCE(..., 0)` 生成列,同时原列保持 NULL(单列外键对 NULL 自动放行,正好表达"未指定")。同一手法在本项目已有先例:`user_organizations`(`scripts/init.sql:14`)。 **要点 1 — 用生成列解决 NULL 让唯一键失效。** 唯一键 `(organization_id, material_id, supplier_id, material_manufacturer_id)` 挡不住重复,因为 SQL 中 `NULL <> NULL`,同一物料的 `(A, NULL)` 可以插入多行。而"只登记供应商不填制造商"是主流场景(119/199)。解法是把参与唯一键的列包成 `COALESCE(..., 0)` 生成列,同时原列保持 NULL(单列外键对 NULL 自动放行,正好表达"未指定")。同一手法在本项目已有先例:`user_organizations`(`scripts/init.sql:14`)。
但同时为空的货源行没有意义——既没有供应商也没有制造商,它不描述任何关系。用 `CHECK (supplier_id IS NOT NULL OR material_manufacturer_id IS NOT NULL)` 挡在数据库层。MySQL 8.0.16 起 `CHECK` 才真正执行约束,本项目用 `mysql:8.0` 镜像,满足要求。 但同时为空的**货源行**没有意义——既没有供应商也没有制造商,它不描述任何关系。用 `CHECK (supplier_id IS NOT NULL OR material_manufacturer_id IS NOT NULL)` 挡在数据库层。MySQL 8.0.16 起 `CHECK` 才真正执行约束,本项目用 `mysql:8.0` 镜像,满足要求。
**这条约束作用于货源行,不作用于物料,两者不要混淆:**
| 情况 | `material_suppliers` 里有没有行 | 是否合法 |
| --- | --- | --- |
| 物料既无供应商也无制造商(老数据 70 条,见 3.2) | **没有行** | 合法。约束管不到「没有行」 |
| 物料有一条货源行,只填了供应商 | 有一行,`material_manufacturer_id` 为 NULL | 合法(119/199 的主流形态) |
| 物料有一条货源行,只填了制造商 | 有一行,`supplier_id` 为 NULL | 合法 |
| 物料有一条货源行,两个伙伴都为空 | 有一行,两列均 NULL | **被挡住**——这行不承载任何信息 |
也就是说「物料可以既无供应商又无制造商」这个合法状态完全不受影响,被挡的只是「建了货源行却说不清是谁的货」。
注意 `price` / `currency` 可空与 `minimum_order_quantity` / `lead_time_days` 非空并不矛盾:前者的 NULL 表示"尚未报价",是未知;后者的 `0` 表示"无最小订购量""无固定交期",是明确的默认值,且原表设计如此。 注意 `price` / `currency` 可空与 `minimum_order_quantity` / `lead_time_days` 非空并不矛盾:前者的 NULL 表示"尚未报价",是未知;后者的 `0` 表示"无最小订购量""无固定交期",是明确的默认值,且原表设计如此。
@ -239,9 +250,9 @@ CREATE TABLE material_suppliers (
| 9 | 武汉物料 INSERT 列清单(`:739-741`) | 同 6 | | 9 | 武汉物料 INSERT 列清单(`:739-741`) | 同 6 |
| 10 | 武汉 183 行 VALUES | 同 7 | | 10 | 武汉 183 行 VALUES | 同 7 |
| 11 | 武汉 `ON DUPLICATE KEY UPDATE`(`:1495`) | 同 8 | | 11 | 武汉 `ON DUPLICATE KEY UPDATE`(`:1495`) | 同 8 |
| 12 | 新增货源数据 | 129 行 `material_suppliers` + 10 行 `material_manufacturers` | | 12 | 新增货源数据 | 161 行 `material_suppliers` + 10 行 `material_manufacturers` |
### 7.2 129 条货源数据的生成方式:临时表中转(D10) ### 7.2 161 条货源数据的生成方式:临时表中转(D10)
改造后无法再用 `INSERT ... SELECT` 从 `materials` 派生货源——`init.sql` 是一遍建成的初始化脚本,新的 `CREATE TABLE materials` 已不含那两列,物料 INSERT 也不再写入它们,因此没有来源可供 SELECT。采用临时表中转,按下列顺序执行: 改造后无法再用 `INSERT ... SELECT` 从 `materials` 派生货源——`init.sql` 是一遍建成的初始化脚本,新的 `CREATE TABLE materials` 已不含那两列,物料 INSERT 也不再写入它们,因此没有来源可供 SELECT。采用临时表中转,按下列顺序执行:
@ -257,7 +268,7 @@ CREATE TABLE material_suppliers (
- **顺序不能变。** 第 5 步依赖第 3、4 步已经落库(要 JOIN 出 `material_id` 和 `material_manufacturer_id`),所以 `materials` 与 `material_manufacturers` 必须先完成写入。 - **顺序不能变。** 第 5 步依赖第 3、4 步已经落库(要 JOIN 出 `material_id` 和 `material_manufacturer_id`),所以 `materials` 与 `material_manufacturers` 必须先完成写入。
- **每一步都要保持幂等。** 该脚本的既有约定是可重复执行,`materials` 用 `ON DUPLICATE KEY UPDATE`(子句中已无那两个字段),`material_manufacturers` 与 `material_suppliers` 也要用 `ON DUPLICATE KEY UPDATE` 或 `NOT EXISTS`,否则重跑会撞唯一键失败。 - **每一步都要保持幂等。** 该脚本的既有约定是可重复执行,`materials` 用 `ON DUPLICATE KEY UPDATE`(子句中已无那两个字段),`material_manufacturers` 与 `material_suppliers` 也要用 `ON DUPLICATE KEY UPDATE` 或 `NOT EXISTS`,否则重跑会撞唯一键失败。
收益是字面量只写一次且保持原样,129 条货源由 SQL 自动派生,避免手工搬运 129 行的错漏。代价是脚本里多一段临时表逻辑。 收益是字面量只写一次且保持原样,129 条基础货源由 SQL 自动派生,避免手工搬运的错漏;多出的 32 条拆分来源见 7.4。代价是脚本里多一段临时表逻辑。
伙伴变量(`@p_bj_00x` / `@p_wh_0xx`,定义于 `:616-652`)继续可用,因为伙伴数据本身不变。 伙伴变量(`@p_bj_00x` / `@p_wh_0xx`,定义于 `:616-652`)继续可用,因为伙伴数据本身不变。
@ -265,7 +276,7 @@ CREATE TABLE material_suppliers (
`manufacturer_model` 现有数据全为 NULL(见 3.3),D4 拆出的 `material_manufacturers.model` 只有建表工作,没有迁移工作。 `manufacturer_model` 现有数据全为 NULL(见 3.3),D4 拆出的 `material_manufacturers.model` 只有建表工作,没有迁移工作。
### 7.4 被单值字段截断的多供应商数据需要回源(待解决) ### 7.4 被单值字段截断的多供应商数据:回源后拆分成多行
源库里有物料用 `/` 或换行写了多家供应商,而 `materials.supplier_id` 是单值外键,迁移时只保留了第一家,其余被丢弃,共涉及 32 条物料: 源库里有物料用 `/` 或换行写了多家供应商,而 `materials.supplier_id` 是单值外键,迁移时只保留了第一家,其余被丢弃,共涉及 32 条物料:
@ -305,7 +316,7 @@ CREATE TABLE material_suppliers (
2. **制造商没有同类问题。** 两个源库用全部七种分隔符检查后,多值记录均为 0,因此 `manufacturer_id` 的截断不构成额外缺口。 2. **制造商没有同类问题。** 两个源库用全部七种分隔符检查后,多值记录均为 0,因此 `manufacturer_id` 的截断不构成额外缺口。
3. **涉及的供应商伙伴全部已建好。** 迁移脚本对拆出的**每一个**名字都建了伙伴(`migrate_to_mysql.py:323-326`:`for name in split_partners(row["supplier"]): touch(name, "is_supplier")`),不只是第一家。所以恢复多供应商关系**只需补 `material_suppliers` 的行**,不需要新增伙伴主数据。 3. **涉及的供应商伙伴全部已建好。** 迁移脚本对拆出的**每一个**名字都建了伙伴(`migrate_to_mysql.py:323-326`:`for name in split_partners(row["supplier"]): touch(name, "is_supplier")`),不只是第一家。所以恢复多供应商关系**只需补 `material_suppliers` 的行**,不需要新增伙伴主数据。
因此回填时,这 32 条需从源库取全部供应商、按上述规则拆分后写入;其余 167 条(199 − 32)沿用现有字面量的单值即可。 因此回填时,这 32 条需从源库取全部供应商、按上述规则拆分后写入**多行**——实测每条恰好 2 家,故每条多出 1 行,32 条共多出 32 行;其余 167 条(199 − 32)沿用现有字面量的单值。合并后即 3.2 与 7.1 所述的 161 条。
本地库没有增量升级路径,改完删库用 `init.sql` 重建即可。 本地库没有增量升级路径,改完删库用 `init.sql` 重建即可。