同步设计与计划文档到 Task 1 的实现结论

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 库重放三条分流语句验证幂等,不再
  要求整脚本重跑。
This commit is contained in:
文睿 张 2026-10-15 16:03:43 +08:00
parent e6e803615a
commit 9539b02f1c
2 changed files with 25 additions and 11 deletions

View File

@ -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: 提交**

View File

@ -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 的连带改动与数据回填