# DB 契约 — 数据库命名与使用规则

## 1. 基本原则

1. 默认使用 `InnoDB`、`utf8mb4` 和 `utf8mb4_unicode_ci`。
2. 数据库、表、字段、索引只使用小写英文字母、数字和下划线。
3. 命名必须表达业务含义，禁止拼音、无意义缩写和中文标识符。
4. 新表、新字段和索引必须有明确用途，禁止预留 `field1`、`ext1` 等无语义字段。
5. 表和业务字段必须填写中文 Comment。
6. 只保存必要数据，禁止在普通业务字段中保存密码、密钥、令牌等敏感明文。
7. 无法确认的业务规则必须先确认，禁止根据名称猜测。
8. DDL 必须经过审核、备份和测试环境验证后执行。

## DDL 变更规范

1. 禁止直接在生产环境手工修改表结构。
2. 每次变更提供用途、影响表字段、兼容性、预览、执行、回滚和注意事项。
3. 新增非空字段时先评估历史数据；必要时按“新增可空字段、回填、改为非空”分步执行。
4. 删除或重命名字段前先扫描代码、接口、报表、任务和外部系统依赖。
5. 大表变更必须评估锁表时间、磁盘空间、复制延迟和在线 DDL 能力。
6. 修改 Comment 时只允许改变 Comment，不得顺带改变类型、NULL、默认值、字符集、生成表达式和索引。
7. 批量更新或删除前必须先执行相同条件的 `SELECT` 预览影响范围。
8. 所有 DDL 和数据修复 SQL 由人工审核后执行。

## 表前缀含义

| 前缀 | 含义 | 示例 |
|------|------|------|
| `admin_` | 系统用户侧实体 | `admin_user`, `admin_depa` |
| `sys_` | 系统功能表（权限/角色/站点/配置） | `sys_role`, `sys_permission`, `sys_site` |
| `attr_` | 属性类表（通用属性、零件关联属性） | `attr_type`, `attr_value`, `attr_factory` |
| `wash_` | 数据清单业务表 | `wash_sheet`, `wash_sheet_item` |
| `part_` | 零件库业务表 | `part_item`, `part_number`, `part_category` |
| `car_` | 车型库（所有车型库共用一套表） | `car_library`, `car_node`, `car_vehicle`, `car_brand` |
| `pro_` | 商品库业务表 | `pro_item`, `pro_brand`, `pro_unit_item` |

零件关联属性类表（如号码厂商 `attr_factory`；原「零件关联属性」菜单已于 2026-09-17 并入「零件库」）统一用 `attr_` 前缀。
`biz_` 前缀已作废（2026-09-28，原示例 `biz_product` 由商品库 `pro_` 取代），不要再用。
商品库的数据权限中间表挂在 `sys_` 下：`sys_role_pro_brand`、`sys_role_pro_unit`。

## 主键命名规则

格式：`{完整表名}_id`

| 表名 | 主键字段 |
|------|---------|
| `admin_user` | `admin_user_id` |
| `admin_depa` | `admin_depa_id` |
| `sys_role` | `sys_role_id` |
| `sys_site` | `sys_site_id` |
| `attr_factory` | `attr_factory_id` |
| `wash_sheet` | `wash_sheet_id` |
| `wash_sheet_item` | `wash_sheet_item_id` |

例外（保留，不改）：
- `sys_permission` 用字符串主键 `sys_permission_code`
- `sys_language` 用字符串主键 `sys_language_code`（zh-CN / en …），翻译表直接存语言编码
- `member` 表名无前缀，字段已按 `member_xxx` 一致命名

## 字段命名规则

格式：`{完整表名}_xxx`，表内所有字段都带完整表名前缀。

| 表名 | 示例字段 |
|------|---------|
| `admin_user` | `admin_user_username`、`admin_user_allow` |
| `attr_factory` | `attr_factory_name` |
| `wash_sheet_item` | `wash_sheet_item_number` |

树形自关联保留 `_parent_id` / `_parent_code`：`attr_value_parent_id`、`sys_permission_parent_code`。

例外：联表展示的计算字段（如 `admin_user_depa_names`）不是表的真实列，标注只读，写库前 `unset`。

## 外键列命名规则

**FK 列名不能与被引用表的主键同名**（否则 JOIN 后不带表别名的引用会报 `Column 'xxx' is ambiguous`）。

```
格式：{本表完整表名}_lnk_{被引用的完整主键名}

示例：
  admin_user_depa 表：
    admin_user_depa_lnk_admin_user_id  → 引用 admin_user.admin_user_id
    admin_user_depa_lnk_admin_depa_id  → 引用 admin_depa.admin_depa_id

  wash_sheet_item 表：
    wash_sheet_item_lnk_wash_sheet_id   → 引用 wash_sheet.wash_sheet_id
    wash_sheet_item_lnk_attr_factory_id → 引用 attr_factory.attr_factory_id

  sys_role_permission 表（字符串主键同理）：
    sys_role_permission_lnk_sys_permission_code → 引用 sys_permission.sys_permission_code
```

列名变长可以接受，不为缩短列名省略被引用表的前缀。

### 同表多外键：加语义后缀

同一张表里有多个外键指向同一张表时，在末尾加**语义后缀**说明用途：

```
格式：{本表完整表名}_lnk_{被引用的完整主键名}_{语义}

part_item_lnk_admin_user_id_operator        → 最后操作人
part_item_lnk_admin_user_id_auditor         → 审核人
part_item_lnk_sys_role_id_product_manager   → 产品经理角色
part_item_lnk_sys_role_id_purchase_manager  → 采购项目经理角色
```

- 指向通用字典 `attr_value` 的外键**一律**加语义后缀（哪怕本表只有一个），后缀用字典类型编码：`part_item_lnk_attr_value_id_unit`、`part_number_lnk_attr_value_id_data_source`
- 只有两端地位对等、没有语义区别的关系（如零件关联的两端）才用 `_a` / `_b`：

```
part_relation_lnk_part_item_id_a → 引用 part_item.part_item_id
part_relation_lnk_part_item_id_b → 引用 part_item.part_item_id
```

## 索引与约束命名规则

| 类型 | 格式 | 示例 |
|------|------|------|
| 普通索引（单列外键） | `idx_{列名}` | `idx_wash_sheet_item_lnk_wash_sheet_id` |
| 普通索引（按用途） | `idx_{表名}_{用途}` | `idx_wash_sheet_item_number` |
| 唯一键 | `uk_{表名}_{用途或列}` | `uk_admin_user_username`、`uk_attr_factory_name` |
| 外键约束 | `fk_{外键列名}` | `fk_sys_admin_role_lnk_sys_role_id` |

- 外键约束必须显式命名，不要用 MySQL 自动生成的 `xxx_ibfk_1`
- MySQL 标识符上限 64 个字符，名称过长时先确认长度
- `_lnk_admin_user_id` 列要存超级管理员（ID=0，不在 `admin_user` 表里）时，外键会拦下（1452）：
  可空的列写 NULL 绕开（见"零件审核"一节）；确实要存 0 的（`admin_remember_token`）只能不建外键，
  改由 Model 在应用层做级联清理，并在建表 SQL 里注明为什么没有外键
- 历史遗留的 `uq_` 前缀（`uq_attr_type_code`、`uq_attr_value_code`）暂不改，新建统一用 `uk_`

## 多对多中间表

命名：`{表A前缀}_{表B简称}` 或 `{sys_前缀}{A}_{B}`

| 中间表 | 连接关系 |
|--------|---------|
| `admin_user_depa` | admin_user ↔ admin_depa |
| `sys_admin_role` | admin_user ↔ sys_role |
| `sys_member_role` | member ↔ sys_role |
| `sys_depa_role` | admin_depa ↔ sys_role |
| `sys_role_permission` | sys_role ↔ sys_permission |

多对多绑定统一用**全量替换**：先 DELETE 旧记录，再批量 INSERT 新记录。

## 业务数据多语言

`lg()` 只翻译界面文案；**存进数据库的名称一律按下面的规则**，不走语言包。

- 主表放中文、英文固定列：`{表名}_cn_name`（必填）、`{表名}_en_name`（可空）
- 其它语言放 `{表名}_lang` 翻译表，一个实体一张：

```
{实体}_lang_id
{实体}_lang_lnk_{实体}_id               → 主表，ON DELETE CASCADE
{实体}_lang_lnk_sys_language_code       → sys_language，ON UPDATE / DELETE RESTRICT
{实体}_lang_{可翻译字段}                 与主表 _cn_xxx / _en_xxx 一一对应
{实体}_lang_is_machine                  1 = 机器翻译待复核
唯一（实体 ID, 语言）；索引（语言, 名称）
CONSTRAINT chk_{实体}_lang_language CHECK (语言编码 NOT IN ('zh-CN', 'en'))
```

- 语言外键必须用 RESTRICT：MySQL 不允许 CHECK 约束引用带 CASCADE 动作的外键列
- 显示名取值顺序：当前语言 → 英文 → 中文；空串按缺失处理（翻译表译文列默认值是 `''`）
- 主表和翻译表的文本列排序规则必须一致（统一 `utf8mb4_unicode_ci`）：回退表达式 `COALESCE(译文, en, cn)` 跨两张表，
  排序规则不同会报 `1267 Illegal mix of collations`。`attr_type` / `attr_value` 是 MySQL 8 默认的 `utf8mb4_0900_ai_ci`，
  迁移 SQL 见 `sql/migrate_attr_collation_unicode_ci.sql`（待人工审核执行），执行前这两个实体只有中英能按显示名过滤
- 号码、编码、车型库原始数据不翻译
- 启用哪些语言由 `sys_language` 控制（`_is_builtin=1` 的中英数据在主表列里）

## 其它约定

- 可空列参与唯一约束时（NULL 不参与唯一判断），用 STORED 生成列兜底，如 `car_node_parent_key = IFNULL(car_node_parent_id, 0)`；生成列的基础列上的外键只能用 RESTRICT
- 追溯外部来源的 ID 用 `{表名}_source_id`，不建外键
- 每次执行升级 SQL / 迁移脚本后写一条 `sys_migration`（一客户一库，按版本升级）
- 全文检索：派生表存拼好的检索内容，`FULLTEXT KEY ft_{表名}_{列} (...) WITH PARSER ngram`；
  **建全文索引前先 `SET SESSION innodb_ft_enable_stopword = OFF`**（内置英文停用词会让 ngram 丢掉所有含 a / i 的二元组，英文词和号码查不全）；
  查询条件用框架 `FullText::condition()` / `relevance()`，不在 Model 里手写 `MATCH ... AGAINST`；规则见 `mvc/v2/.claude/CLAUDE.md`「全文检索」
- 零件检索表 `part_item_search` 由 `model/PartItemSearch.php` 维护：内容组成与触发时机写在类注释里，源表（零件、翻译、号码、分类、厂商、车型汇总）写完后必须调对应的 `refresh*()`；零件删除靠外键级联，不用调
- Medoo 表达不了的写法（upsert、全文检索）才用 `rawQuery()` / `rawExecute()`，SQL 结构由代码写死，值一律 `?` 占位

## 零件审核与零件写入规则

**审核状态** `part_item_audit_status`：`1` 待审 / `2` 已审（2026-09-24 用户拍板取消草稿，SQL v1.0.9 `migrate_part_item_audit_two_status.sql`）；新建零件一律待审

| 动作 | 改前 → 改后 | 附带 |
|------|------------|------|
| `approve` 审核 | 1 → 2 | 写审核人 `_lnk_admin_user_id_auditor`、审核时间、审核备注 |
| `unapprove` 反审核 | 2 → 1 | 清空审核人 / 时间 / 备注（历史看审核记录） |

- 状态变更只能走 `PartItem::approve()` / `unapprove()`：同一事务里带改前状态做条件 UPDATE（并发时只有一个成功）并写一行 `part_item_audit_log`
- 可以自审，没有自审开关
- 超级管理员（ID=0）不在 `admin_user` 表里：操作人、审核人外键一律写 NULL
- **零件数据的编辑是永久性的，不受审核状态限制**：任何状态都能改、能删；编辑已审零件**不改变**审核状态。
  零件本身和**所有子表**（翻译、号码、参数、图片、车型、关联、位置、来源）写之前调 `PartItem::assertExists($itemId)` 确认零件还在

**零件子表 Model 的写入责任**（`PartItemLang` / `PartNumber` 为范例，S2–S5 新子表照做）：
1. 对外写方法最外层开事务，写前 `assertExists()`
2. 写后 `PartItem::touch()`（更新时间 + 最后操作人，列表按更新时间倒序）；影响检索内容的调 `PartItemSearch::refresh()`
3. 前端提交的派生列一律丢弃，由 Model 维护：
   - 主号码缓存 `part_item_lnk_attr_factory_id` / `_main_number` / `_main_number_format` ← `PartItem::syncMainNumber()`，号码增删改后调用
   - `part_number_number_format` ← `formatNum()`
   - 主图缓存 `part_item_lnk_sys_file_id` ← `PartItem::syncMainImage()`，`part_file`（image 类型主文件）增删改后调用（S3 已实现）
4. 号码：同零件内「厂商 + 格式化号码」唯一（先查再抛）；不同零件可以挂同一个号码（只提示）；每个零件恰好一个主号码——
   第一个号码自动为主号、设新主号自动取消旧的、不能直接取消主号、不能删主号

## 分类树维护规则（S2）

- `part_category_is_leaf` / `part_category_level` 不接受前端提交，由 `PartCategory` 全程程序维护：
  - `is_leaf`：该节点当前有没有子节点，新增/删除/换父节点时同步父节点（最后一个子节点删掉或移走时父节点变回 leaf）
  - `level`：根节点（`parent_id` 为空）恒为 1，否则父节点 `level + 1`；换父节点时递归重算自己和全部下级
- 换父节点（`part_category_parent_id` 变化）先校验：新父节点存在、不是自己、不是自己的下级（`selfAndDescendantIds()` 判环）
- 删除前校验：没有子节点、没有零件挂在这个分类上（FK 本身是 RESTRICT，Model 里先查一遍给出友好提示，不依赖数据库报错）
- `part_category_code` 由用户填写、必须唯一（不像 `part_param_code` 是程序自动生成）
- `part_param_code` 新增时自动生成 `p{自增ID}`，界面不暴露编辑；全局定义一次，通过 `part_category_param` 挂到各分类，可以被多个分类共用
- 参数值 `part_param_value`：`int`/`decimal`/`text`/`longtext` 各用一个 value 列，`select` 存一行（`_lnk_part_param_option_id`），`multiselect` 一个选项一行；
  保存前整行覆盖（先按 `item+param` 删旧值再按新值插入），必填校验按 `part_category_param_is_required`——**即使前端整份提交里缺了这个参数的 key 也要按空值校验**，不能靠「没传就跳过」绕过必填

## 零件文件规则（S3）

- `part_file` 每个零件每种类型（`image`/`drawing`/`attachment`）最多一个主文件（`part_file_is_main`）：该类型的第一个文件自动成为主文件；
  设置新主文件时原主文件自动取消；**不能直接取消主文件**（跟号码一样，只能把其它文件设为主文件），但主文件**可以直接删除**（比号码宽松——文件是辅助资料，删除后该类型变回没有主文件，不强制先转移）
- 只有 `image` 类型能挂标签（`part_file_tag`，字典 `image_tag`，多选）
- 删除 `part_file` 只删关联行，**不删 `sys_file` 本身**（可能被其它零件复用，或者只是解除这个零件的关联）
- zip 批量图片导入（`PartFile::attachFromZip()`）：按「厂商 + 号码」全库匹配，不是本零件独有范围；找不到厂商 / 号码没匹配到零件 / 匹配到多个零件 / 零件不可编辑 / 文件已存在，都是 `status=skipped` 不是 `status=failed`——
  这些是数据/业务层面「用不上」，不是文件内容本身的问题；这类判断逻辑写在业务处理器（`file.php` 的 `partImageEntry()`）和 `PartFile::attachFromZip()` 里，**不抛 `MvcException`**（抛出会被 `ZipTask::processEntry()` 统一归类成 `failed`）

## 车型库结构与适用车型规则（S4）

- 车型库层级完全由 `car_library_level` 定义（软件提供商随版本下发，客户不能改），程序按 `depth` 顺序逐级展开，不写死「国际 3 级 / 中国 5 级」这种数字——当前数据是国际 车厂→车系→车型（3 级），中国 品牌→生产商→车系→车代→车型（5 级），但代码只认 `is_vehicle` 标记，不认层数
- 车型叶子层（`is_vehicle=1`）的数据在 `car_vehicle`，不在 `car_node`：`car_node` 只到「车型的上一层」；要列车型时按车型库联出各层节点（`CarVehicle::scopeToLibrary()`），不是继续查 `car_node` 的子节点
- `car_vehicle` / `car_node` 数据量大（车型 24.5 万+、节点 3 万+），**不做全量加载**：`carNode/children` 只返回直接下级，车型一律走远程分页 / 过滤（车型管理页、零件详情「新增」弹窗 `carVehicle/pick`），不建全文索引（数据基本不变、更新靠车型库同步而不是用户编辑，值不了全文索引的维护成本）
- 顶层节点（国际车厂 / 中国品牌）的首字母在 `car_node_extra.initial`（国际是单个字母，中国是拼音缩写如 `BJYY`），选车型弹窗的字母栏取它的第一个字符转大写（`CarNode::topInitials()` / `topIdsByInitial()`），不按名称现算拼音
- **`part_item_region` 已确定不需要**（2026-09-19 用户拍板）：零件的**适用区域由零件对应车型的适用区域推算**，零件本身不设置，口径和 `part_item_brand` 一致。
  删表 SQL `sql/migrate_drop_part_item_region.sql`（v1.0.6）待人工审核执行；`model/PartItemRegion.php` / `admin/action/partItemRegion.php` 和详情页的区域勾选树已删。
  **`sys_region`（284 行：32 区域 + 252 国家）/ `sys_region_country`（621 行）/ `sys_region_lang` 三张区域字典表保留不动**，`model/SysRegion.php` 也保留。
  **缺口**：车型侧目前**没有任何区域数据**——`car_vehicle` / `car_node` / `car_library` 都没有 region 列，
  `car_vehicle_extra` 只有 `{tecdoc_id, frey_mm_id, tecdoc_from}`、`car_node_extra` 只有 `{en_name, initial, frey_mk_id}`，也没有 `car_*_region` 映射表。
  所以「零件 → 车型 → 区域」这条链现在断在最后一环（品牌能推是因为有 `car_brand_node` 兜底，区域没有对应的映射）。
  推算方法（对应 `PartVehicle::brandsByItem()` 的 `regionsByItem()`）和展示位**等车型区域的数据来源定了再做**。
- `part_vehicle` 新增走批量（`PartVehicle::attachMany()`），不支持单条 `gridInsert`：界面是「勾选若干车型 + 填一次备注/数据来源 → 一次性提交」，不是逐行填表单；已经挂过的（同零件 + 同车型）跳过不报错，不像号码那样对「重复」抛错
- **`part_item_brand` 已确定不需要**（2026-09-16 用户拍板）：零件 ↔ 品牌不落缓存表，现查现算——`PartVehicle::brandsByItem($itemId)` 顺着 `part_vehicle → car_vehicle → car_node`（`CarNode::topAncestorId()` 走到顶层节点）→ `car_brand_node` 反查品牌，去重返回；零件详情页「适用车型」分页顶部的「适用品牌」就是这样现算显示的，不做任何写入。迁移阶段发现的覆盖率问题（917 个国际车厂、181 个中国品牌没映射到 `car_brand`，个别节点映射到多个品牌）不需要再处理成「口径」——查不到就是空数组，查到多个就是都显示，界面上如实反映，不用建同步机制
- `part_item_summary`（按零件+车型库的车型/底盘/发动机/排量/年款汇总，不含品牌）**本次未实现**，字段和表已建；这张表还有没有必要保留、要不要一起按上面「现查现算」的思路处理掉，留给以后真的要用汇总展示时再定
- `part_item_brand` 表本身待人工审核后删除，迁移脚本见 `sql/migrate_drop_part_item_brand.sql`（0 行数据，删除前预览确认）
- 零件列表页「汽车品牌」列目前仍然是 `part_number_brand`（号码上标的品牌，S1 做的）——这是号码维度的品牌标注，跟「适用车型」推算出的品牌是两个不同的信息（一个是"这个号码宜配哪些品牌"，一个是"这个零件实际适配哪些车"），不是同一个概念的两个实现，不需要合并，本次没有动那一列

**汽车品牌 / 车型管理（2026-09-17）**：
- `car_brand` 管理页可增删改，中文名唯一（先查再抛）；删除前如果被 `part_number_brand`（RESTRICT）或 `car_brand_node`（CASCADE，但删了会让「适用品牌」现算结果悄悄变少）引用，一律不允许删，提示引用数；翻译在 `car_brand_lang`（`CarBrandLang`）
- **`car_brand` 就是参考系统的「所属车型品牌」**（2026-09-19 用户拍板，不另建字典）：参考库有两张品牌表——旧的 `cd_make`（中台遗留，128 行）和新的 `tag_brand`（133 行），`tag_brand.tb_from_ids` 注释写明是「原中台表 cd_make 的源 ID」，**参考系统自己已经把 cd_make 并进了 tag_brand**，121/133 行带着源 ID。MDM 的 `car_brand` 从 `tag_brand` 迁来，拿的已经是合并结果，不缺字段、不需要 DDL。`cd_make` 多出的 `cm_py_name` / `cm_sp_name` / `cm_al_name` 是**俄语 / 西语 / 阿拉伯名称**（不是拼音 / 别名），在 MDM 由 `car_brand_lang` 承载
- `car_brand_lang` 建表迁移时只取了中英文，俄 / 西 / 阿三列没迁，**至今 0 行**；`sql/migrate_car_brand_lang_ru.sql`（v1.0.8，DML）补 18 条俄文（另外 76 条「俄文」只是英文名原样抄写，不导入以免挡住回退 en；西语 / 阿拉伯在 `sys_language` 里没有这两种语言，外键会挡住，等启用后另开一版）
- 国际车型（`intl`）可增删改、中国车型（`cn`）只读，共用 `CarVehicle::scopeToLibrary()`：按 `car_library_level` 逐级 LEFT JOIN `car_node`（直属上级 → `car_node_parent_id` 往上），不写死层数
- 车型写入：车型库由限定范围决定；直属上级必须是本库最深一层非车型节点；`car_vehicle_source_id` 同库唯一（国际 = TCD typ_id，数据单深度清洗按它解析车型）；`car_vehicle_kw_value` 由 `car_vehicle_kw` 原文取第一个数字；起止年都非 0 时结束不能早于起始（只在提交了年月列时校验，不因历史脏数据挡住别的列）；被 `part_vehicle` 引用时不能删
- 深度清洗结果（BLOB）里存的 `vehicle_id` 可能指向之后被删掉的车型：`WashSheetItemPart::collectVehicles()` 写入前按车型库过滤掉已不存在的 ID（跳过，不报错）；详情弹窗里这类车型显示不出名称，重新深度清洗即可

**业务层事务写法**：框架 `Db::begin()` / `end()` 没有回滚，业务校验抛 `MvcException` 时用不了。
零件相关 Model 用 trait `model/PartItemInput.php` 的 `transaction()`（内部是 Medoo `action()`：抛异常回滚后原样抛出，`GridActions` 照常转 `code=1`）；
只在最外层开，内部辅助方法（`insertRow()`、`syncMainNumber()`、`touch()`）不开——PDO 不支持嵌套事务

## 零件关联 / 区域 / 安装位置 / 来源规则（S5）

- `part_relation`（link 关联 / pair 配对 / assembly 装配）两端都是 `part_item`：
  - link / pair 两端地位对等，没有方向语义：写入前按数值大小把两个零件 ID 规范成 `a`=较小、`b`=较大，
    避免同一对零件反过来建一次就多出一行「重复」的关联（唯一键是 `type+a+b`，不做这层规范化防不住反向重复）
  - assembly 有方向语义（`a`=父件、`b`=子件），按用户选择的角色写，不做数值规范化
  - 详情页「关联」分页按 `a` 或 `b` 等于当前零件取行（同一行会出现在两个零件各自的分页里），计算列按「当前零件是哪一端」
    现算对方零件的 id / 名称 / 主号码和（仅 assembly）当前零件的角色，不落到额外的列
  - S5 只支持选择已有零件建立关系；`part_relation_lnk_part_item_id_b` 为空的「填厂商+号码留作待匹配」用法本期不做
- `part_category_position`（分类限定安装位置）管理界面在 `partCategory` 编辑弹窗的「位置管理」面板（`PartCategoryPosition::replaceForCategory()`，
  全量替换，只对末级分类开放，取值必须是 `part_position` 字典的叶子值即 `attr_value_parent_id` 非空）；
  零件能选的安装位置**完全**由这张表决定（`PartItemPosition::allowedIdsForCategory()`）：
  **分类没有配置行 = 该分类的零件没有安装位置可选**（一个都不能选，保存时报错），不是「不限」。
  这条和「无记录=不限」的常见惯例**相反**，别照着推：安装位置是分类固有属性
  （刹车片才分前后轴、左右），分类没定义就说明这类零件没有位置概念
  （原来是拿 `part_item_region` 的「无记录=不限」做反例，那张表 2026-09-19 已确定不需要，见上）
- `part_source`（零件来源快照，1:1）主要是「数据单转零件」（S6b，见下方「数据单转零件规则」）自动写入的清洗快照：
  确认来源产品 ID / 确认产品信息 / EPC 信息 / 原始参数 这几列不提供编辑入口；
  只开放 `part_source_check_from` 手工设置（''/tcd/epc/self），设为空且没有记录时不建行，清空到空字符串时更新已有行而不删除

## 数据单清洗规则（S6a）

- `wash_sheet_item` 的确认 / 分类列（`_check_from` / `_check_source_id` / `_check_info` / `_check_manual` / `_lnk_part_category_id` / `_category_from` / `_category_candidates`）
  只能由 `WashSheetItem` 的 `clean()` / `confirm()` / `setCategory()` / `matchCategory()` 写，`save` 一律丢弃（`stripReadOnly()`）
- 人工优先：`_check_manual=1` 的确认只要还在新检测结果里就保留；`_category_from='manual'` 的分类不被自动匹配覆盖，`setCategory(ids, 0)` 才清掉人工标记并重匹配
- 自动分类只落在**启用的末级分类**上（`PartCategory::filterUsableLeaves()`）；某一步匹配到多个时分类置 NULL、候选 ID 数组写 `_category_candidates`，不往下一步匹配
- `wash_sheet_item_clean`（1:1）：
  - `_result` 是 JSON 经 `gzcompress` 的 MEDIUMBLOB，只能通过 `WashSheetItemClean` 读写；**grid / 列表查询不许 select 这一列**（一行几十 KB；号码带图片后刹车片这种 424 号码的明细约 70KB）
  - `_summary` 是明细列表唯一能读到的清洗内容：除了各项总数 / 建议数，还原样带一份 `params`（参数列表），
    供明细的「已确认数据」列显示参数——参数只在压缩的 `_result` 里，列表不许解它。`summarise()` 加字段后，旧行用 `WashSheetItemClean::resummarise()` 按已存结果刷一遍，不必重新深度清洗
  - `_check_from` / `_check_source_id` 记录本结果对应的确认产品，与明细当前确认不一致 = 过期（grid 计算列 `wash_sheet_item_deep_clean_stale`）
  - `_selection` 为 NULL = 没有人工勾选，按结果里每项的 `suggested`；重新深度清洗会清空勾选；取消确认直接删行
  - 子表的 `wash_sheet_item_clean_time` 与明细表 `wash_sheet_item_clean_time`（检测时间）**同名**：明细 grid 里子表列改名 `wash_sheet_item_deep_clean_time`，明细列登记成带别名的计算列，否则过滤 / 排序报列名不明确
- 深度清洗结果里的车型存来源 ID（`key`）和解析好的 `car_vehicle_id`（`vehicle_id`，车型库查不到为 0）：国际车型库 source_id = TCD typ_id，中国车型库 source_id = `number/getModelByNumber` 的 ID
- 号码分四组显示（确定 / 不确定 × 对照号 `num` / 其它号码 `othnum`），分组口径与参考系统一致，直接用上游的 `num` / `othnum` 两份列表和每条的 `ok`；**只在各组内按 unikey 去重**，`key` 记成 `组名:unikey`。某组为空时界面不显示那个标签——上游对某些产品确实不返回 `othnum`，这不是缺功能
- 国际车型项还带 `sources`：`model` = 产品自己的车型、`model_oknum` = 确定号码推出来的车型，两边都有的车型只存一条、`sources` 记两个。详情页按它分「产品国际车型」「确定号码的国际车型」两个标签，但**勾选仍然只有 `vehicles_intl` 一组**（两个标签的勾选取并集，`saveSelection()` 去重）。2026-09-20 之前清洗的结果没有 `sources`，一律当成 `model`（只出一个标签），要分开得重新深度清洗
- S6b 转零件读 `WashSheetItemClean::effectiveByItem()`（结果 + 生效勾选），不要自己解 BLOB

## 数据单转零件规则（S6b）

- 逻辑全部在 `model/WashSheetItemPart.php`（类注释是完整流程），`WashSheetItem::toPart()` 只做数据单范围校验和刷新数据单更新时间
- `wash_sheet_item_lnk_part_item_id`（FK → part_item，删零件置 NULL）/ `wash_sheet_item_part_time` 只能由转零件写，`save` 一律丢弃；
  `_part_time` 有值而 `_lnk_part_item_id` 为空 = 转过但零件已被删除，再转时重新按号码匹配
- 前置条件不满足、命中多个零件都是 `status=skipped`（code=0 + 原因），不抛异常；写入过程中的校验失败才抛（整条回滚，code=1）
- 找已有零件：明细记录的零件还在就直接用；否则 `PartNumber::itemIdsInCategory()` 在明细分类里按「厂商 + 格式化号码」查（明细本身 + 勾选号码 + 勾选替换号）
- 号码厂商：明细本身用明细厂商；OE 号（结果 `is_oe`，brand 是汽车品牌）和替换号一律 `OE`，OE 号的汽车品牌写 `part_number_car_brand_text`（见下条）；其余按 brand 名称（不区分大小写）找 `attr_factory`，找不到跳过该号码并提示，**不自动登记厂商**
- `part_number_car_brand_text`（VARCHAR 255，默认空串）：号码上的汽车品牌**文本**，不映射 `car_brand`，与 `part_number_brand` 多选并存；多个品牌用「, 」拼接、不区分大小写去重（`PartNumber::mergeCarBrandText()`，超长的品牌不再追加）；转零件合并时已有号码只补品牌；号码分页可手工编辑
- 数据来源（字典 `data_source` 编码）：明细本身号码 `manual`（用户自己导入的，不是清洗得来的）、TCD 号码 `tcd`、EPC 号码和替换号 `epc`、国际车型 `tcd`、中国车型 `yiparts`
- **零件子表数据来源规则**（2026-09-22 用户定，代码集中在 `PartDataSource`）：号码 / 文件 / 适用车型 / 关联的 `_lnk_attr_value_id_data_source` **不留空**——
  只有清洗转零件带进来的才是 TCD / EPC（中国车型是宜配），其它（页面上手工加的）一律「人工」`manual`；页面可以改，但只能在 TCD / EPC / 人工 里选，
  宜配只允许出现在适用车型上。字典 `data_source` 只启用这四项，其余停用（`sql/migrate_part_data_source.sql`），以后要加来源再说
- 新建零件：待审、中英文名取分类名、明细本身号码为主号码、分类默认单位 / 经理角色照 `PartItem::insertItem()` 带上；合并时不改名称、不改主号码，不看也不改目标零件的审核状态
- `part_source` 转零件时整行覆盖（含合并进已有零件）：`_lnk_wash_sheet_item_id`、`_check_from` / `_check_source_id`（深度清洗结果的确认产品）、`_check_info`（明细确认摘要 + `pro` 深度清洗产品信息）、`_epc_info`（明细 EPC 检测摘要，没匹配为 NULL）、`_raw_params`（清洗参数）
- 参数本期不写 `part_param_value`（`part_param` / `part_param_tcd_name` 还没有数据），等参数迁移后再按 `part_param_tcd_name` 映射
- 零件子表 Model 给批量导入提供的「不开事务」写法：`PartItem::insertItem()`、`PartNumber::insertRow()`（可带品牌文本）/ `keysOf()` / `updateCarBrandText()`、`PartVehicle::insertRows()`（带适用条件）、`PartSource::saveWashSnapshot()`；调用方在最外层开事务并负责 `assertExists` / `syncMainNumber` / `touch` / 检索刷新

## 商品库规则（2026-09-28 用户拍板，建表 SQL `sql/pro_library_v1.sql` v1.1.0 已执行）

方案全文见 Artifact「商品库一期数据结构」https://claude.ai/artifact/MpNEXbnV1ZbvUmSJP4KFNN ，**表结构以 SQL 文件为准**（方案页写的是 `product_` 前缀草案）。

- **三层**：主商品 → 品牌商品（同一张 `pro_item`，`_lnk_pro_item_id_parent` 为空 = 主商品，`_level` 1 / 2 由程序维护）→ 经营单元商品 `pro_unit_item`（商品 × 经营单元，可挂任意一层商品）
- **商品必须从零件生成**，不支持脱离零件新建；品牌商品的 `_lnk_part_item_id` 冗余同上级
- **零件被商品引用时不能删**（`fk_pro_item_lnk_part_item_id` RESTRICT）：`PartItem` 删除前要先查一遍给友好提示，不依赖数据库 1451 报错（**这条改变了「零件任何状态都能删」**，零件仍可删，只是被商品引用的不行）
- **编码生成后不再改**：`pro_item_code` / `_code_format` / `_code_segments` 只在生成时写，任何 save 一律丢弃；零件换分类时只同步 `pro_item_lnk_part_category_id` 缓存，编码不变
- **没有审核**：生成商品这个动作就是审核，`pro_item` 没有审核状态列；状态走字典 `pro_status`（参考站 8 个主状态 + `00` 未定义，新商品默认 `00`），流转限制在 `pro_status_flow`（无记录 = 任意切换）
- **继承 + 覆盖**：可空覆盖列 NULL = 继承上一层（主商品继承零件）；翻译表空串 = 继承；号码 / 车型 / 文件差异表只存 `add` / `exclude`（文件多一个 `main`）；参数某参数有行即覆盖，覆盖为空 = 一行值全 NULL。
  有效值取值顺序：本层 → 上一层 → … → 零件
- **汽车品牌范围** `pro_item_car_brand`：本层无记录 = 继承上一层，主商品无记录 = 不过滤；车型按顶层节点经 `car_brand_node` 映射过滤，OE 号按 `part_number_brand` 过滤，**没标汽车品牌的 OE 号默认显示**
- **号码厂商范围** `pro_brand_factory`：无记录 = 显示全部（注意和安装位置「无记录 = 没有可选」相反）
- **编码取号**（P2 实测后改）：计数器行不存在时用**旁路连接**（自动提交）`INSERT IGNORE` 补行，再在业务事务里 `SELECT … FOR UPDATE` 锁行、加号；
  复用登记 `pro_code_serial_reuse` 在锁住计数器行之后查，**不加锁**读最新提交（加锁会拿间隙锁、并发死锁）；
  `_last_value` 初值 -1 表示没取过；事务回滚号码退回（不跳号）；`uk_pro_item_code_format` 兜底。见 `ProCodeCounter` / `ProCodeEngine`
- `pro_code_map_lnk_pro_code_rule_id` 是生成列基础列，外键只能 RESTRICT：删编码规则前 Model 先删它的专用码表
- 主商品品牌最多一个：生成列 `pro_brand_main_flag` + 唯一键保证
- 扩展字段值（`pro_item_field_value` / `pro_unit_item_field_value`）按类型只填一列，多选一个选项一行；Grid 用行级相关子查询计算列筛选排序
- 数据权限 `sys_role_pro_brand` / `sys_role_pro_unit`：角色没有任何行 = 不限制，全量替换写入。多角色合并**只看有行的角色**取并集，一个都没有 = 不限制（2026-09-28 用户拍板，`ProDataScope`）
- **P1 写入规则**（2026-09-28）：
  - `pro_brand_code` 只允许字母数字（拼进商品编码）；主商品品牌由 `ProBrand` 先查再报出已有的是哪个，品牌下已有商品时不能改主品牌标记
  - 号码厂商范围 `pro_brand_factory`、角色范围 `sys_role_pro_*` 随主行提交（`*_ids`），提交了才全量替换
  - 这三张表对被引用方都是 CASCADE，删了会让范围变成「无行 = 不限制」：**品牌 / 单元被角色范围引用、号码厂商被品牌范围引用时 Model 先查再拦**，不让删
  - `pro_category_ext` 只对末级分类写；整份提交；没有行且全空时不建行；流水位数 1–16（对应 `pro_code_serial_reuse_serial` VARCHAR(16)）
- `pro_change_log` 不建商品外键（商品删除后日志保留）；`pro_sync_queue` 一期只有骨架
- 新字典（通用属性）：`item_type`（编码 finished_goods / semi_finished / packaging / auxiliary）、`material`（参考库 23 个值原样迁移，里面混有颜色、主件 / 子件等区分属性）、`pro_status`（编码 00–07、99）、`pro_unit_type`（只有 division 事业部）

### 编码规则引擎（P2，2026-09-28 / 29 用户拍板；v2 SQL `sql/pro_code_rule_v2.sql` v1.1.2 已执行）

- **段类型**（`pro_code_rule_segment`，保存时整行按类型规范化，换类型不留旧配置）：
  - text 固定文本（字母数字和 `- _ . /`，≤16）；map 码表；serial 流水；slice 截取上级商品编码第 a 至 b 位；inherit 取上级商品的段；date（`Y y m d`）；check GS1 模 10 校验位（必须最后一段、前缀纯数字）；plugin 插件
  - serial / check 每条规则各最多一个；serial 位数 1–16，**分类商品属性的位数优先**，但设了结束值（区间流水）时位数固定；超过位数或结束值报「流水号已用完」
  - 流水作用域 = 本规则其它段标识（值拼成 `C=808|M=6`）；复用维度 parent / car_brand / part / item_type / material / brand
- **码表** `pro_code_map`：对象类型 = car_brand / part_category / pro_brand / item_type / material / `attr:通用属性类型编码`；
  变体 = 物料类型或材质的字典编码，或编码套 A～E（和变体依据二选一）；同类型下编码允许重复（界面标黄）；
  查码顺序：本规则 + 变体 → 本规则 + 默认 → 通用 + 变体 → 通用 + 默认；查不到时商品品牌回退 `pro_brand_code`、`attr:` 回退 `attr_value_code`
- **选规则**：规则适用条件 = 商品品牌 + 分类（含下级）+ 编码扩展（字典 `pro_code_ext`，树形，含下级），空 = 不限；
  启用、非手动、至少一个条件的规则参与自动匹配，生成列 `pro_code_rule_match_key` 唯一（同一组条件只能一条）；
  优先级：品牌一致 > 品牌不限，再比分类（近的优先 > 不限），再比扩展；商品有扩展只匹配扩展相同（或上级）的规则、**不回退普通规则**；
  都没有且商品没有扩展时回退到商品品牌的默认规则 `pro_brand_lnk_pro_code_rule_id`。手动规则（`_is_manual`）只能生成时手动选
- 规则的三个条件列是生成列基础列，外键只能 RESTRICT：**删商品品牌、分类前 Model 先查是否被规则条件引用**；删编码扩展字典值目前会撞外键（同 `pro_unit` 的字典外键）
- **删规则**：被商品品牌（默认规则）或商品引用不能删，可停用；能删时事务里先删专用码表，段 / 计数器 / 复用登记外键级联
- **流水区间重叠**：规则只由固定文本 + 流水 (+ 校验位) 组成时，和写法、位数相同的其它规则比区间，重叠拦下；含码表等段的规则交给生成时的编码查重
- **表达式**：`ProCodeExpression` 段 ⇄ 一行文本（兼容参考站 gsp 写法 `[S:0200-2100]` `[2-5]` `[MA]`），保存表达式 = 整体替换段（段顺序 10、20、30……），写错整条不保存
- **唯一性键**：按 `config/product.php` 的 `uniqueDims` 拼；part / item_type / brand / parent 缺了为 null，material / car_brand 缺了记 0；material 只在分类 `unique_by_material=1` 时拼
- **`ProCodeEngine::generate()` 必须在调用方事务里**（和插入 `pro_item` 同一个事务），不在事务里抛异常；规则停用、编码已被占用也抛
- **插件**：`config/product.php` 的 `plugins.codeSegment` 注册，类文件放 `plugin/{customer}/`（接口 `ProCodeSegmentProvider` / `ProUniquenessResolver`）

## Model 使用规则

- `$table` 填写物理表名，`$key` 填写主键字段名
- 需要 JOIN 展示时覆盖 `gridFrom()` 和 `gridSelect()`
- 需要 GROUP BY 时覆盖 `gridGroupBy()`
- 联表展示字段（如 `sys_site_name`）在 `gridInsert/gridUpdate` 里必须 `unset` 后再写库
- 需要按「算出来的列」过滤、排序时覆盖 `gridComputedColumns()`（列名 => 表达式），SELECT 里拼 `gridComputedSelect()`；
  表达式只能是代码写死的行级表达式，不能拼用户输入，不能是 GROUP_CONCAT 这类聚合（规则详见 `mvc/v2/.claude/CLAUDE.md` 的 DxFilter 一节）
- 有翻译表的实体，显示名一律用 `Lang::translation()` 生成，不自己写 COALESCE、不用 `Lang::pickField()`：

```php
private function nameSql(): array
{
    return Lang::translation('part_category', 'pc', ['name']); // join + columns['name']
}
protected function gridFrom(): string
{
    return '`part_category` pc' . $this->nameSql()['join'];
}
protected function gridSelect(): string
{
    return 'pc.*, ' . $this->gridComputedSelect();
}
protected function gridComputedColumns(): array
{
    return ['part_category_name' => $this->nameSql()['columns']['name']];
}
```

  前端列 `dataField: 'part_category_name'` 即可过滤、排序、导出；它不是真实列，写库前 `unset`。
  lookup 列（dataField 是外键 ID）要按名称排序时，在被引用实体的 Model 里联出名称列，列定义用 `calculateSortValue: '名称列'`
- 使用 Medoo 的 `$this->db` 操作；`gridFrom()` / `gridSelect()` / 计算列表达式之外禁止在 Model 里拼裸 SQL 字符串

## 数据库连接

- 默认连接：`Mvc::$cfg['db']['default']`（配置在 `config/database.php`）
- 多库场景：`Db::instance('other')` 按名称取连接
- 禁止在 Action 里直接 new PDO 或操作数据库
