material_manage_system/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md
文睿 张 ba0bde3c33 澄清货源约束作用域,修正回填货源总数为 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)
2026-10-15 15:18:51 +08:00

334 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 物料多供应商与多制造商关系设计(数据库层)
**日期:** 2026-10-15
**状态:** 初稿,待评审
**适用范围:** warehouse-mms 数据库结构
## 1. 目标
让一个物料可以关联多个供应商和多个制造商,并且能表达出三者之间的对应关系——即"哪家供应商供的是哪家制造的货",同时保留每条货源的采购条件(价格、币种、最小订购量、供货周期、首选标记)。
本次只确定数据库结构,前后端改造另行设计。
## 2. 范围
### 2.1 本次包含
- 物料、供应商、制造商三者关系的表结构。
- 相关表的字段、唯一键、外键、索引设计。
- 现有 `materials` 表上单值供应商/制造商字段的处置。
- 现有 `material_suppliers` 空表的改造。
- 新旧数据的数据回填方案。
### 2.2 本次不包含
- 后端 model / repository / service / handler 改造。
- 物料判重规则的调整。
- Excel 批量导入的模板与解析调整。
- 前端表单、列表、详情的展示与交互。
- 权限模型调整。
- 物料日志内容调整。
## 3. 现状
### 3.1 数据库现状
| 对象 | 现状 | 位置 |
| --- | --- | --- |
| `materials.supplier_id` | 单值可空外键,语义为"默认供应商" | `scripts/init.sql:19` |
| `materials.manufacturer_id` | 单值可空外键,语义为"制造商" | `scripts/init.sql:19` |
| `materials.manufacturer_model` | 厂家型号,物料上的单值文本;**现有数据全为 NULL**(见 3.3) | `scripts/init.sql:19` |
| `material_suppliers` | 物料-供应商关系表,带采购条件;**表已建、零数据、零代码引用** | `scripts/init.sql:34` |
| `material_manufacturers` | 不存在 | — |
三个必须先说明的事实:
1. `materials` 当前通过复合外键 `FOREIGN KEY(organization_id, supplier_id) REFERENCES organization_partners(organization_id, id)` 保证伙伴属于同一公司(`scripts/init.sql:32-33`),依赖 `organization_partners` 上的唯一键 `uk_organization_partners_org_id(organization_id, id)`(`scripts/init.sql:27`)。该唯一键目前全库只有 `materials` 一处消费。
2. `material_suppliers` 设计不完整:它只有查询索引 `idx_material_suppliers_material`,**没有唯一键**;`organization_partner_id` 是**单列**外键,没有公司隔离;**没有 `created_at` / `updated_at`**;`effective_from DATE NOT NULL` 无默认值。由于该表是空的且无代码引用,改造代价为零。
3. 文档已明确 `material_suppliers` 的角色:`docs/数据库表结构与关系说明.md:255` 写明"现有 `material_suppliers` 继续保存多个供应商的价格与供货条件;物料的 `supplier_id` 只表示默认选择";`:196` 注明"当前后端未见完整维护路径"。
### 3.2 实测数据
对 `scripts/init.sql` 中 `materials` 的 199 行 INSERT 逐行统计:
| `(supplier_id, manufacturer_id)` | 行数 |
| --- | --- |
| 两者皆空 | 70 |
| 只有供应商 | 119 |
| 只有制造商 | 8 |
| 两者都有 | 2 |
- 有供应商的物料:121 行;有制造商的物料:10 行。
- 两者都有的 2 行中,供应商与制造商是**同一个伙伴**(原厂直销)。
- 按「每物料一条货源」计,去重后为 129 条(121 + 10 − 2);再加上 7.4 所述 32 条被截断的多供应商(每家一条),回填后共 **161 条**货源记录。
两个直接推论:多制造商目前几乎没有数据基础(10/199),改造的迁移压力很小;"只登记供应商、不填制造商"是主流场景(119 行),因此**货源的制造商维度必须可空**。
### 3.3 迁移脚本层面的两个发现
检查 `scripts/init.sql` 中物料数据的写入语句后,有两个直接决定改造方式的事实:
1. **`manufacturer_model` 是死列。** 该列在全文件中只出现在第 19 行的 `CREATE TABLE`:199 条物料的 INSERT 列清单里没有它,也没有任何 `UPDATE` 给它赋值,前端表单同样不含该字段(`frontend/src/views/materialForm.js:3-16`)。即现有数据的这一列**全部为 NULL**。因此 D4 拆表时没有型号数据需要迁移。
2. **物料插入不是裸 `INSERT`,而是幂等写入。** 北京 16 条(`scripts/init.sql:731`)与武汉 183 条(`:1495`)各带一段 `ON DUPLICATE KEY UPDATE` 子句,其中包含 `supplier_id = new.supplier_id, manufacturer_id = new.manufacturer_id`。删列时必须同步删除这些赋值,否则整个初始化脚本会因 `Unknown column` 直接失败。
## 4. 关系模型论证
### 4.1 为什么是三元关系
「多个供应商」和「多个制造商」如果各自独立存储,会丢失一条关键业务信息:**哪家供应商供的是哪家的货**。
现实中一条采购记录的本质是"我从 A 买,买到的是 M 造的货,价格 P、交期 D"。供应商和制造商不是物料的两个平行属性,而是同一条货源记录上的两个维度。因此真正的实体是 `(物料, 供应商, 制造商)`,价格、币种、最小订购量、供货周期都是这个三元组上的属性。
三条佐证:
1. 预置的 `material_suppliers` 把 `price` / `currency` / `minimum_order_quantity` / `lead_time_days` / `is_preferred` 全部带上了,原始设计意图就是"货源"而非"清单"。
2. `materials.manufacturer_model` 是物料上的单值文本,多制造商时必然冲突(M1 是 `ESF32016H24`、M2 是别的型号),说明型号也应随制造商走。
3. 现有数据中供应商与制造商为同一伙伴的那 2 条,在"两个独立列表"模型下会显示成两个相同的名字,语义是坏的。
反过来不成立:三元模型能退化成两个清单(每行只填供应商、制造商留空即可),两个清单却推不出配对。
### 4.2 被否决的候选模型
| 模型 | 说明 | 否决理由 |
| --- | --- | --- |
| 两张平行列表 | `material_suppliers` + 新建 `material_manufacturers`,互不关联 | 无法表达配对,将来做比价或替代货源需推倒重构 |
| 物料变体拆分 | 不同制造商拆成不同物料码,用替代料关系关联 | 物料码膨胀(10 家制造商 = 10 个物料码),采购与库存口径分裂 |
| 型号留在物料上 | `manufacturer_model` 不下移 | 多制造商时一个字段装不下,语义残缺 |
最终采用**三元关系表 + 独立的制造商表**:货源表承载"物料 × 供应商 × 指向的制造商条目 × 采购条件",制造商表单独承载"物料 × 制造商 × 型号"。
## 5. 已确定的决策
| 序号 | 决策 | 结论 |
| --- | --- | --- |
| D1 | 关系语义 | 三元配对,且带采购条件 |
| D2 | 价格历史 | 只存当前价,唯一键不含生效日期;历史靠 `material_logs` |
| D3 | `materials` 旧字段 | 删除 `supplier_id`、`manufacturer_id`、`manufacturer_model` 及相关外键索引 |
| D4 | 厂家型号归属 | 拆出 `material_manufacturers` 第三张表 |
| D5 | 货源表表名 | 沿用 `material_suppliers`,不改名 |
| D6 | 停用方式 | 软停用,与 `materials` 保持一致 |
| D7 | 公司与物料一致性 | **全部放到应用层校验**,数据库只保留单列外键,不引入新的复合外键 |
| D8 | `status` 取值 | `active` / `obsolete`,与物料及 BOM 版本一致 |
| D9 | `uk_organization_partners_org_id` | 删除。它是为本次废弃的复合外键而建,此后无任何消费者 |
| 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`)。货源沿用同一模型是自洽的。
关于 D7 的后果:不引入复合外键意味着数据库**不再保证**伙伴与物料属于同一公司,也**不再保证**货源指向的制造商条目属于同一物料。这些检查全部由应用层承接,清单见第 8 节第 5 条。
## 6. 表设计
### 6.1 `material_manufacturers`(新建)
物料可用的制造商及其型号。
```sql
CREATE TABLE material_manufacturers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
manufacturer_id BIGINT NOT NULL COMMENT 'organization_partners ID',
model VARCHAR(200) NULL COMMENT '厂家型号',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_material_manufacturers_dim(organization_id, material_id, manufacturer_id),
FOREIGN KEY(material_id) REFERENCES materials(id),
FOREIGN KEY(manufacturer_id) REFERENCES organization_partners(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- `uk_material_manufacturers_dim` 保证同一物料下同一制造商只有一条。
- 由于 `manufacturer_id` 非空,本表不需要 `COALESCE` 生成列。
- `status` 同样采用软停用,且同样**不进入唯一键**,行为与 6.4 要点 3 描述的货源一致:停用过的制造商条目仍占着 `(物料, 制造商)` 键位,重新启用只能恢复,不能重建。
### 6.2 `material_suppliers`(改造现有空表)
每条记录表示一条货源:某供应商供某物料、供应的是哪个制造商条目、以及该货源的采购条件。
```sql
CREATE TABLE material_suppliers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
supplier_id BIGINT NULL COMMENT 'organization_partners ID',
material_manufacturer_id BIGINT NULL COMMENT 'material_manufacturers ID',
price DECIMAL(20,6) NULL,
currency CHAR(3) NULL,
minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0,
lead_time_days INT NOT NULL DEFAULT 0,
is_preferred BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
supplier_key BIGINT GENERATED ALWAYS AS (COALESCE(supplier_id, 0)) VIRTUAL,
manufacturer_entry_key BIGINT GENERATED ALWAYS AS (COALESCE(material_manufacturer_id, 0)) VIRTUAL,
preferred_row BIGINT GENERATED ALWAYS AS (CASE WHEN is_preferred AND status = 'active' THEN id ELSE NULL END) VIRTUAL,
UNIQUE KEY uk_material_suppliers_dim(organization_id, material_id, supplier_key, manufacturer_entry_key),
UNIQUE KEY uk_material_suppliers_preferred(organization_id, material_id, preferred_row),
CONSTRAINT chk_material_suppliers_dimension CHECK (supplier_id IS NOT NULL OR material_manufacturer_id IS NOT NULL),
KEY idx_material_suppliers_supplier(organization_id, supplier_key),
KEY idx_material_suppliers_manufacturer(organization_id, manufacturer_entry_key),
FOREIGN KEY(material_id) REFERENCES materials(id),
FOREIGN KEY(supplier_id) REFERENCES organization_partners(id),
FOREIGN KEY(material_manufacturer_id) REFERENCES material_manufacturers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
相对现有结构的字段变化:
| 列 | 变化 |
| --- | --- |
| `organization_partner_id` | 改名为 `supplier_id`,保持可空 |
| `material_manufacturer_id` | 新增,可空 |
| `price` / `currency` | 由 `NOT NULL` 改为**可空** |
| `minimum_order_quantity` / `lead_time_days` | 保留 `NOT NULL DEFAULT 0` |
| `is_preferred` / `status` | 保留,`status` 默认 `active` |
| `effective_from` / `effective_to` | **删除**(对应 D2) |
| `created_at` / `updated_at` | 新增,与其他表一致 |
`price` / `currency` 必须改可空的原因很直接:回填的 161 条老数据没有任何价格信息,硬性要求非空则迁不动。
### 6.3 `materials`(删列)
删除以下对象:
- 列:`supplier_id`、`manufacturer_id`、`manufacturer_model`
- 外键:`fk_materials_supplier`、`fk_materials_manufacturer`(`scripts/init.sql:32-33`)
- 索引:`idx_materials_org_supplier`、`idx_materials_org_manufacturer`(`scripts/init.sql:30-31`)
连带删除:`organization_partners` 上的 `uk_organization_partners_org_id(organization_id, id)`(`scripts/init.sql:27`)。它是为上面两条复合外键而建的,本次改造后不再被任何表消费;且 `id` 已是主键,该组合唯一键本身冗余,删除只省一个索引,无功能影响(对应 D9)。
### 6.4 约束设计的三个要点
**要点 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` 镜像,满足要求。
**这条约束作用于货源行,不作用于物料,两者不要混淆:**
| 情况 | `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` 表示"无最小订购量""无固定交期",是明确的默认值,且原表设计如此。
**要点 2 — 公司隔离与物料一致性由应用层承接(对应 D7)。** 因为不再使用复合外键,数据库不会阻止以下三种脏引用:货源或制造商条目的 `organization_id` 与物料不同、货源指向的制造商条目属于另一个物料、伙伴不属于本公司。接口层必须在写入前逐条校验,具体清单见第 8 节第 5 条。
这个取舍与项目其他表一致:分类的 `parent_id`、`base_unit_id`、`warehouse_id`、`material_id` 等也都是单列外键,公司一致性统一由服务层保证(`docs/数据库表结构与关系说明.md:237`)。本次改造后,原先只此一处的复合外键模式将完全退出。
**要点 3 — 两个唯一键对 `status` 的态度不同,这是有意的不对称。**
- 三元组唯一键 `uk_material_suppliers_dim` **不含 status**:停用的货源仍占着它的三元组键位,重新启用只能走"恢复"(UPDATE status),不能 INSERT。这与物料的行为完全一致(作废物料仍占物料码)。
- 首选唯一键 `uk_material_suppliers_preferred` **必须含 status 条件**(写在 `preferred_row` 生成列里):否则一条首选货源停用后仍占着首选位,新的首选永远插不进来。
区别在于:物料编码是业务唯一标识,停用后不该被复用;而"首选"只是当前生效的指向,停用后理应让位。
## 7. init.sql 的连带改动与数据回填
`scripts/init.sql` 同时是结构定义与数据载体(空库初始化、可重复执行)。删改 `materials` 的列会波及文件内多处,必须一并处理,否则脚本无法执行。
### 7.1 必须同步修改的位置
| # | 位置 | 改动 |
| --- | --- | --- |
| 1 | `CREATE TABLE materials`(`:19`) | 删 `supplier_id`、`manufacturer_id`、`manufacturer_model` 三列 |
| 2 | `ALTER TABLE materials`(`:30-33`) | 整段删除(2 个索引 + 2 个复合外键,对应 D3) |
| 3 | `CREATE TABLE organization_partners`(`:27`) | 删 `uk_organization_partners_org_id`(对应 D9) |
| 4 | 新建 `material_manufacturers` | 见 6.1 |
| 5 | 新建 `material_suppliers` | 见 6.2,替换原 `:34` 的建表语句 |
| 6 | 北京物料 INSERT 列清单(`:663`) | 删 `supplier_id, manufacturer_id` 两个列名 |
| 7 | 北京 16 行 VALUES | 每行删对应的 2 个值 |
| 8 | 北京 `ON DUPLICATE KEY UPDATE`(`:731-735`) | 删 `supplier_id = new.supplier_id, manufacturer_id = new.manufacturer_id` |
| 9 | 武汉物料 INSERT 列清单(`:739-741`) | 同 6 |
| 10 | 武汉 183 行 VALUES | 同 7 |
| 11 | 武汉 `ON DUPLICATE KEY UPDATE`(`:1495`) | 同 8 |
| 12 | 新增货源数据 | 161 行 `material_suppliers` + 10 行 `material_manufacturers` |
### 7.2 161 条货源数据的生成方式:临时表中转(D10)
改造后无法再用 `INSERT ... SELECT` 从 `materials` 派生货源——`init.sql` 是一遍建成的初始化脚本,新的 `CREATE TABLE materials` 已不含那两列,物料 INSERT 也不再写入它们,因此没有来源可供 SELECT。采用临时表中转,按下列顺序执行:
1. **建临时表**,列与原物料 INSERT 完全一致(含 `supplier_id` / `manufacturer_id`)。
2. **199 行 VALUES 原样插入临时表**——这部分字面量完全不动。
3. **物料进 `materials`**:从临时表按新列清单(已删列)写入,保留幂等处理。
4. **制造商进 `material_manufacturers`**:从临时表 JOIN `materials`(关联键是 `organization_id` + `material_code`)取到 `material_id`,`manufacturer_id` 非空者各生成一行。
5. **货源进 `material_suppliers`**:从临时表 JOIN `materials` 取 `material_id`,再 LEFT JOIN `material_manufacturers` 取 `material_manufacturer_id`;`supplier_id` 或 `manufacturer_id` 非空者各生成一行,`is_preferred = TRUE`、`status = 'active'`。供应商与制造商皆有的 2 条生成**一行**(同时带两个维度),而非两行。
6. **删除临时表**。
两个必须守住的点:
- **顺序不能变。** 第 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`,否则重跑会撞唯一键失败。
收益是字面量只写一次且保持原样,129 条基础货源由 SQL 自动派生,避免手工搬运的错漏;多出的 32 条拆分来源见 7.4。代价是脚本里多一段临时表逻辑。
伙伴变量(`@p_bj_00x` / `@p_wh_0xx`,定义于 `:616-652`)继续可用,因为伙伴数据本身不变。
### 7.3 型号无历史数据
`manufacturer_model` 现有数据全为 NULL(见 3.3),D4 拆出的 `material_manufacturers.model` 只有建表工作,没有迁移工作。
### 7.4 被单值字段截断的多供应商数据:回源后拆分成多行
源库里有物料用 `/` 或换行写了多家供应商,而 `materials.supplier_id` 是单值外键,迁移时只保留了第一家,其余被丢弃,共涉及 32 条物料:
| 组织 | 物料编码 | 源里的供应商 | 落库取值 |
| --- | --- | --- | --- |
| 武汉 | `B010019` | 北京致远合创电子科技有限公司、欧时电子元件(上海)有限公司 | 北京致远合创电子科技有限公司 |
| 武汉 | `A030001` | 东莞市纳百智造技术有限公司、武汉市普创模型技术有限公司 | 东莞市纳百智造技术有限公司 |
| 武汉 | `A030002` | 同上 | 东莞市纳百智造技术有限公司 |
| 武汉 | `A020001` | 同上 | 东莞市纳百智造技术有限公司 |
两个关键事实:
1. **伙伴主数据没有丢。** 被丢弃的第二家已经建成了伙伴记录,例如 `WH-P008` 欧时电子元件(上海)有限公司(`scripts/init.sql:569`)与 `WH-P010` 武汉市普创模型技术有限公司(`:571`),`is_supplier` 均为 TRUE。所以恢复多供应商关系只需补 `material_suppliers` 的行,不需要新建伙伴。
2. **但对应关系丢了。** 这两个伙伴变量 `@p_wh_008` / `@p_wh_010`(`:626`、`:628`)在全文件中只出现一次,即定义处本身,**没有任何物料引用它们**。"这两家还供了哪些物料"在 `init.sql` 中完全不存在,只能回源 SQLite 库重取。
这打破了 D10 的一个隐含前提——它假定现有 199 行字面量是完整的,但这 32 条的字面量本身已残缺(只剩第一家)。**临时表的数据源不能原样照搬,这 32 条需要从源库补全后再写入。**
待办:
- 源库位置未在仓库中找到(`sqlite-to-mysql-migration/` 目录不存在)。
- 需一并核查制造商的源字段是否也存在同样的多值截断。
#### 核实结果(2026-10-15 已查源库)
源库位置:`C:\1111\12.project\PyCharm\sqlite-to-mysql-migration`(在仓库外)。武汉原始库为 `original/material-apply-wuhan/material.db`,北京为 `backup/recovered_material_beijing.db`。
用与 `migrate_to_mysql.py` 相同的拆分规则(`PARTNER_SEPARATORS`,共 `\r\n`、`\n`、`\r`、`/`、`、`、`;`、`;` 七种)复现统计:
| 源库 | 物料 | 多供应商 | 多制造商 |
| --- | --: | --: | --: |
| 武汉 | 183 | **32** | **0** |
| 北京 | 16 | 0 | 0 |
三点结论:
1. 32 条多供应商**全部在武汉**,与 `migration-report.md` 第 3.1 节一致(该节含完整中文清单)。
2. **制造商没有同类问题。** 两个源库用全部七种分隔符检查后,多值记录均为 0,因此 `manufacturer_id` 的截断不构成额外缺口。
3. **涉及的供应商伙伴全部已建好。** 迁移脚本对拆出的**每一个**名字都建了伙伴(`migrate_to_mysql.py:323-326`:`for name in split_partners(row["supplier"]): touch(name, "is_supplier")`),不只是第一家。所以恢复多供应商关系**只需补 `material_suppliers` 的行**,不需要新增伙伴主数据。
因此回填时,这 32 条需从源库取全部供应商、按上述规则拆分后写入**多行**——实测每条恰好 2 家,故每条多出 1 行,32 条共多出 32 行;其余 167 条(199 − 32)沿用现有字面量的单值。合并后即 3.2 与 7.1 所述的 161 条。
本地库没有增量升级路径,改完删库用 `init.sql` 重建即可。
## 8. 后续(应用层,本文不覆盖)
以下事项由本次结构变更推导而来,需要另行设计,此处仅作记录:
1. **写入模式必须是差异对比,不能是整表替换。** 软停用(D6)意味着"提交列表有、库中没有"→ INSERT,"库中有、提交列表没有"→ UPDATE 为 `obsolete`,两侧都有 → UPDATE 采购条件。原本可照抄 `user_roles` 的 delete-then-insert 模式(`internal/repository/user.go:132-170`)会直接物理删除停用记录,与 D6 冲突。
2. **判重规则失效。** `FindExactDuplicates` 目前把 `supplier_id` / `manufacturer_id` 作为精确比对维度(`internal/repository/material.go:315-321`),而这两列将被删除。需要重新定义判重维度。
3. **Excel 导入表达不了三元组。** 模板的供应商/制造商两列是文本(`internal/service/material_import_excel.go:32`),且导入坚持"只匹配已有伙伴、绝不新建"(`internal/service/material_import.go:225-261`)。需要决定导入是否只填默认货源。
4. **列表与详情展示多值。** 当前列表列宽 `min-width="140"` 渲染单值文本(`frontend/src/views/Materials.vue:68-77`),详情页用 `el-descriptions` 单行(`:172-173`)。
5. **接口层必须承接的完整性校验(对应 D7)。** 每条货源写入前依次校验:伙伴存在且属于当前公司;伙伴具备对应身份(`is_supplier` / `is_manufacturer`,可复用 `internal/repository/material.go:59-67` 的 `IsPartnerForRole`);货源指向的制造商条目存在且属于**同一个物料**、同一公司;`material_manufacturers` 的物料属于当前公司。
6. **物料日志快照。** `material_logs` 的 JSON 需纳入货源集合,否则变更追溯会丢失。
7. **顺手改进 SQL 别名可读性。** `internal/repository/material.go:255-258` 目前用 `sop` / `sb` / `mop` / `mb` 这套别名,从名字看不出分别对应哪张表。物料列表查询在本次改造中必然要重写(`materials` 的旧列被删,供应商名改为从货源表取),届时一并换成 `supplier_op` / `supplier_bp` / `manufacturer_op` / `manufacturer_bp` 这类表意别名。