diff --git a/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md b/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md new file mode 100644 index 00000000..9fc38e6e --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md @@ -0,0 +1,322 @@ +# 物料多供应商与多制造商关系设计(数据库层) + +**日期:** 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)。 + +两个直接推论:多制造商目前几乎没有数据基础(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 行物料原样插入临时表后分流,避免手工搬运 129 条货源 | + +关于 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` 必须改可空的原因很直接:回填的 129 条老数据没有任何价格信息,硬性要求非空则迁不动。 + +### 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` 镜像,满足要求。 + +注意 `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 | 新增货源数据 | 129 行 `material_suppliers` + 10 行 `material_manufacturers` | + +### 7.2 129 条货源数据的生成方式:临时表中转(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 自动派生,避免手工搬运 129 行的错漏。代价是脚本里多一段临时表逻辑。 + +伙伴变量(`@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 条需从源库取全部供应商、按上述规则拆分后写入;其余 167 条(199 − 32)沿用现有字面量的单值即可。 + +本地库没有增量升级路径,改完删库用 `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` 这类表意别名。