# API 契约 — 接口协议

## 统一响应格式

所有接口统一返回 JSON，结构固定：

```json
{"code": 0, "data": {...}, "msg": ""}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | `0` 成功，非0为错误码 |
| `data` | any\|null | 成功时的载荷，失败时为 `null` |
| `msg` | string | 错误描述，成功时为空串 `""` |

PHP 端实现：
```php
$this->success($data);          // code=0
$this->error(401, '请先登录');  // code=401, data=null
```

**Ajax 请求认证失败时**（`X-Requested-With: XMLHttpRequest`）返回 `code=401` 的 JSON，不 302 跳转。
**普通页面请求未登录时** 302 跳转 `/login/index`，不返回 JSON。

---

## 错误码约定

| code | 含义 |
|------|------|
| `0` | 成功（保留，不得用于错误） |
| `1` | 通用业务错误（参数缺失、数据不存在等） |
| `2` | 数据验证失败（如两次密码不一致） |
| `3` | 策略不满足（如密码强度不够） |
| `401` | 未登录 |
| `403` | 无权限 |
| `500` | 未捕获异常（框架 `ExceptionHandler` 出口，不是 Action 主动返回的）；`DEBUG` 开时 `msg` 是异常原文 + 文件行号，关时是「系统出现异常错误」 |

业务错误统一用 `code=1`，只有需要前端分支处理时才用具体错误码（2、3...）。

**调试开关**：`config/admin.php` 的 `DEBUG`（bool，默认 `true`），由入口 `admin/index.php` `define('DEBUG')` 交给框架。
开着的时候接口出异常（典型的是数据库报错）会把 SQLSTATE 原文和出错位置放进 `msg` 直接弹给前端，**生产环境必须改成 `false`**。

---

## Grid 接口协议（DevExtreme CustomStore）

### load — `GET /{module}/grid`

DevExtreme 自动附带的查询参数：

| 参数 | 类型 | 说明 |
|------|------|------|
| `skip` | int | 跳过的行数（分页偏移） |
| `take` | int | 本页条数 |
| `requireTotalCount` | bool | 为 true 时需要返回 totalCount |
| `filter` | JSON | DevExtreme 过滤表达式（见下方） |
| `sort` | JSON | 排序数组 `[{"selector":"字段","desc":false}]` |
| `group` | JSON | 分组配置 `[{"selector":"字段","desc":false,"isExpanded":false,"groupInterval"?:"year"}]`，列头筛选器取候选值也是它；带 group 时返回分组 `[{"key":值,"items":null,"count":N}]`（`Model::gridGroups()`） |
| `requireGroupCount` | bool | 带 group 时为 true 需要返回 groupCount（第一级分组数） |

响应 data 结构：

```json
{
  "code": 0,
  "data": {
    "data": [...],
    "totalCount": 100
  },
  "msg": ""
}
```

### save — `POST /{module}/save`

请求 body（JSON）：

```json
// 新增（无 key）
{"key": null, "values": {"field1": "value1", ...}}

// 更新（有 key）
{"key": 1, "values": {"field1": "new_value", ...}}
```

响应 data：

```json
// 新增成功
{"key": 新生成的主键值}

// 更新成功
{"key": 传入的主键值}
```

### remove — `POST /{module}/remove`

请求 body（JSON）：

```json
{"key": 1}
```

响应 data：`null`

### export — `POST /{module}/export`

请求格式：**表单 POST（非 JSON body）**，三个字段均为 JSON 字符串：

| 字段 | 类型 | 说明 |
|------|------|------|
| `columns` | JSON string | `[{"dataField":"字段名","caption":"列头"},...]` |
| `keys` | JSON string（可选） | 导出已选时传，主键数组 `[1,2,3]` |
| `filter` | JSON string（可选） | 无 keys 时用，DevExtreme filter 表达式 |

响应：直接输出 Excel 文件（不是 JSON），前端会触发浏览器下载。

---

## DevExtreme Filter 表达式格式

`DxFilter::toSql()` 支持以下格式（自动转成带参数化的 SQL 片段）：

```js
// 单条件（三元素）
["admin_user_name", "=", "张三"]
["admin_user_name", "contains", "张"]
["admin_user_email", "<>", null]    // → IS NOT NULL

// 简写（二元素，默认 "="）
["admin_user_allow", 1]

// 取反
["!", ["admin_user_allow", 0]]

// 组合（and/or 可混用，可嵌套）
[["admin_user_name","contains","张"], "and", ["admin_user_allow","=",1]]
[["f1","=","v1"], "or", ["f2","=","v2"], "or", ["f3","=","v3"]]
```

支持的操作符：
`=` `<>` `>` `>=` `<` `<=` `startswith` `endswith` `contains` `notcontains` `between` `anyof` `noneof` `isblank` `isnotblank`

---

## 分页接口（非 Grid 场景）

`Model::paging()` 用于非 Grid 的自定义分页查询，返回：

```php
[
    'page'    => 1,      // 当前页
    'pagesize'=> 50,     // 每页条数
    'total'   => 200,    // 总页数
    'records' => 10000,  // 总记录数
    'limit'   => 50,     // 本页实际条数
]
```

---

## URL 路由格式

```
/{模块名}/{方法名}
/{模块名}/{方法名}/k/{键}/{值}/{键}/{值}   // 键值传参
/{模块名}/{方法名}/v/{值1}/{值2}            // 位置传参
```

请求参数合并优先级：GET → POST body（JSON）→ URL `/k/` 键值段
参数自动按方法形参名绑定，不需要在 Action 里手动读 `$_GET/$_POST`。

---

## 异常与错误页

- 抛出 `MvcException` → 被 `ExceptionHandler` 捕获
- `DEBUG` 常量为 true（开发环境）：输出异常详情（Monokai 暗色风格）
- `DEBUG` 为 false（生产环境）：记录 `error_log`，页面显示通用错误提示

Action 内业务校验失败时，**用 `$this->error(code, msg)` 而不是抛异常**（异常用于不可预期的错误）。

Model 里调不到 `error()`，业务校验失败时抛 `MvcException('给用户看的原因')`：
- 走 `GridActions` 的 `save` / `remove` 时，框架捕获后返回 `code=1`、`msg=异常信息`，dxDataGrid 直接显示
- 自定义端点调用会抛 `MvcException` 的 Model 方法时，Action 里自己 `try/catch` 转成 `error(1, ...)`（参考 `washSheetItem/clean`）
- 只捕获 `MvcException`；数据库异常（如外键冲突）属于不可预期错误，仍走全局异常处理，所以能预见的约束要在 Model 里先查再抛

---

## 主从子表接口约定（零件库起）

- **子表一个 Action**，都用 `GridActions`，请求带主表主键参数（如 `itemId`）限定范围；
  限定由 Model 负责（`scopeToItem()`）：**不带主键时 grid 查不到任何行、写操作报「零件不存在」**，不能退化成查全表
- **按语言出行的翻译表格**（`{实体}Lang`）：一行一种启用的非内置语言，Grid `key` 是语言编码 `sys_language_code`；
  `save` 带 key 为 upsert（名称、描述都清空即删除该语言译文）、`remove` 删除译文、不支持新增；人工保存把 `_is_machine` 置 0
- **保存后需要附带提示（不拦截）时另开查询端点**：`save` 只回 `{key}`，不改响应格式；前端在 `onRowInserted` / `onRowUpdated`（或弹窗保存成功）后再调查询端点。
  例：同一号码挂在多个零件上 → `partNumber/duplicates`
- **状态流转动作**用独立端点，body `{主键参数, remark?}`，成功返回**最新详情**（前端直接刷新表头和可编辑状态）；
  当前状态不允许该动作、并发冲突都是 `code=1`
- 多选关联（如号码的汽车品牌）随主行一起保存：values 里带 ID 数组字段（`part_number_brand_ids`），**提交了才全量替换，没提交不动**

### 零件库接口清单（S1）

| 端点 | 请求 | 说明 |
|------|------|------|
| `partItem/index` | — | 列表页 |
| `partItem/detail/k/itemId/{id}` | — | 详情页；零件不存在返回 `code=1` |
| `partItem/grid` / `export` | `categoryId?` `categoryIds?` `keyword?` `numbers?` + loadOptions | `categoryId` 含下级分类；`categoryIds`（列表页左侧面板「多选」）是一组分类，每个都含下级，**传了就不看 `categoryId`**；导出是表单 POST，`categoryIds` 到服务端时是 JSON 字符串，两种形态都认；`keyword` 走全文检索（2026-09-22 起列表页去掉了关键词搜索框，这个参数服务端保留，页面不再传）；`numbers` 多行文本或数组，按 `formatNum()` 匹配 `part_number` 子表（EXISTS），有输入但全无效时查不到 |
| `partItem/save` | 无 key：`values{part_item_lnk_part_category_id, part_number_lnk_attr_factory_id, part_number_number, part_item_cn_name}`；有 key：基本信息列 | 新增时零件和主号码同一事务，分类的默认单位 / 经理角色带到零件上；修改只接受基本信息白名单列。**2026-09-22 起列表页没有「新增」入口**（零件从数据单转换而来，见 `washSheetItem/toPart`），无 key 的新增分支接口还在，页面不调 |
| `partItem/remove` | `{key}` | 任何审核状态都能删，子表外键级联 |
| `partItem/info` | `{itemId}` | 列表行 + `part_item_auditor_name` / `part_item_operator_name` |
| `partItem/approve` / `unapprove` | `{itemId, remark?}` | 审核（待审 → 已审）/ 反审核（已审 → 待审），返回最新 info；规则见 DB.md「零件审核」 |
| `partItem/auditLog` | `{itemId}` | 审核记录，新的在前 |
| `partItem/batchAudit` | `{keys:[...], action:'approve'\|'unapprove', remark?}` | 列表页勾选后批量审核；**逐条独立事务**，单条失败只记原因继续下一条，返回 `{ok, failed:[{itemId, title, reason}]}`。条数上限 `PartItem::BATCH_MAX`（1000），超限 / 动作不合法 / 一个有效 ID 都没有是 `code=1` |
| `partItem/batchUpdate` | `{keys:[...], values:{...}}` | 批量改基本信息，`values` 只认 `BASIC_FIELDS` 白名单（滤完为空报 `code=1`），复用 `gridUpdate()` 的校验；返回同上。前端「留空的字段不修改」＝不提交那个 key，不是提交 null |
| `partNumber/grid` / `save` / `remove` / `export` | `itemId` 必带 | 号码；主号码规则见 DB.md；`part_number_car_brand_text` 汽车品牌文本（S6b 起，可编辑，≤255） |
| `partNumber/duplicates` | `{itemId, factoryId, number}` | 同一「厂商 + 格式化号码」挂在哪些其它零件上（最多 20 个） |
| `partItemLang/grid` / `save` / `remove` | `itemId` 必带，key = 语言编码 | 零件其它语言翻译 |

### 零件库接口清单（S2：分类与参数管理）

| 端点 | 请求 | 说明 |
|------|------|------|
| `partCategory/index` | — | 分类管理页（dxTreeList） |
| `partCategory/tree` | — | 全量平铺（含停用），供 dxTreeList 客户端组树，不分页 |
| `partCategory/save` | 无 key：新增（`part_category_parent_id` 为空建根节点）；有 key：修改；提交 `part_category_parent_id` 时按「换父节点」处理（校验成环、重算层级、同步新旧父节点末级标记） | `part_category_is_leaf` / `part_category_level` 不接受提交，程序维护 |
| `partCategory/remove` | `{key}` | 有子节点或有零件挂在上面时拒绝 |
| `partCategoryLang/grid` / `save` / `remove` | `categoryId` 必带，key = 语言编码 | 分类其它语言翻译，模式同 `partItemLang` |
| `partCategoryParam/grid` | `categoryId` 必带 | 该分类已挂载的参数（含 `part_param_name` / `part_param_type`） |
| `partCategoryParam/save` | `{key, values}` | 只能改 `sort` / `is_required` / `show_in_list` / `show_in_detail`，不支持新增（新增走下面两个端点） |
| `partCategoryParam/remove` | `{key}` | 取消挂载，不删全局参数定义 |
| `partCategoryParam/available` | `{categoryId}` | 该分类还没挂载的启用参数，供「选择已有参数」下拉用 |
| `partCategoryParam/attachExisting` | `{categoryId, paramId}` | 挂载一个已有参数，重复挂载报错 |
| `partCategoryParam/attachNew` | `{categoryId, param: {cn_name*, en_name?, type*, cn_unit?, en_unit?, decimal_places?, max_length?, options?}}` | 新建全局参数定义并挂载（同一事务）；`type` 为 `select`/`multiselect` 时 `options` 是 `[{cn_name, en_name}]` |
| `partCategoryPosition/list` | `{categoryId}` | 该分类当前允许的安装位置 ID 列表（不是 Grid 协议，多选打钩；空 = 不限） |
| `partCategoryPosition/save` | `{categoryId, ids:[...]}` | 整份提交，全量替换；只对末级分类开放，`ids` 必须是 `part_position` 字典的叶子值。**提交空数组 = 该分类的零件不需要选安装位置**，不是「不限」 |
| `partParamOption/grid` / `save` / `remove` | `paramId` 必带 | 参数选项（select/multiselect 用），独立维护入口 |
| `partItemParam/list` | `{itemId}` | 零件所属分类挂载、`show_in_detail=1` 的参数定义 + 当前值，不是 Grid 协议（用于动态生成表单） |
| `partItemParam/save` | `{itemId, values: {参数ID: 值}}` | 整份提交（缺的字段按空值处理，必填参数即使没提交这个 key 也会被拦截）；`select` 传单个选项 ID，`multiselect` 传选项 ID 数组；返回最新的 `list` 结果 |

### 零件库接口清单（S3：图片文件分页 + zip 批量图片）

| 端点 | 请求 | 说明 |
|------|------|------|
| `partFile/grid` / `save` / `remove` | `itemId` 必带，`grid` 可带 `fileType` | 零件文件（图片/图纸/附件）；`save` 无 key 是把一个已上传的 `sys_file` 挂到零件上（`part_file_lnk_sys_file_id` 必填），**不传 `part_file_type` 时按扩展名自动判定**（`PartFile::typeFromExt()`，`TYPE_BY_EXT` 之外的一律当附件），传了就按传的来；**`part_file_type=image` 只能是图片格式**（新增、改类型都校验，`PartFile::assertTypeFitsExt()`，报「只有图片格式的文件才能设为图片」），图纸 / 附件不限格式——详情页「图片」分页只收图片、传上来记 `image`，「文件」分页不限格式、传上来的图片显式记成 `attachment`（不然会判成 image 跑到图片分页），其它不传类型；零件图片 zip 批量导入里非图片格式的条目直接跳过；有 key 是改类型/备注/数据来源/标签/主文件；`remove` 只解除关联，不删 `sys_file`；`fileType` 限定 `grid` 查哪几种类型（`image` = 图片、`file` = 图纸 + 附件，别的值和不传都是全部），详情页「图片」「文件」两个分页就是靠它分开的 |
| `file/options` / `file/chunk` | — | 单文件上传走框架通用分片上传（S0），`onComplete` 拿到 `sys_file_id` 后再调 `partFile/save` 挂到零件 |
| `file/zipChunk` `{kind:'partImage'}` / `file/taskNext` / `file/taskDiscard` | — | 零件图片批量导入，走框架通用 zip 批处理（S0），处理器在 `file.php` 的 `partImageEntry()` |

**zip 批量图片命名约定**：包内目录式命名 `厂商/号码/序号[_标签].扩展名`（如 `OE/12345678/1_正面图.jpg`）：
- 号码按 `formatNum()` 在全库匹配 `part_number`（厂商 + 格式化号码）；未匹配到、匹配到多个零件、路径层级不对，都是 `status=skipped`（不算 `failed`），在批处理结果里列出原因
- 序号 1 且该零件当前没有 `image` 类型主文件时，自动设为主文件（不会覆盖已有主文件）
- 标签按 `image_tag` 字典的中文名 / 英文名 / 编码匹配（不区分大小写）；匹配不到时文件仍会导入，只是不打标签，消息里会提示
- 同一文件（按内容去重）已经挂在目标零件上时跳过；不看目标零件的审核状态
- 业务逻辑全部在 `PartFile::attachFromZip()`，处理器（`file.php`）只做路径解析

### 零件库接口清单（S4：适用车型）

| 端点 | 请求 | 说明 |
|------|------|------|
| `carLibrary/list` | — | 启用的车型库 + 各自的层级定义（按深度排列，含 `is_vehicle` 标记哪一层是车型叶子层） |
| `carNode/children` | `{libraryId, parentId?}` | 某个上级节点（不传或 0 表示顶层）下的下一级节点；节点只到「车型叶子层的上一层」，车型本身不在这里 |
| `carVehicle/grid` | `nodeId` 必带 + loadOptions | 某个节点下的车型（标准 Grid 协议，分页/过滤） |
| `carVehicle/search` | `{libraryId, keyword}` | 按名称关键字在整个车型库搜索（`LIKE`，不是全文检索，条数封顶 200），附带所属节点名称 |
| `partVehicle/grid` / `save` / `remove` | `itemId` 必带 | 零件适用车型；`save` 只能改备注/数据来源（不支持新增，新增走 `attach`） |
| `partVehicle/attach` | `{itemId, libraryId, vehicleIds:[...], remark?, dataSourceId?}` | 批量把选中车型挂到零件，已经挂过的（同零件+同车型）跳过不报错，返回 `{added, skipped}` |
| `partVehicle/brands` | `{itemId}` | 零件适用品牌，按已选车型现查现算（不落缓存表，不需要 `part_item_brand`，见 DB.md） |

**车型选择两种方式**：左侧层级树按需展开（`carNode/children` 每展开一个节点取一层，点到最末一级非车型节点后改用 `carVehicle/grid` 取车型列表）；或顶部关键字输入即搜 `carVehicle/search`。两种方式的结果最终都是勾选车型 ID 传给 `partVehicle/attach`。

> 2026-09-19 起前端从「逐级下拉」改成树，接口没动。树用 dxTreeView 的 `createChildren`（虚拟模式）：
> **平铺结构下 `createChildren` 返回的每一项必须自己带 `parentId`**，不带的话新节点会挂到根上，表现为「点了展开箭头但没有子节点」。

**对方零件的远程搜索下拉**（关联分页）走 `partItem/grid` 的 `keyword`，CustomStore 的 `loadMode` 必须是 `processed`——
`raw` 模式下 DevExtreme 只拉一次全量再本地过滤，`load()` 拿不到 `searchValue`。

### 汽车品牌 / 车型管理接口（2026-09-17，菜单「零件库 → 车型」下）

| 端点 | 请求 | 说明 |
|------|------|------|
| `carBrand/grid` / `save` / `remove` / `export` | loadOptions / 标准 Grid | 汽车品牌；可写 `car_brand_cn_name`（必填、唯一）/ `_en_name`（空串存 NULL）/ `_keyword` / `_sort`，`_source_id` 只读；计算列 `car_brand_name`（当前语言显示名）、`car_brand_node_count`、`car_brand_number_count`（可过滤排序）；被 `part_number_brand` 或 `car_brand_node` 引用时 `remove` 返回 `code=1` 并带引用数 |
| `carBrandLang/grid` / `save` / `remove` | `brandId` 必带 | 品牌其它语言译文，一行一种语言（key = 语言编码），做法同 `partCategoryLang`；译文清空即删除，不支持新增 |
| `carSeriesIntl/grid` / `save` / `remove` / `export` | loadOptions / 标准 Grid，可带 `parentId` | 国际车系（`car_node` 里最深一层非车型节点，层级按 `car_library_level` 推，不写死）；`parentId` 限定到某个车厂，新增时没传 `car_node_parent_id` 就用它；计算列 `car_series_parent_name` / `car_series_vehicle_count` / `car_series_chassis` / `car_series_is_passenger` / `car_series_is_commercial`（后三个存 `car_node_extra`，写入时只改这三个键，其它键保留）；`car_node_source_id` 可不填（新增自动生成 `M{ID}`，修改时清空视为不改），同一上级下唯一；名称不做唯一校验（现有数据同车厂下同名车系很多）；换上级重算 `car_node_path`；下面有车型 / 下级节点 / 被 `car_brand_node` 映射时不能删 |
| `carSeriesIntl/parents` | 无 | 车厂列表 `[{car_node_id, car_node_name, en_name, series_count, vehicle_count}]`，一次返回全部（约 1100 行） |
| `carVehicleIntl/grid` / `save` / `remove` / `export` | loadOptions / 标准 Grid | 国际车型（`car_library_code=intl`）；计算列 `car_vehicle_node{depth}_name`（各层节点名，可过滤排序）、`car_vehicle_node{depth}_id`（直属上级以外各层，编辑回填用）；`car_vehicle_lnk_car_node_id` 必须是本库最深一层非车型节点；`car_vehicle_source_id`（TCD typ_id）必填且同库唯一；车型库 ID 和 `kw_value` 由服务端决定，`car_vehicle_extra` 不可写；被 `part_vehicle` 引用时不能删 |
| `carVehicleCn/grid` / `export` | loadOptions | 中国车型（`cn`），列同上；`save` / `remove` 一律 `code=1`（只读） |
| `carVehicleCn/report` | — | 「中国车型统计」页面（品牌覆盖率报表），注入车型库对应的 `module` 和全部零件分类 |
| `carVehicleCn/reportData` | `{categoryIds:[...]}` | 一行一个品牌层节点：`car_node_name` / `vehicle_count` / `coverage{分类ID: 已覆盖车型数}`；`categoryIds` 为空只出车型数 |
| `carVehicleCn/reportExport` | 表单 POST `columns` + `categoryIds` | 同上数据导出 xlsx，覆盖率列拍平成 `cate_{id}`，值是 `12.34% (56)` 文本 |
| `carVehicleCn/syncRun` | `{preview?:bool, jobId}` | 从宜配拉全量车型，增量更新中国车型库；返回各类计数 + 变更明细的 `token`；`preview=true` 只比对不写库。`jobId` 由前端生成（8~32 位十六进制），进度轮询靠它配对 |
| `carVehicleCn/syncProgress` | `{jobId}` | 进度 `{phase, percent, seconds, running}`；查不到文件返回 `running=false`（还没开始写或已经跑完），成败以 `syncRun` 的返回为准 |
| `carVehicleCn/syncReport` | 表单 POST `token` | 下载上一次同步的变更明细 xlsx（对象 / 类型 / 来源ID / 名称 / 说明五列） |

编辑弹窗逐级选上级节点复用 `carNode/children`（见 S4）；层级数读 `car_library_level`，不写死。

**品牌覆盖率的口径**（`CarVehicle::brandCoverageReport()`）：「品牌层」取该车型库**最浅的一层非车型层级**
（中国库是「品牌」，国际库是「车厂」），不写死层数；品牌下的车型按 `car_node_path` 物化路径 `LIKE` 往下汇总。
选中的分类**含其全部下级**（零件只能挂末级分类），下级的覆盖数归并到选中的那一级。
覆盖数是 `COUNT(DISTINCT part_vehicle.part_vehicle_lnk_car_vehicle_id)`，同一款车型挂多个零件只算一次。
百分比在前端算（列里存数值参与排序，`cellTemplate` 拼成 `12.34% (56)`），后端只给覆盖数。

**同步车型**（`CarVehicleSync`，对标参考系统的 `carmodelcn/uploadModel`，没有照搬）：

- 上游 `GET {ypModelAPI.url}client/getYpcModel?token=xxx`，**裸数组响应**（没有 `{status,data}` 外壳，
  和清洗用的 `ypAPI` 不是同一个上游，所以没复用 `Wash`）。实测 83367 行 / 约 64MB，先落临时文件再解析，
  峰值内存约 500MB，全程约 4 秒
- 配置在 `config/common.php` 的 `ypModelAPI`（地址和令牌与 catalog_frey 的 `YPAPI` 相同），
  `admin/index.php` 要把它透传进 `Mvc::$cfg`；临时目录取 `Mvc::$cfg['upload']['tempDir']`（**不是**顶层的 `uploadTempDir`）
- **令牌不通用**：车型接口只认 `FREY-WEB1-…`，拿上面 `ypAPI`（api.paojd.cn 清洗接口）的 `YPPDC-tesT-…`
  调过来会收到 `{status:false, data:"token不存在", code:"token"}`——就是上面第 1 道闸拦的那种状态包。
  要给 MDM 单独发一个令牌得找宜配那边开
- 同步相关的接口**必须自己 `catch (MvcException)` 再 `$this->error()`**：异常冒到全局处理器输出的是 HTML，
  前端按 JSON 解析拿不到 `msg`，弹窗只剩一句没有下文的「同步失败：」。
  `error()` 内部 `exit`，`finally` 不会执行，所以要先记下文案、等进度文件收干净了再报错
- 参考系统的平铺表 `m_cn_model` 在 MDM 是 `car_node` 四层 + `car_vehicle`，所以要先把平铺结果还原成层级。
  认节点用**整条祖先链**（`brand_id|make_id|mod1_id|mod2_id`），不能只用本层来源 ID——
  385 个 `make_id` 对应 450 个「品牌 × 生产商」，只按 `make_id` 认会把不同品牌下的同名生产商混成一个
- **删除有闸**：被 `part_vehicle` 引用的车型不删、还挂着下级或车型的节点不删，只在明细里标「保留」
- 接口没有的列（起止月、马力、缸数、门数）不参与更新，保持原值
- 节点删除排在车型同步之后：车代换车系时表现为「旧节点消失 + 新节点出现」，得等车型先改挂过去
- `preview=true` 时新增节点用负数 ID 占位，所以挂在新节点下的车型在预览里一律算「新增」
- **两道安全闸，都在动任何数据之前**（`sql` 之外最重要的一条，别删）：
  1. 响应必须是**车型列表**。踩过一次：接口偶尔返回 `{status,data,code,msg}` 状态包，它也是数组、
     `count()` 正好等于 4，当成 4 行车型往下走会把全库 8 万多款判成「上游已删除」。
     参考系统的 `ypApi` 客户端同样在防这个（检查 `$return['status']`）
  2. 行数不能缩水：接口行数低于库里现有的 `minRowRatio`（默认 0.9）就整次中止，防截断响应
- **进度**：Model 通过 `onProgress(fn(phase, percent))` 汇报，Action 把它写进
  `{tempDir}/carSync/progress_{jobId}.json`，前端每秒轮询。阶段编码 fetch / parse / node / vehicle /
  cleanup / done，文案在视图里翻译，Model 不管显示。
  `syncRun` **必须先 `session_write_close()`**，否则 session 锁会把同一用户的进度轮询堵到同步结束
  （做法同 `file.php` 的分片上传）

**菜单结构**：`config/admin.php` 里「零件库」下的「车型」是第三级菜单
（零件 / 分类 / 号码厂商 / 车型 → 汽车品牌 / 国际车型 / 中国车型 / 中国车型统计），
`adminLayout.js` 的菜单渲染本身支持任意层嵌套，加一层不需要改前端。

### 零件库接口清单（S5：关联 / 区域 / 安装位置 / 来源）

| 端点 | 请求 | 说明 |
|------|------|------|
| `partItemPosition/list` | `{itemId}` | 已选的安装位置 ID 列表（不是 Grid 协议，多选打钩） |
| `partItemPosition/save` | `{itemId, ids:[...]}` | 整份提交，全量替换；候选范围完全由分类的 `part_category_position` 决定——**分类没配置时提交任何 ID 都报错**（该分类的零件没有位置可选），越界同样报错。详情页的 `allowedPositionIds` 为空时直接显示「该分类没有可选的安装位置」 |
| `partSource/info` | `{itemId}` | 来源快照，没有记录时返回 `null` |
| `partSource/save` | `{itemId, checkFrom: ''\|'tcd'\|'epc'\|'self'}` | 只开放确认来源手工设置，其它字段（清洗快照）由以后的数据单导入零件（S6）自动写入，本期不提供编辑 |

> **来源在详情页上只读展示**（2026-09-28）：零件详情「基本信息」的「清洗来源」区块（来源 / 来源号码 + 产品页链接 / 产品名 / 中英文参数），
> 数据由 `partItem/detail` 随页面带下（`washSource`，`PartSource::displayByItem()` 解好的快照，没有为 `null`），**不调** `partSource/info` / `save`，页面上不能改。
> 2026-09-22 去掉的「确认来源」下拉没有加回来；两个接口和 `setCheckFrom()` 原样保留。
| `partRelation/grid` / `remove` | `itemId` 必带 | 零件关系（link/pair/assembly），按当前零件是 a 端还是 b 端取行，两端各自的分页都能看到同一行；`save` 只能改配对类型/数据来源（不支持新增，新增走 `attach`） |
| `partRelation/attach` | `{itemId, type:'link'\|'pair'\|'assembly', otherItemId, role?:'parent'\|'child', pairTypeId?, dataSourceId?}` | 新增一条关系，只能选已有零件（不支持「填厂商+号码留作待匹配」）；`role` 仅 `type=assembly` 时必填，表示当前零件在这条装配关系里的角色 |

> **`partItemRegion/list` / `save` 已于 2026-09-19 删除**（连同 `PartItemRegion` / `partItemRegion` Action）：
> 用户拍板零件的适用区域由适用车型推算，零件本身不设置，口径同 `part_item_brand`。
> `sys_region` / `sys_region_country` 区域字典保留。推算端点等车型侧的区域数据接入后再加。

对方零件的选择走既有的 `partItem/grid`（带 `keyword` 参数即可，全文检索已覆盖号码/名称），不单独建搜索接口。

### 数据单清洗接口清单（S6a）

所有端点都可带 `sheetId`，带了就只能操作这张数据单里的明细；远端接口失败、校验失败都是 `code=1`。

清洗详情页的标签和参考系统 `work/wdiDetail` 一一对应：确定的对照号 / 不确定的对照号 / 确定的其它号码 / 不确定的其它号码（空组不显示）+ 产品国际车型 / 确定号码的国际车型 / 中国车型。**替换号（OE 替换号，`etk/getReplaceNumber`）在这个页面上不出现**：既不是标签页也不在卡片里。它的勾选（`selection.replace`）仍然存在、转零件照用，`saveSelection` 时前端原样把库里的值带回去，不会被清空。

清洗详情是**页面**不是弹窗：`washSheetItem/detail/k/itemId/{明细ID}`（明细列表里点号码 / 「清洗详情」用 `AdminLayout.openTab()` 开新标签），页面内容由 `washSheetItem/info` 提供。

| 端点 | 请求 | 说明 |
|------|------|------|
| `washSheetItem/grid` | loadOptions | 在原有列上多出：`wash_sheet_item_check_*`、`wash_sheet_item_lnk_part_category_id`、`wash_sheet_item_category_from` / `_candidates`（JSON 已解码）、`part_category_name`（可过滤排序）、`wash_sheet_item_clean_summary`（深度清洗统计，含 `params` 参数列表和 `vehicle_intl_product` / `vehicle_intl_oknum` 两个来源的车型数）、`wash_sheet_item_deep_clean_time`、`wash_sheet_item_deep_clean_stale`（1=过期） |
| `washSheetItem/clean` | `{key, mode:'unclean'\|'all'}` | 检测 → 确认参考产品 → 匹配分类 → 深度清洗，返回 `{key, skipped, cleanTime, warning}`；`warning` 非空 = 深度清洗失败但前面已保存 |
| `washSheetItem/confirm` | `{key, from:'tcd'\|'epc'\|'', sourceId}` | 人工确认（TCD art_id / EPC pro_id，必须在检测摘要里）后重新匹配分类并深度清洗，返回 `{warning}`；`from=''` 取消确认并删除深度清洗结果 |
| `washSheetItem/deepClean` | `{key}` | 只重跑深度清洗，返回统计；没有确认产品时报错 |
| `washSheetItem/setCategory` | `{keys:[...], categoryId}` | 批量人工指定分类（启用的末级），`categoryId=0` 清掉人工标记并立即自动匹配；返回 `{updated}` |
| `washSheetItem/matchCategory` | `{keys:[...]}` | 批量自动匹配，人工指定的跳过；返回 `{matched, multiple, none, manual}` |
| `washSheetItem/info` | `{key}` | `{item, clean, rateChanged}`：`clean` 为 null 或 `{result:{from, source_id, rate_number, rate_model, pro, numbers, replace, vehicles_intl, vehicles_cn, params}, selection, manual, check_from, check_source_id, clean_time}`；车型项带 name / path / years / engine / kw / hp / cc / fuel / cylinders / drive / body（详情页车型表格的列，对齐参考系统 work/wdiDetail），国际车型另带 `sources`（model / model_oknum，分两个标签用）；号码 `key` 是 `组名:unikey`（`num` 和 `othnum` 各自去重，同一号码两组都有时两条都留，界面是两个标签）；号码项带 `pic` / `pic_big`（缩略图 / 大图，接口给多张只取第一张）、`art_id`、`checks[{type, msg, rate}]`（type：promainnum 产品主号码 / samerefnum 相互对应 / refnumrate 各自的对照号对比 / outcom 各自搜索出来的号码；2026-09-20 之前清洗的结果 checks 是纯字符串数组，前端两种都认） |
| `washSheetItem/compareNumbers` | `{key, type:'out'\|'in', numbers:[{factory, number}], artIds:[art_id], sameCategory?}` | 号码比较（走 ypAPI `tcd/compareNum`，参数要整包 json 放表单字段 `data`）。`out` 比号码（至少 2 个）、`in` 比 TCD 产品（至少 2 个 art_id）；**`ga_id` 默认不传**（`sameCategory=true` 才传确认产品的产品类型）：实测传了之后接口把每列都收敛到该类型下的交集，几列结果会变得一模一样（悬置支架那条 5 个号码：传 ga_id 每列都只剩同样 2 条，不传是 3/17/11/12 /3 条）。返回 `{columns:[{factory, number, name, url, pic, pic_big, para, count}], rows:[{unikey, cells, count, same:'all'\|'some'\|''}]}`，行已按共有数量排好序，`cells` 与 `columns` 一一对应（该列没有这个号码就是 null） |
| `washSheetItem/saveSelection` | `{key, selection:{numbers, replace, vehicles_intl, vehicles_cn}}` | 各组是结果项的 `key` 数组，不存在的丢弃，返回规整后的勾选；没有深度清洗结果时报错 |

### 数据单转零件接口（S6b）

| 端点 | 请求 | 说明 |
|------|------|------|
| `washSheetItem/grid` | loadOptions | 在 S6a 基础上多出：`wash_sheet_item_lnk_part_item_id`、`wash_sheet_item_part_time`、`part_item_main_number` / `part_item_audit_status`（联转成的零件，零件已删为 null） |
| `washSheetItem/toPart` | `{key, skipConverted?: bool（默认 true）, sheetId?}` | 转一条，前端逐条调、不限条数。返回 `{key, status:'created'\|'merged'\|'skipped', itemId, reason, warnings:[...], candidates:[{part_item_id, part_item_main_number_display, part_item_name}], numbersAdded, vehiclesAdded}`；跳过是 `code=0` + `status=skipped`（`candidates` 只在同分类命中多个零件时有值），`warnings` 是被跳过的号码；OE 号的汽车品牌写进号码的 `part_number_car_brand_text`；写入失败 `code=1`、整条回滚。规则见 DB.md「数据单转零件规则」 |

### 商品库接口清单（P1：商品品牌 / 经营单元 / 分类商品属性 / 数据权限）

| 端点 | 请求 | 说明 |
|------|------|------|
| `proBrand/index` | — | 商品品牌管理页（菜单「商品库 → 商品品牌」） |
| `proBrand/grid` / `save` / `remove` / `export` | 标准 Grid | 可写 `pro_brand_code`（必填、字母数字、≤16、唯一）/ `_cn_name`（必填、唯一）/ `_en_name`（空串存 NULL）/ `_is_main` / `_is_enabled` / `_sort`（留空：新增排最后、修改不动）/ `_remark`；计算列 `pro_brand_name`（当前语言显示名，可过滤排序）；`pro_brand_factory_ids`（ID 数组）+ `pro_brand_factory_names`（展示用，不能过滤排序）是号码厂商范围，**提交了才全量替换**。主品牌已存在、品牌已有商品还改主品牌标记、删除时被商品或角色数据权限引用，都是 `code=1` |
| `proBrandLang/grid` / `save` / `remove` | `brandId` 必带，key = 语言编码 | 品牌其它语言译文，做法同 `carBrandLang` |
| `proUnit/index` | — | 经营单元管理页 |
| `proUnit/grid` / `save` / `remove` / `export` | 标准 Grid | 可写 `pro_unit_code`（必填、≤32、唯一）/ `_cn_name`（必填、唯一）/ `_en_name` / `_lnk_attr_value_id_pro_unit_type`（字典 `pro_unit_type`；新增不填且字典只有一个启用值时默认用它，修改不能清空）/ `_is_enabled` / `_sort` / `_remark`；计算列 `pro_unit_name`；被经营单元商品或角色数据权限引用时不能删 |
| `proUnitLang/grid` / `save` / `remove` | `unitId` 必带，key = 语言编码 | 单元其它语言译文 |
| `proCategoryExt/info` | `{categoryId}` | 分类商品属性，没设置过时各列为空值（不是 Grid 协议） |
| `proCategoryExt/save` | `{categoryId, values}` | 整份提交（没提交的列按空值）；只对末级分类；报关单位取字典 `unit`、默认物料类型取字典 `item_type`；退税率 0–100；流水位数 1–16；返回最新 `info` |
| `role/grid` / `save` | 标准 Grid | 在原有列上多出 `sys_role_pro_brand_ids` / `sys_role_pro_unit_ids`（ID 数组，提交了才全量替换，空数组 = 该角色不限制）和对应的 `_names`（展示用） |
| `numberVendor/remove` | `{key}` | 新增拦截：被商品品牌的号码范围引用时 `code=1` |

### 商品库接口清单（P2：编码规则引擎）

| 端点 | 请求 | 说明 |
|------|------|------|
| `proCodeRule/index` | — | 编码规则页（菜单「商品库 → 编码规则」），两个页签：规则与段、码表 |
| `proCodeRule/grid` / `save` / `remove` / `export` | 标准 Grid | 可写 `pro_code_rule_code`（字母数字 `-_`，≤32，唯一）/ `_cn_name` / `_remark` / `_is_enabled` / `_is_manual` / `_lnk_pro_brand_id` / `_lnk_part_category_id` / `_lnk_attr_value_id_ext`（字典 `pro_code_ext`）；`pro_code_rule_expression`（不是真实列）提交了就**整体替换段**，写错整条不保存；grid 多出 `pro_code_rule_expression`、`_segment_keys`、`_brand_names`（展示用，不能过滤排序）。同一组条件已有启用的自动规则、被商品品牌或商品引用时删除，都是 `code=1` |
| `proCodeRule/preview` | `{ruleId, family, context}` | `ruleId=0` 按适用条件自动选；`family=true` 同时算各商品品牌的编码（上级编码 = 主编码）。`context`：`part_item_id` / `category_id` / `pro_brand_id` / `car_brand_id` / `item_type_id` / `material_id` / `ext_id` / `attrs:{类型编码: 取值ID}` / `parent_item_id` / `parent_code` / `parent_segments`（数组或 `M=6,C=808`）/ `date`。返回 `{items:[{brand_id, rule_id, rule_code, via(condition / brand_default / manual), result, error}]}`，`result` 含 `code` / `code_format` / `code_segments` / `unique_key` / `level` / `segments` / `warnings` / `duplicate`。不写库、不占号 |
| `proCodeRule/manualRules` | — | 启用的手动规则 `[{pro_code_rule_id, _code, _name, _expression}]`，P3 / P4 生成商品时选 |
| `proCodeRuleSegment/grid` / `save` / `remove` | `ruleId` 必带 | 段；保存按段类型整行规范化（见 DB.md「编码规则引擎」） |
| `proCodeMap/matrix` | `{mapType, ruleId(0 = 通用), variantBy('' / item_type / material / code_set)}` | 码表矩阵 `{variants:[{variant, name}], rows:[{target_id, target_name, target_path, fallback, codes:{变体: 编码}}]}`；`mapType` 可以是 `attr:通用属性类型编码` |
| `proCodeMap/saveMatrix` | `{mapType, ruleId, changes:[{target_id, variant, code}]}` | code 为空 = 删除；编码只能字母数字、≤16 → `{count}` |
| `proBrand/save` | 标准 Grid | P2 新增可写 `pro_brand_lnk_pro_code_rule_id`（品牌的默认编码规则，空存 NULL，规则不存在 `code=1`）；删除时被编码规则的适用条件引用也拦下 |
