material_manage_system/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md
文睿 张 1a4bd1d9e8 物料多供应商与多制造商数据库设计文档
确定物料-供应商-制造商三元关系模型,本次只覆盖数据库层:

- D1-D10 决策:三元配对且带采购条件、只存当前价、删除 materials
  的 supplier_id/manufacturer_id/manufacturer_model、拆出
  material_manufacturers、软停用、公司隔离交应用层、status 用
  active/obsolete、删除 uk_organization_partners_org_id、
  init.sql 用临时表中转
- 三张表的字段、唯一键、外键、索引设计,含 NULL 唯一键、
  首选唯一性两个生成列要点
- init.sql 连带改动清单 12 处,以及临时表中转的分流顺序
- 被单值字段截断的 32 条多供应商数据:已查证源库,确认
  伙伴主数据齐备、制造商无同类问题,需回源补齐

前后端改造另行设计。
2026-10-15 14:00:21 +08:00

24 KiB
Raw Blame History

物料多供应商与多制造商关系设计(数据库层)

日期: 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(新建)

物料可用的制造商及其型号。

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(改造现有空表)

每条记录表示一条货源:某供应商供某物料、供应的是哪个制造商条目、以及该货源的采购条件。

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 这类表意别名。