From 9539b02f1cee4e7d155ad473ce2e385b3b07f94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Thu, 15 Oct 2026 16:03:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3=E5=88=B0=20Task=201=20?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E7=8E=B0=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 实现与评审暴露出文档和计划里的若干错误,据实修正: - 设计文档 6.2 / 6.4:preferred_row 生成列由 `THEN id` 改为 `THEN 1`。 原写法既被 MySQL 拒绝(生成列不能引用自增列,ERROR 3109),语义上也 失效——每行 id 各不相同,唯一键永不冲突,「每物料至多一条首选」形同 虚设。并补注它与 user_organizations 用 `THEN user_id` 的场合差异: 那张表没有自增列,用的是业务键。 - 设计文档 6.1 / 6.2、计划 Task 1:两张关系表补 `FOREIGN KEY(organization_id) REFERENCES organizations(id)`。改造前的 material_suppliers 旧定义本就有这条单列外键,重写时漏掉了,而项目里 所有按组织分表的表都有它。 - 计划 Global Constraints:删除「init.sql 必须可重复执行」这一错误约束。 脚本本就不幂等(普通建表没有 IF NOT EXISTS、schema_migrations 的 INSERT 也没有幂等,第二次执行在第 8 行即中止),改为只要求新增的分流 语句自身幂等。 - 计划 Task 1 Step 5:material_manufacturers 建表位置改为 organization_partners 之后(原位置触发前向外键 ERROR 1824)。 - 计划 Task 1 Step 7:临时表补 DEFAULT CHARSET=utf8mb4(不补会因排序 规则与持久表不一致报 ERROR 1267)。 - 计划 Task 1 Step 13:改为在 scratch 库重放三条分流语句验证幂等,不再 要求整脚本重跑。 --- ...026-10-15-material-sources-db-migration.md | 30 ++++++++++++------- ...5-material-supplier-manufacturer-design.md | 6 +++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-15-material-sources-db-migration.md b/docs/superpowers/plans/2026-10-15-material-sources-db-migration.md index b1c2d27e..d731a9e3 100644 --- a/docs/superpowers/plans/2026-10-15-material-sources-db-migration.md +++ b/docs/superpowers/plans/2026-10-15-material-sources-db-migration.md @@ -17,7 +17,7 @@ - **所有外键均为单列**,不引入任何复合外键(spec 的 D7)。公司隔离与物料一致性由应用层校验。 - `status` 取值固定为 `active` / `obsolete`(D8),不用数字。 - 货源表表名沿用 `material_suppliers`,不改名(D5)。 -- `init.sql` 必须保持**可重复执行**(重跑不报错)。 +- `init.sql` **本来就是一次性脚本,不保证可重复执行**:普通建表没有 `IF NOT EXISTS`,`schema_migrations` 的 INSERT 也没有幂等处理,第二次执行会在第 8 行以 `Table 'users' already exists` 中止。本任务只要求**新增的分流语句自身幂等**,不要求整脚本幂等,**也不要顺手去改既有的建表语句**——那是另一个话题。 - **验证一律使用隔离库**,绝不碰真实库。本机 MySQL 在 3306,账号 `root` / 密码 `1234`。 - **`init.sql` 不能靠「指定库名」来隔离**:它第 5-6 行硬编码了 `CREATE DATABASE IF NOT EXISTS wmms;` 与 `USE wmms;`,脚本会自己切回 `wmms`,把表建到真实库上去。必须先用 `sed` 把这两行的库名替换掉,再喂给 mysql: @@ -94,18 +94,22 @@ UNIQUE KEY uk_organization_partners_org_id(organization_id,id), - [ ] **Step 5: 新建 `material_manufacturers`** -紧接在 `CREATE TABLE materials` 之后插入: +插在 `CREATE TABLE organization_partners` **之后**、`CREATE TABLE material_suppliers` **之前**(**首行注释不可省**,它标明这是一张关系表): + +> **不能紧接 `CREATE TABLE materials`**:本表含 `FOREIGN KEY(manufacturer_id) REFERENCES organization_partners(id)`,而 `organization_partners` 创建在更后面;MySQL 不允许前向外键,实测 `ERROR 1824: Failed to open the referenced table 'organization_partners'`。该脚本里也没有 `SET FOREIGN_KEY_CHECKS=0`,所以只能靠调整建表顺序解决。 ```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; +-- 物料与制造商关系表:某物料有哪些可用制造商,以及各自的厂家型号 +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(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(manufacturer_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` - [ ] **Step 6: 用改造后的定义替换 `CREATE TABLE material_suppliers`** -把 `scripts/init.sql:34` 整行替换为: +把 `scripts/init.sql:34` 整行替换为(**首行注释不可省**,它标明这是一张关系表): ```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; +-- 物料与供应商关系表:每条记录一个货源(某供应商供某物料、供的是哪个制造商条目)及其采购条件 +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 1 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(organization_id) REFERENCES organizations(id), 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; ``` - [ ] **Step 7: 在物料 INSERT 之前建临时表** @@ -123,7 +127,7 @@ CREATE TEMPORARY TABLE tmp_materials ( supplier_id BIGINT, manufacturer_id BIGINT, category_id BIGINT, base_unit_id BIGINT, unit VARCHAR(20), status VARCHAR(20), version INT, created_at DATETIME, updated_at DATETIME -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` - [ ] **Step 8: 把两处物料 INSERT 的目标表改成临时表** @@ -232,14 +236,20 @@ SELECT m.material_code, m.name, 预期:`B010201` 与 `A020201` 各一行,供应商与制造商均为 `BJ-P001` / `BJ-P003`(原厂直销,两者相同);`B010202` 一行,供应商为空、制造商为 `BJ-P002`。 -- [ ] **Step 13: 确认脚本可重复执行** +- [ ] **Step 13: 确认分流语句自身幂等** + +**不要**在同一个库上重跑整个脚本——`init.sql` 整体不幂等(见 Global Constraints),那样只会得到 `ERROR 1050 at line 8: Table 'users' already exists` 然后中止,证明不了任何事。 + +分流语句的幂等性由「唯一键 + `ON DUPLICATE KEY UPDATE`」的写法保证。若要实证,单独建一个 scratch 库、把 `tmp_materials` 建表 + 几行样例数据 + 三条分流语句抽成一个独立脚本,连跑两轮: ```bash -sed -e "s/CREATE DATABASE IF NOT EXISTS wmms/CREATE DATABASE IF NOT EXISTS wms_verify/" -e "s/^USE wmms;/USE wms_verify;/" scripts/init.sql | mysql -u root -p1234 -mysql -u root -p1234 wms_verify -e "SELECT COUNT(*) FROM material_suppliers;" +mysql -u root -p1234 -e "DROP DATABASE IF EXISTS wms_idem; CREATE DATABASE wms_idem CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mysql -u root -p1234 wms_idem < /tmp/idem_probe.sql # 第一轮 +mysql -u root -p1234 wms_idem < /tmp/idem_probe.sql # 第二轮 +mysql -u root -p1234 wms_idem -e "SELECT (SELECT COUNT(*) FROM materials) AS m, (SELECT COUNT(*) FROM material_manufacturers) AS mm, (SELECT COUNT(*) FROM material_suppliers) AS ms;" ``` -预期:不报错,且第二次执行后仍是 `129`(不翻倍)。 +预期:两轮之后计数完全相同(不翻倍)。验证完 `DROP DATABASE wms_idem;`。 - [ ] **Step 14: 提交** 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 index eb0b77a2..aa615059 100644 --- a/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md +++ b/docs/superpowers/specs/2026-10-15-material-supplier-manufacturer-design.md @@ -135,6 +135,7 @@ CREATE TABLE material_manufacturers ( 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(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(manufacturer_id) REFERENCES organization_partners(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -165,12 +166,13 @@ CREATE TABLE material_suppliers ( 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, + preferred_row BIGINT GENERATED ALWAYS AS (CASE WHEN is_preferred AND status = 'active' THEN 1 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(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(supplier_id) REFERENCES organization_partners(id), FOREIGN KEY(material_manufacturer_id) REFERENCES material_manufacturers(id) @@ -229,6 +231,8 @@ CREATE TABLE material_suppliers ( - 三元组唯一键 `uk_material_suppliers_dim` **不含 status**:停用的货源仍占着它的三元组键位,重新启用只能走"恢复"(UPDATE status),不能 INSERT。这与物料的行为完全一致(作废物料仍占物料码)。 - 首选唯一键 `uk_material_suppliers_preferred` **必须含 status 条件**(写在 `preferred_row` 生成列里):否则一条首选货源停用后仍占着首选位,新的首选永远插不进来。 + `preferred_row` 的表达式用常量 `1`,**不能写 `THEN id`**:MySQL 禁止生成列引用自增列(实测 `ERROR 3109: Generated column 'preferred_row' cannot refer to auto-increment column`);即便能写,每行 `id` 各不相同,唯一键永不冲突,约束也就形同虚设。写成恒定的 `1` 才能让「同一物料的第二条首选」撞唯一键。这与 `user_organizations`(`scripts/init.sql:14`)用 `THEN user_id` 的场合不同——那张表没有自增列,用的是业务键。 + 区别在于:物料编码是业务唯一标识,停用后不该被复用;而"首选"只是当前生效的指向,停用后理应让位。 ## 7. init.sql 的连带改动与数据回填