# 模块开发进度 — 会话记录归档

> 本文件是 [modules.md](modules.md) 「最近会话记录」的完整历史版本，2026-09-16 拆分。
> **日常会话开场不需要读本文件**——`modules.md` 的表格 + 一行摘要已经够用；
> 只有排查某次改动的具体决策、自测细节、踩坑经过时，才按日期/关键词来查这里。
> 新会话结束后，把完整记录追加到本文件末尾，`modules.md` 只加一行摘要（格式见该文件说明）。

---

### 2026-09-16 零件库 S0 — 框架层分片上传 + zip 安全解压（跨层）

跨层原因：上传能力在框架层（`lib/UploadedFile` / `FileUploader` / `FileStorage`），分片与解压是它的扩展；配置和接口在业务层。业务层只动了配置、`file` 接口、语言包和一个通用 JS。
框架新增 4 个类：`ChunkUpload`（dxFileUploader 自带的 chunkSize 协议：`chunkMetadata` 的 `FileGuid` 当上传 ID，格式校验后才拼目录；首片写 owner，续传 / 查询 / 完成都要同一个 owner；`meta.json` 上 flock 串行；非最后一片大小一致 + 总片数 = `ceil(总大小 ÷ 分片大小)`；合并前核对片数与总大小；完成后删分片、结果留在 meta，重发最后一片拿同一结果不重复入库；回调抛 `UploadException` 作废整个上传，抛其它异常保留分片可重试）、
`ZipSafeReader`（只扫中央目录出清单；zip slip / 绝对路径 / 盘符 / 控制字符判不安全；条目总数、文件数、解压总大小、单文件大小、压缩比上限；忽略目录项 / 符号链接 / `__MACOSX` / 隐藏文件 / `Thumbs.db`；加密与重复路径记失败；非 UTF-8 名按 CP936 转码；解压按登记大小封顶读取，多一个字节判炸弹并删半截文件）、
`ZipTask`（前端驱动分批：建任务只扫清单，每批用到才解压；每个文件重新过 `inspect()` 再交业务处理器；逐个写回游标；flock 非阻塞防并发；完成删 source.zip）、`UploadTempDir`（过期清理 + 递归删除，不跟随符号链接）。`UploadException` 加 12 个错误代码（分片 / zip / 任务）。
**大小上限不再看 php.ini**：`maxSize()` = 配置值（分片上传按它），新增 `directMaxSize()` = 与 php.ini 取小（只给不分片的 `file/upload`）；`inspect()` 可传临时上限给 zip 用。只有单片受 `upload_max_filesize` / `post_max_size` 限制。
业务：`file/chunk`（普通文件，最后一片合并后走原有校验 + crc32 去重入 `sys_file`）、`file/chunkStatus`（断点续传查已收分片）、`file/zipChunk`（zip 合并后校验 + 扫描 + 建任务，参数 `kind` 选处理器）、`file/taskNext`（每批默认 20、上限 100）、`file/taskDiscard`；`options` 增加 `chunkSize` / `directMaxFileSize` / zip 上限和前端文案。处理器注册表在 `file.php` 的 `taskProcessors()`，本会话只放最小的 `check`（只校验不入库），S3 的零件图片在这里挂自己的 kind 并在处理器里做权限判断。
前端 `static/js/lib/chunkUpload.js`：`createChunkUploader()` 用 dxFileUploader 的 `uploadChunk` 接管发送（切片仍由 DevExtreme 做，请求格式与自带协议一致），上传 ID 记 localStorage，重选同一文件先查 `chunkStatus` 跳过已收分片、最后一片总是重发；`runZipTask()` 逐批调 `taskNext` 显示进度和跳过 / 失败明细（做法同 `washClean.js`，用 dxProgressBar + dxScrollView，没加新 CSS），结束或关窗口调 `taskDiscard`。
**与拍板的一处差异**：解压改成「建任务时只扫中央目录 → 每批用到哪个文件才解压哪个」，不是上传完一次性解压。理由：本机 `max_execution_time=30`，2GB 包一次解压会超时且占双倍磁盘；整包级的炸弹 / 文件数 / zip slip 仍在建任务那一步就拦下，清单总数照常返回。
自测：CLI 88 项（分片解析与伪造 ID、顺序 / 乱序 / 续传 / 他人续传、分片大小与总数核对、回调失败两类、过期清理与节流；zip slip 5 种、GBK 名、炸弹、伪造登记大小、加密、重复、忽略项、三种整包超限；任务并发、环境异常后从出错文件续跑、处理器跳过 / 失败、放弃与清理）；
HTTP 43 项（`php -S` + 临时 router 伪造登录）：未登录 401 / 302、5MB 文件 5 片上传成功（本机 `upload_max_filesize` 只有 2M）、同内容去重、重发最后一片不重复入库、断点续传、他人续传与他人查询被拒、伪造上传 ID、乱序、声明超限、单片超 `post_max_size`、单片超分片上限、zip 建任务 + 分批处理 + GBK 路径 + 伪装 png 与包内 php 被拒、未知 kind、非 zip 的 .zip、5001 个文件超限、英文文案。测试建的 3 条 `sys_file` 记录先 SELECT 预览再删除，存储文件一并删掉，行数回到 0；临时目录只剩空的 `chunk` / `task`。
踩坑：PHP curl 扩展发 multipart 时 `CURLOPT_POST` 设在 `CURLOPT_POSTFIELDS` 之后，会把 multipart 重置成空的 urlencoded 表单（服务端 `$_FILES` / `$_POST` 全空，差点误判成上传代码的问题）；`php -S` 后台启动时 `cd` 不生效，要用绝对长路径 + `-t`。两条已写进框架契约的「本地测试踩坑」。
已知限制：超 `post_max_size` 时 PHP 启动期 Warning 仍会排在 JSON 前面（生产要关 `display_startup_errors`）；这种情况下报的上限是文件上限而不是分片上限（前端按 `options` 的 chunkSize 切片，正常不会遇到）；PHP 7.4 本机没装 zip 扩展，8.1 的 `getStream()` 回退路径是在 8.3 上用同一调用方式验证的；浏览器实测（dxFileUploader 真实分片）需人工登录后做，本会话没做。

### 2026-09-16 零件库 S1 — 菜单 / 列表 / 新增 / 基本信息 + 多语言 / 号码 / 审核 / 删除

（只改 `pdc/`，没动框架）。
表：`sql/migrate_part_item_audit.sql` + `_rollback.sql`，2026-09-15 经用户授权执行（预览 `part_item` 0 行、表不存在 → 改注释 + 建 `part_item_audit_log` → 核对类型 / NULL / 默认值未变、外键 2 条、`sys_migration` 记 1.0.2；1.0.1 已被排序规则迁移占用）。
Model：`PartItem`（列表计算列 + `gridWhere()` / `gridDefaultOrder()`、新增、基本信息白名单、删除、`assertEditable()` / `touch()` / `syncMainNumber()`、四个审核动作）、`PartNumber`、`PartNumberBrand`、`PartItemLang`、`PartItemAuditLog`、`PartCategory`、`CarBrand`，共用 trait `PartItemInput`（操作人、值转换、长度校验、事务）；
`AttrValue` 加显示名计算列 `attr_value_name` + `optionsByTypeCode()` / `belongsToType()`（attrValue 页面已回归测试），`SysLanguage` 加 `translatableNames()`。
Action / View / 前端：`partItem`（index / detail / grid / save / remove / export / info / submit / withdraw / approve / unapprove / auditLog）、`partNumber`（+ `duplicates`）、`partItemLang`；
`view/partItem/index.view.php`、`detail.view.php`、`static/js/lib/partItem.js`、`static/css/partItem.css`；菜单第一组「零件库 / 零件」。
关键决定：**事务用 Medoo 的 `action()`**——框架 `Db::begin()` / `end()` 没有回滚，业务校验失败时 `error()` 直接 exit，shutdown 里会再抛 `RuntimeException`；用户在「业务层用 action()」和「先改框架加回滚」两个方案里选了前者（不改框架）。
号码批量查询走 `part_number` 子表 `EXISTS`（`dxGrid.js` 的 batchSearch 只能对单列 `anyof`，所以列表页自己做弹窗，值放进 `params` 交服务端）；
同号提示改为新增成功后先弹提示框、关掉再打开详情标签（详情标签一打开，列表页的 notify 就看不到了）；新增时把分类上配置的默认单位 / 产品经理 / 采购经理带到零件上。
自测：CLI 130 项（参考数据与下拉、新增校验与失败回滚、基本信息白名单与派生列丢弃、号码主号规则 / 跨零件限定 / 品牌全量替换、列表分类 + 关键词 + 号码 + 计算列过滤排序、翻译 upsert 与删除、审核全流程与自审开关、级联删除）；
HTTP 45 项（`php -S` + 临时 router 伪造登录）：未登录 401 / 页面 302、列表页与详情页无 PHP 报错、导出 xlsx、英文会话下显示名取英文且不串会话、状态不符返回 `code=1`、审核接口返回最新 info。两轮测试数据都先 SELECT 预览再删除，跑完各表行数与测试前一致。
语言包：CLI 调 `LangScanner::run()`（与 langTool 扫描同参数），`zh-CN.php` / `en.php` 各新增 80 条、删除 0 条，英文已全部翻译；扫描前备份在会话临时目录。
契约：`contracts/API.md` 新增「主从子表接口约定」与零件库接口清单；`contracts/DB.md` 新增「零件审核与零件写入规则」（状态流转、只读校验、派生列维护责任、业务层事务写法）。
**库里有一条不是本会话造的数据**：`part_item` ID 7「刹车片 / TRW GDP900」，2026-09-15 15:32 由超级管理员创建（应是用户在浏览器里试新页面），测试没碰它，也没有删。
未做 / 已知限制：浏览器实测需人工登录；S2–S5 的分页（参数 / 图片 / 车型 / 关联 / 区域 / 来源）在详情页不显示；列表「汽车品牌」列只展示、不能过滤排序（等 S4 定了 `part_item_brand` 的口径再做）；列表导出时图片列导出的是文件 ID。

### 2026-08-31 修复 Model.php PHP 版本兼容问题；规划会话分层与契约体系

### 2026-09-15 框架层配合 - 全文检索封装（跨层）

跨层原因：Medoo 与 DxFilter 都表达不了 `MATCH ... AGAINST`，Grid 的 WHERE / ORDER BY 也没有接入附加条件的口子；检索内容怎么拼属于业务。
服务器（192.168.1.188，MySQL 8.0.20）：`ngram_token_size=2`；`innodb_ft_min_token_size=3` / `ft_min_word_len=4` 对 ngram 不生效（中文双字、2 位号码片段可查，天然支持词中间匹配）；单字查不到（`气*` 只能匹配以该字开头的二元组）→ 短词走 LIKE。
**停用词问题**：`innodb_ft_enable_stopword=ON` + 内置 36 个英文停用词（含 `a`、`i`），ngram 丢弃包含停用词的二元组，实测 `pad` / `AI` / `Air` 查不到、`cylinder` / `disc` 只靠剩余二元组命中。已写 `sql/part_item_search_ngram_stopword.sql`（本会话关停用词后 DROP / ADD 索引 + sys_migration，不改服务器参数），后已执行（见下方 2026-09-15「零件库一期建表」及 modules.md 主表）。
框架：新增 `mvc/v2/lib/FullText.php`——`terms()` 去 BOOLEAN 操作符 `+ - > < ( ) ~ * " @ : & |` 和控制字符、按空白拆词、去重、限 10 词 / 100 字；`condition($列, $关键词|写法数组)` 逐词 `+"词"`，词长 < token size 走 `LIKE`（转义），多种写法组间 OR（全是长词时合成一个 MATCH 走索引）；`relevance()` 相关度；列名过 `DxFilter::column()`。`Model::grid()` 加 `gridWhere()`（与 filter AND，总数同样生效）、`gridDefaultOrder()`（前端没排序时用，参数接在 WHERE 参数后），默认空，现有 Model 行为不变；与并行会话加的计算列逻辑共存。
业务：新增 `pdc/model/PartItemSearch.php`。内容一段一行（换行切断 n 元组，避免跨字段假命中）：零件中英名称 / 描述 + `part_item_lang` 名称 / 描述；号码原文、去空白、`formatNum()`、存库格式化值、厂商名；分类中英名称 / 别名 / 关键字（按逗号拆）+ `part_category_lang`；`part_item_summary` 品牌 / 底盘 / 发动机（车型名汇总不收：太长、会大量互相命中）。写入用参数化 `INSERT ... AS new ON DUPLICATE KEY UPDATE`（会消耗自增值，`part_item_search_id` 跳号无影响）。检索时关键词全为 ASCII 才加 `formatNum()` 写法（含中文时加了会放宽条件）。
触发时机（类注释同）：零件新增 / 修改、号码增删改、零件翻译增删改、车型汇总刷新 → `refresh($id)`；分类名称 / 别名 / 关键字 / 翻译变更 → `refreshByCategory()`；厂商改名 → `refreshByFactory()`；批量导入 / 改拼装规则 → `refreshAll()`；删零件靠外键级联。未接任何界面。
自测（CLI，脚本在会话临时目录）：纯函数 12 项；带标记临时零件 5 个 + 临时语言 `zz`（全文索引提交后才可查，不能用事务回滚）→ 刷新 6 项、检索 37 项（中文 / 英文 / 俄文译名 / 描述、完整号 / 带空格号 / 部分号 / 全角号 / 格式化号、厂商、分类关键字、单字 LIKE、注入式输入）、Grid 组合 5 项（关键词 + 嵌套 filter、前端排序优先、分页、总数、无关键词不限制），共 60 项通过，另 2 项为已知停用词问题（`pad`、`Air`）。删除前 SELECT 预览后删除，核对 `part_item` / `part_item_lang` / `part_number` / `part_item_search` 均为 0、`sys_language` 回到 2。自增值被消耗（`part_item` 下个 ID 从 7 起）。
契约：`mvc/v2/.claude/CLAUDE.md` 与 `AGENTS.md` 新增「全文检索」；`contracts/DB.md`「其它约定」补全文索引建索引前关停用词、`PartItemSearch` 维护责任、raw SQL 仅限 upsert / 全文检索。未做：零件 Model / Action / View；浏览器实测。

### 2026-09-15 框架层配合 - 语言包扫描按启用语言生成（跨层）

跨层原因：语言解析在 `Mvc::init()`、扫描同步在 `lib/LangScanner`，启用语言的数据在业务库 `sys_language`。
传参：`Mvc::init()` 新增配置 `locales`（`[编码 => 自称]` 数组或闭包，闭包在 composer / projectDir 就绪后调用），存 `Mvc::$cfg['locales']`；框架不读业务表。`admin/index.php` 传 `fn() => (new SysLanguage())->enabledNames()`（新增 `model/SysLanguage.php`），每请求一次主键小表查询。
框架：`Lang::isValidCode()`（编码拼文件名前必过，`Lang::load()` 不合法抛异常——原来 `?lang=../config/database` 能 require 到配置文件）、`Lang::match()`（大小写不敏感规范写法）、`Lang::fallback()`（资源回退 精确 → 语言段 → en）；`resolveLocale()` GET → SESSION → 默认逐级校验，失效 SESSION 清掉，默认 zh-CN 始终可用。
`LangScanner::run()` / `editableLangFiles()` / `loadFileRows()` / `updateFileValue()` **签名加 `$locales`**（pdc 调用点只有 langTool，已同步）：zh-CN 始终同步、启用语言缺文件自动建（中文占位）、停用语言文件不读不写不删并在返回值 `kept` 列出、只允许编辑启用语言。
业务：langTool 下拉显示「自称 (文件)」、扫描结果提示保留未同步的文件（新 key 1 条，en 已译）；首页 / 登录页语言切换菜单改为遍历 `Mvc::$cfg['locales']`（原先写死中文 / English）；`header.view.php` 按实际存在的 `dx.messages.*.js` 回退，`locale()` 仍传真实编码。
自测：先 SELECT 预览（2 行），临时插 `ko / 한국어` 启用：扫描生成 `ko.php` 218 条、可编辑；CLI 解析 11 例（空 / en / EN / zh-cn / ko / xx / 路径穿越 / 非法 GET + 合法 SESSION 等）正确；HTTP 登录页菜单三项、`ko` 加载 `dx.messages.en.js`；浏览器 `locale('ko')` 下 DevExtreme 消息为英文、日期为韩文，zh-CN 不受影响。停用后：扫描 `kept: [ko.php]`、文件 md5 不变、langTool 拒绝读取、`?lang=ko` 与 SESSION `ko` 均回退默认。测试结束删除 `ko` 行（复查只剩 zh-CN / en）和 `ko.php`；语言包与备份比对只多了新 key 和并行上传会话的 key。
契约：`mvc/v2/.claude/CLAUDE.md` 与 `AGENTS.md` 多语言小节；`contracts/i18n.md` 重写启用语言 / 维护流程 / 切换机制（改正「跑 `php pdc/lang/scan.php`」——它是扫描结果不是脚本，`pdc/.claude/CLAUDE.md` 同步改正；删文案会被扫描自动删 key）。未做：登录后台后 langTool 页面与首页切换菜单的浏览器实测（需人工登录）。

### 2026-09-15 框架层配合 - Grid 按多语言显示名筛选排序（跨层）

跨层原因：`Model::grid()` / `buildOrderSql()` / `DxFilter` 只认真实列名，显示名是按当前语言算出的表达式，业务层无法让它进 WHERE / ORDER BY。
框架：`Model::gridComputedColumns()`（列名 => 表达式，默认空）+ `gridComputedSelect()`；`grid()` 把计算列传给 `DxFilter::toSql($filter, $computed)` 和排序，count 复用 `gridFrom()` + 同一 WHERE，GROUP BY 的 `COUNT(DISTINCT)` 同样生效。
新增 `DxFilter::column()`：计算列换表达式，其余字段必须是 `字段` / `别名.字段`（字母数字下划线）否则抛 `MvcException`——原来只剥反引号，现在过滤和排序都是白名单格式。`Lang::translation($table, $alias, $fields, $locale)` 生成翻译表 LEFT JOIN + 回退表达式（空串按缺失，`NULLIF`），语言编码过 `Lang::isValidCode()` 才拼进 ON；`pickField()` 保留给无翻译表的旧表（`admin_depa`）。
兼容：`toSql()` / `buildOrderSql()` 新参数带默认值；pdc 里覆盖 `gridFrom/Select/GroupBy`（Users / Depa / Member / Role）和 `grid()`（AttrValue / WashSheetItem / Users / Member / Depa）的地方不用改，没有 dataField 带点号或特殊字符；`paging()` 走 Medoo 数组条件、pdc 无调用点，未改；`GridActions` 三端点、`Action::json/success/error` 未动。前端不用改：`dxGrid.js` 的 CustomStore 原样转发 filter / sort，列 `dataField` 写别名（如 `part_category_name`）即可，导出同样走 `grid()`。
自测（CLI，临时 Model 与脚本在会话临时目录，未进项目）：`part_category` 406 行 / `attr_value` 58 行，zh-CN / en / 假设 ru（事务内插 `sys_language` 与译文、结束回滚，已核对无残留）下 contains / = / anyof / noneof / `!` 嵌套 / isblank / 排序与手写 SQL 一致，译文为空串回退英文，GROUP BY 总数正确，注入字段名与非法语言编码被拒，现有 5 个 Model 回归与密码列拦截正常，共 46 项通过。
**发现**：`attr_type` / `attr_value` 是 `utf8mb4_0900_ai_ci`，翻译表是 `utf8mb4_unicode_ci`，第三语言下按名称过滤报 1267。已写 `sql/migrate_attr_collation_unicode_ci.sql`（预览 / CONVERT / 核对 / sys_migration）+ `_rollback.sql`，后已执行（见 modules.md 主表）；预览实测唯一键无冲突（27 行 NULL 编码不参与）、无尾部空格。另：库 `sql_mode` 含 `ANSI_QUOTES`，表达式字面量只能单引号。
契约：`mvc/v2/.claude/CLAUDE.md` 与 `AGENTS.md`（DxFilter 计算列、多语言 `translation()`）、`contracts/DB.md`（多语言排序规则一致、Model 使用规则与示例）。未做：零件库菜单与界面；浏览器实测。

### 2026-09-15 框架层配合 - 通用文件上传（跨层）

跨层原因：项目里原本没有任何 `$_FILES` / multipart 处理，上传校验与存储是通用能力放框架层，业务层只做 `sys_file` 的 Model 和接口。
框架（`mvc/v2/lib`，只新增文件，`Mvc.php` 未改——multipart 的普通字段本来就进 `$_POST`、`php://input` 为空，`buildParams()` 不受影响）：
`UploadedFile`（唯一读 `$_FILES` 的地方，兼容 `file` / `files[]`，按 `CONTENT_LENGTH` 识别超 `post_max_size`）、`FileUploader`（大小上限取配置与 php.ini 最小值、扩展名白名单、`finfo` 真实 MIME、位图 `getimagesize`、写死的禁止扩展名 / MIME、`年/月/日/随机hex.扩展名` 路径、存储选择）、
`FileStorage` 接口 + `LocalFileStorage`（路径白名单正则 + realpath 防穿越、sha256 内容比对、不覆盖、鉴权输出带 ETag/304/nosniff/`filename*`、图片内联加 CSP sandbox）、`UploadException`（错误代码常量，框架不写 `lg()`）；OSS 只留接口，`storage('oss')` 抛「尚未实现」。
业务：`model/SysFile.php`（crc32 + 大小 + 存储名找候选，再比实际内容一致才复用；入库失败删已写文件）；`admin/action/file.php`（`upload` / `view` / `download` / `options`，走登录，`UploadException` 代码映射成 `lg()` 文案）；`config/upload.php`（50MB、类型白名单、内联类型）；`common.php` 加 `uploadDir`（默认 `pdc/storage/upload`，`common.local.php.example` 给了 Web 根外示例）；`admin/index.php` 合并成 `Mvc::$cfg['upload']`；`pdc/storage/.htaccess` 禁止直接访问。
访问方案：选**经 Action 鉴权输出**，不直出静态目录——零件图纸属业务资料，要求登录；以后按零件权限控制、切 OSS（302 签名地址）时前端网址不变；代价是每次经 PHP 读文件（已 `session_write_close()` + 浏览器缓存 1 天 + ETag）。
语言包：新增 10 条上传错误文案，手工补进 `zh-CN.php` / `en.php`（已译）/ `ko.php`（原文占位），没跑扫描——另一会话正在改 `LangScanner`，`scan.php` 等下次扫描自动带上。
契约：`mvc/v2/.claude/CLAUDE.md` 与 `AGENTS.md` 新增「文件上传」设计约束，「本地测试踩坑」补 Git Bash curl 上传与 `php -S` router 路径两条。
自测：CLI 36 项（路径穿越 11 种、内容比对、写入不覆盖、ini 换算、文件名清洗、配置禁止 php/svg、oss 未实现）；`php -S` + curl（伪造登录头）：未登录 401 / 页面 302、png/gif/pdf/txt/3MB 成功、同内容与中文名去重、存储内容被改后不误复用、超管上传人为 NULL / 普通用户为 ID、多文件 / 空文件 / 无扩展名 / 伪造 MIME / `.php` / PHP 藏在 jpg 与 txt / 只有 PNG 魔数 / exe 冒充 zip / HTML 冒充 png 全部拒绝、超 upload_max 与超 post_max 报上限、英文文案、view 内联与 download 附件头、304、不存在 ID；Apache 80 端口直接访问存储目录和文件均 403。测试记录与文件已删除，`sys_file` 恢复 0 行（自增值已到 7，未重置）。
已知限制：超 `post_max_size` 时 PHP 启动期 Warning 会先于 JSON 输出（生产关 `display_startup_errors`）；复用记录时返回第一次上传的原文件名；登录用户凭 ID 可访问任意文件，按零件权限控制等业务接上再加；没有删除接口（引用表 RESTRICT / SET NULL，孤儿文件清理以后做）；不支持分片上传；DWG / 新版 Office 的 MIME 允许 `application/octet-stream`，只用本机 libmagic 验证过 png/gif/pdf/txt。
前端对接 dxFileUploader（零件界面暂不做）：`uploadMode: 'instantly'`、`uploadUrl: APP.HOST + '/file/upload'`、`name: 'file'`、`uploadHeaders: {'X-Requested-With': 'XMLHttpRequest'}`（未登录才会回 401 JSON 而不是 302）、`maxFileSize` / `allowedFileExtensions` 从 `file/options` 取；接口业务错误也是 HTTP 200，要在 `onUploaded` 里 `JSON.parse(e.request.responseText)` 判 `code`，非 0 时提示 `msg`，成功取 `data.sys_file_id` 写进业务表单

### 2026-09-07 新增「零件关联属性」菜单组 + numberVendor（号码厂商）模块

图标 fa-puzzle-piece。Model/Action/View/菜单/语言包已完成，表 `attr_factory`（零件关联属性统一用 attr_ 前缀）建表 SQL 当时待人工执行（已于 2026-09-14 迁移执行完成，见 modules.md 主表）。

### 2026-09-07 项目分析（只读）

确认生产为 PHP 8+，改正三份契约的 PHP 版本约束（根契约 / 框架契约 / AGENTS.md），下限定为 8.1。

### 2026-09-07 新增「数据单」菜单组 + washSheet / washSheetItem 两个模块

图标 fa-file-text-o。Model/Action/View/菜单/语言包已完成；DB.md 补充 `wash_` 前缀与两表的主键、FK 命名；表 `wash_sheet` / `wash_sheet_item` 建表 SQL 当时待人工执行（已建，见 modules.md 主表）。

### 2026-09-07 数据单列表点标题打开明细标签 + 粘贴导入号码

`washSheet/detail`。`dxGrid.js` 增加 `params`（固定请求参数，主从页面限定范围用）和 `toolbar.custom`（业务自定义按钮）两个通用能力；`adminLayout.js` 的标签标题改用 `.text()` 写入（标题来自业务数据，不能拼 HTML）。

### 2026-09-07 框架层新增号码去格式公共函数

`mvcFunction.php` 增加 `formatNum($string, $keep = "")`（全角转半角 → 只保留字母数字和 `$keep` 里的额外字符 → 转大写）及其依赖的 `getSemiangle()`（全角转半角，走 mbstring）；当时暂无业务调用点，供后续模块使用（后来 `wash_sheet_item_format` 等多处用上）。

### 2026-09-08 修固定列行高错位

清洗结果展开号码后，勾选框/操作列那张表不跟着变高。`dxGrid.js` 增加 `dxSyncRowHeights()`（先比高度，错位才调 DevExtreme 的 `updateDimensions()`），并在 `createDxGrid()` 里按点击自动补一次；单元格模板不用管这件事。没用 ResizeObserver/rAF，因为后台标签页 iframe 是 `display:none`，不渲染就不派发回调。新增 `contracts/local.md`（本机地址/PHP 路径，每人一份不提交），根契约加「第三方代码不可改」一节。

### 2026-09-07 数据清洗上线

`model/Wash.php` 重写为 ypAPI 清洗客户端（cleanTcd / cleanEpc / checkNumber + 摘要提取），`washSheetItem/clean` 单条清洗端点，`washClean.js` 共用清洗弹窗与进度条，明细两页新增 TCD / EPC 结果列并开多选。**跨层改动**：`mvc/v2/lib/HttpClient.php` 增加 `postForm()`（表单编码 POST）——ypAPI 的 `tcd/*` POST 接口只读 `$_POST`，实测 GET 返回空、JSON body 报「token不存在」，业务层无法绕开。`langTool` 的 SCAN_DIRS 补上 `admin/action`（原先 action 里的 lg() 文案扫不到，会被当成废弃 key 删掉）。

### 2026-09-15 零件库一期建表与初始数据迁移

经用户授权执行 `sql/part_library_v1.sql`（45 张新表、`attr_factory` 加类型列、种子数据），再执行 `sql/migrate_part_library_v1_data.php`（源库账号走环境变量；首次在中国车型因功率脏值 `221220257199` 超出 DECIMAL 范围整步回滚，加上限 9999 后重跑，已完成的步骤自动跳过）。DB.md 补充 `part_` / `car_` 前缀、`sys_language` 字符串主键例外、业务数据多语言翻译表规则、生成列与 `_source_id` 约定。

### 2026-09-14 零件库结构设计

只读学习参考站与 `frey_catalog` 库，未写代码：出新表方案草案，定下命名规则与车型全量同步；核对实际库发现 `attr_factory` / `wash_sheet` / `wash_sheet_item` 已建，本文件状态已更正；删除过期文件 `sql/sysDxDataGrid.sql`；统一命名改名拆到单独会话。

### 2026-09-14 统一表/字段命名（跨层）

规则写进 `contracts/DB.md`：主键 `{完整表名}_id`、字段 `{完整表名}_xxx`、外键 `{本表}_lnk_{被引用完整主键}`（同表双外键加 `_a`/`_b`）、索引 `idx_{列名}`、外键约束 `fk_{列名}`。
改名：`admin_user` 全部字段 `admin_*` → `admin_user_*`；`attr_factory` 的 `factory_id/name` → `attr_factory_*`；17 个外键列（`_lnk_admin_id` → `_lnk_admin_user_id`、`_lnk_role_id` → `_lnk_sys_role_id` 等）及对应索引名/外键约束名。
代码：`mvc/v2/lib/Auth.php`、`PermissionResolver.php`、`ExcelExporter.php`（注释）+ `pdc` 的 Model/Action/视图全量替换；`$_SESSION['user']` 快照键改为 `admin_user_id/admin_user_username/admin_user_name`，`Auth::user()` 把没有 `admin_user_id` 的旧快照当未登录处理（部署后老会话走「记住我」或重新登录）；超级管理员仍是 ID=0。
SQL：新增 `sql/migrate_unify_naming.sql`（备份/预览/清空布局/删外键/改列/重建外键/核对/回滚）；各建表 SQL 同步新命名，`attr.sql` 补上实际库已有的外键；历史迁移 `migrate_rename_sys_dxdatagrid.sql` 保持原样。
布局：迁移 SQL 里 `DELETE FROM sys_dxdatagrid`（state JSON 存的是旧列名，当前 3 条测试布局，用户决定清空），执行后各用户需重新保存布局。
`wash_sheet` / `wash_sheet_item` 补外键（用户确认）：添加人列改为可空，`ON DELETE SET NULL`，超级管理员建单写 NULL；明细→数据单 `CASCADE`；明细→厂商 `RESTRICT`，厂商列去掉默认值 0。
程序同步：`WashSheet` 空添加人写 NULL；`WashSheetItem::assertReferences()` 新增/修改时数据单、厂商必填；`NumberVendor::gridRemove()` 厂商被使用时拒绝删除；两个明细视图的厂商列改为必填。
联表计算字段一起统一：`admin_depa_ids/names` → `admin_user_depa_ids/names`，`admin_role_ids/names` → `admin_user_role_ids/names`，`admin_display_name` → `admin_user_display_name`（Users Model + users / washSheet 视图 + dxGrid.js 注释）。
`mvc/v2/NOTES.md` 有效规则与踩坑记录并入 `mvc/v2/AGENTS.md` 后删除。
**框架修复（跨层）**：`mvc/v2/GridActions.php` 的 `save()` / `remove()` 捕获 Model 抛出的 `MvcException`，转成 `code=1` + 异常信息（响应格式不变）；原来会落到全局异常处理、前端只看到通用错误。API.md 补充 Model 校验失败的约定。受益的现有校验：`Permission` 权限码校验、`Role::bindToMember()`、本次新增的明细必填和厂商删除检查。
部门计算字段一起统一：`depa_role_ids/names` → `admin_depa_role_ids/names`（Depa Model + depa 视图）。
SQL 汇总：复查实际库确认迁移 SQL 一条都没执行；比对建表文件发现 `wash_sheet_item_tcd` 文件是 MEDIUMTEXT、库里是 TEXT，作为独立的第 7 步写进同一份 SQL，由人工决定。
**执行（用户已备份 pdc 库并授权）**：第 0~6 步已执行完成，第 6 步核对通过（旧列名 0、外键 19 条、布局清空）。首次执行时第 1.1 步预览把第 0 步刚建的备份表也算进去（31 行，预期 30），脚本按设计在任何改动前停止；给 1.1 加上排除备份表的条件后从第 1 步续跑。
冒烟测试 22 项全过（CLI 调各 Model，写入用例在事务内执行后回滚）：联表计算字段、DxFilter 新列名、权限合并、导入、超级管理员建单写 NULL、厂商必填拦截、在用厂商禁删、删用户时添加人置 NULL。回滚会消耗自增值（wash_sheet / wash_sheet_item 的 ID 会跳号），不影响数据。
未覆盖：没有登录浏览器实测页面（登录需要输入密码，由人工操作）；`GridActions` 的 JSON 错误返回未经 HTTP 实测。
备份表 `sys_dxdatagrid_bak_20260914` 已按用户要求删除（用户已手工备份整库）。
第 7 步经用户确认已执行：`wash_sheet_item_tcd` TEXT → MEDIUMTEXT（库是 STRICT 模式，TCD 摘要分类数不设上限，超 64KB 会写入报错）；epc 只同步注释。改表前后 7 行、最大长度一致。
浏览器验证（用户登录普通账号后逐项检查，未保存任何数据）：用户列表部门/角色列与编辑弹窗多选回填正常；部门页角色列正常（`sys_depa_role` 本就 0 行）；数据单添加人显示正常；明细页厂商列、TCD 列正常，新增不选厂商被前端必填拦截且未发请求；删除在用厂商 OE → `numberVendor/remove` 返回 code=1，界面显示「该号码厂商已被数据单明细使用，不能删除」，OE 未删。全程控制台无报错。
发现的既有问题（与本次无关，已另开任务）：`Users::gridSelect()` 用 `u.*` 把 `admin_user_password` 哈希发到了浏览器，编辑弹窗密码框预填了哈希。（已于同日修复，见下方「修复密码哈希发到浏览器」）
框架契约一致性（已完成）：`mvc/v2/AGENTS.md` 的 Grid 协议补上 save/remove 捕获 `MvcException` 转 code=1 的说明；`mvc/v2/.claude/CLAUDE.md` 按镜像约定由 AGENTS.md 生成，两份只差第 6 行业务契约路径（`.claude` / `.Codex`），NOTES.md 迁入的设计约束与踩坑记录两份都有。
`mvc/v2/.claude/settings.local.json`（已完成，本机文件）：删除早期 `php -S` 8899 测试服务器的一次性 curl / printf 权限条目 82 条（含全部旧字段名），200 → 118 条，旧字段名清零，JSON 校验通过；按用户要求保留含真实凭据的 9 条（admin / test.admin 登录密码、数据库密码）和 `profile/index?passwordExpired=1` 1 条。
既有问题未处理（不在本次范围，**已于 2026-09-16 token 精简会话处理**：见 modules.md「Agent 专属文件边界」与本次会话记录）：项目里不存在 `.Codex` 目录，根目录与 `mvc/v2` 的 AGENTS.md 里指向 `.Codex` 的链接都是断的；根目录 AGENTS.md 缺「第三方代码不可改」一节，与根 CLAUDE.md 不一致。
后续待办（用户安排，改名验证正常后再做）：`mvc/v2/.claude/CLAUDE.md` 与 `AGENTS.md` 一致性（已完成，同上）；`mvc/v2/.claude/settings.local.json` 里的旧字段名（已完成，见上文）。

### 2026-09-14 修复密码哈希发到浏览器

仅业务层，未改框架。原因：`Users` / `Member` 的 `gridSelect()` 用 `u.*` / `m.*`，`users/grid`、`member/grid` 和导出（`GridActions::export` 复用 `Model::grid()`）都带出了 bcrypt 哈希，编辑弹窗密码框预填了哈希。
新增 trait `model/SecretColumns.php`，两个 Model 的 `grid()` 里：先 `guardSecretColumns()` 拒绝 filter / sort 引用密码列（抛 `MvcException`；列不返回但 WHERE 仍能引用，`startswith` 逐字符试探可猜出哈希；识别别名 `u.`、反引号、大小写、`!` 与 and/or 嵌套），再 `stripSecretColumns()` 从结果行里删掉。没改成显式列清单，是为了以后加列不会被静默漏掉。
视图：两个密码列加 `showInColumnChooser/allowFiltering/allowHeaderFiltering/allowSearch/allowSorting/allowExporting: false`，正常操作界面不会发出按密码过滤的请求；编辑器 `mode: 'password'` + `autocomplete: 'new-password'`（防浏览器把已保存的登录密码自动填进去，保存时改掉别人的密码）+ 提示「编辑时留空表示不修改密码」。会员页原来是明文文本框，现在也是密码框。
编辑语义不变：`gridUpdate` 空密码 unset、有值才哈希。语言包扫描新增 2 条（无删除），en 已翻译。
检查结论：资料页 `profile/index` 服务端 `findByUsername()` 取整行，但视图只输出邮箱/手机号，哈希不出服务端；`listAll` / `listAllowed` 是显式三列；`login/resetPassword` 只把用户名传给视图；`WashSheet` 没联 `admin_user`。
冒烟测试 24 项全过（CLI，写入用例在事务内执行后回滚，回滚后哈希与原值一致）：grid / 导出两种 filter 无密码列、7 种按密码过滤排序被拒、正常过滤与 `admin_user_password_update_time` 不误伤、只改备注 / 空密码 / null / 整行回传哈希不变、填新密码重新哈希可验证、新增哈希。会员表当前 0 行，只验证了拦截没验证行数据。
未覆盖：浏览器实测（需人工登录）——用户页编辑弹窗密码框应为空、Network 里 `users/grid` 无 `admin_user_password`、编辑姓名保存后仍能用原密码登录。按密码过滤的伪造请求走全局异常处理（`GridActions::grid` 不捕获 `MvcException`），界面上已无入口，不再处理。

### 2026-09-07 wash_sheet_item 增加 wash_sheet_item_format 字段

号码格式化值，`formatNum()` 生成，不在界面显示，新增/修改/批量导入三条写入路径都同步维护，`formatNum()` 有了第一个业务调用点。

### 2026-09-16 零件库 S2 — 分类管理 + 参数管理 + 参数分页

只改 `pdc/`，没动框架；表已在建表阶段（S0 之前的初始迁移）全部建好，本次没有 DDL。

需求来自用户三点说明：1）「零件库」菜单下加「分类」菜单，功能参考外部系统 `http://192.168.1.44/catalog_frey/pdc/admin/`「商品库 → 小类」（用户登录后现场看的，只读学习，没有写数据）；2）参数管理在分类的末级节点编辑 UI 里做；3）零件详情页按所属分类动态生成参数表单（文本/数字/单选/多选/枚举对应不同控件）。

参考站现场看到的关键信息：`小类` 是 dxTreeList 风格的树表格（中/英/俄/西/阿等多语言名称列 + 重置/新增/修改/删除/导出/批量更新/填充零件库经理工具条）；点「修改」打开的编辑弹窗里有独立的「参数管理」面板（+新增/×删除，列出已挂参数的中英文名）；面板的「+新增」实际是**新建一个全局参数定义并直接挂到当前分类**（字段：中/英/俄/西/阿名称、参数类型下拉只有「文本/带小数位数字/数字」三种、单位、长度、顺序、TCD 参数名，多个用分号隔开）。我们自己的 `part_param_type` 比参考站更全（多了 select/multiselect），照用户的第 3 点需求做，没有照抄参考站的三选项。

Model（`pdc/model/`）：
- `PartCategory` 扩充 `tree()`（管理页用，全量平铺含停用）+ `gridInsert/gridUpdate/gridRemove`：新增/修改支持「换父节点」（`part_category_parent_id` 变化时先查 `selfAndDescendantIds()` 判环，再重算自己和全部下级的 `part_category_level`，同步新旧父节点的 `part_category_is_leaf`）；删除前检查没有子节点、没有零件挂在这个分类上，给友好错误而不是等数据库外键报错；`part_category_is_leaf` / `_level` 不接受前端提交。原有 `treeOptions()` / `selfAndDescendantIds()` / `isUsableLeaf()`（S1 就有，供零件页用）不变。
- `PartCategoryLang`：按语言出行的翻译表格，模式照抄 `PartItemLang`（S1），只是没有 `assertEditable` 这层（分类不是审核实体）。
- `PartParam`：全局参数定义，`createDefinition()` 新增时编码自动生成 `p{自增ID}`（先插一个临时唯一值占位、拿到 ID 后再回写，界面不暴露编码字段）；`availableForCategory()` 给「选择已有参数」下拉用（排除已挂在该分类上的）。
- `PartParamOption`：参数选项（select/multiselect 用），`scopeToParam()` 限定范围，`optionsByParam()` 给动态表单用。
- `PartCategoryParam`：分类 ↔ 参数的挂载关系，`scopeToCategory()` 限定范围；`gridInsert()` 直接禁用（抛异常提示走 `attachExisting()` / `attachNew()`），`gridUpdate()` 只能改排序/必填/列表显示/详情显示这几个挂载属性；`attachNew()` 在一个事务里调 `PartParam::createDefinition()` 建参数再插挂载行，`attachExisting()` 挂一个已有参数（重复挂载报错）。
- `PartParamValue`：`applicableParams($itemId)` 按零件所属分类 JOIN `part_category_param`/`part_param`（`show_in_detail=1`）取参数定义 + 当前值；`saveValues($itemId, $values)` 整份覆盖式保存——**遍历全部适用参数而不是只处理提交里出现的 key**，因为最初实现「没传就跳过」导致必填校验能被绕过（前端整个不传某个字段就跳过了必填检查），CLI 自测抓出来后改成不管提交里有没有这个 key 都按空值处理再校验。

关键决定：
- **事务不能嵌套**：`PartParam::createDefinition()` / `PartParamOption::insertRow()` 都不自己开事务（不像 `PartItem::gridInsert()` 那样是最外层入口），因为它们会被 `PartCategoryParam::attachNew()` 包在一个事务里调用，Medoo 的 `action()` 不支持嵌套；这条已经在 `PartItemInput` trait 的文档里写过，这次是新增两个内部辅助方法要遵守。
- 参数编码 `part_param_code` 用户不填，程序自动生成；分类编码 `part_category_code` 反过来必须用户填、程序只保证唯一——因为分类是业务上有意义的编号（历史上从 ypdb 迁移过来的分类也带编码），参数是纯内部标识，用户不关心。
- 参数值表按类型分列存（`value_int` / `value_decimal` / `value_text` / 选项外键），不用一个万能字符串列，保持强类型和可比较（虽然本期没有用参数做查询条件）。
- 参数示意图、参考号查参数、公共参数勾选、附件、外观检测联动这几个参考站有的功能本期没做——不在用户这次提的三点范围内，我们的 `part_param` 表结构本身也没有这些字段（当初设计 `part_library_v1.sql` 时就没纳入）。

Action（`pdc/admin/action/`）：`partCategory`（index/tree + GridActions 的 save/remove/export）、`partCategoryLang`（GridActions，`categoryId` 限定范围）、`partCategoryParam`（GridActions + available/attachExisting/attachNew 三个自定义端点）、`partParamOption`（GridActions，`paramId` 限定范围，本次没接界面，留给以后编辑已有参数用）、`partItemParam`（list/save，不是 Grid 协议，是「查询该零件的参数表单 + 保存」）。菜单 `config/admin.php` 「零件库」组下加「分类」。

View / 前端：`view/partCategory/index.view.php` + `static/css/partCategory.css`（新文件，dxTreeList 主表格 + 自定义 dxPopup 编辑弹窗，弹窗里嵌基本信息 dxForm + 语言翻译 mini-grid（`createDxGrid`）+ 参数管理 mini-grid，新建参数走单独小弹窗，参数类型是 select/multiselect 时显示一个简单的选项编辑器（jQuery 拼 DOM，加一行/删一行））；`partItem/detail.view.php` 加「参数」分页（`renderParamTab`，按 `partItemParam/list` 返回的定义动态生成 `dxForm.items`，类型→控件的映射：int/decimal → dxNumberBox、text → 普通文本框、longtext → dxTextArea、select → dxSelectBox、multiselect → dxTagBox）。

自测：
- CLI 51 项（脚本在会话临时目录）：分类新增/改名/换父节点（层级重算、末级标记同步）、编码查重、成环拦截、有子节点/有零件时删除拦截；`attachNew`/`attachExisting`（含重复挂载拦截）、`available` 排除已挂参数、选项创建；分类翻译 upsert/清空删除/非法语言码拦截（库里当时没有启用的第三语言，临时插了一行测完删除）；零件参数值：新建零件 + 6 个参数（文本/整数/小数/单选/多选/独立参数走已有挂载）、初始值全空、保存后取值正确、小数按位数四舍五入、int/select 非法值拦截、必填校验（含「没传这个 key 也要拦」那条，是改出来的 bug）、清空非必填参数会删值行、零件提交审核后参数分页被 `assertEditable()` 拦住。全部通过，测试数据先建后删（分类删除级联挂载关系，零件删除级联参数值，独立参数最后单独删），复查各表行数与测试前一致。
- HTTP 45 项左右（`php -S` + 临时 router 伪造登录，办法同 S0/S1；这次踩了一次框架契约里记录过的坑——Git Bash 下 `curl -d` 直接传中文会被截断乱码导致 `json_decode` 失败、`save()` 误判成空新增，改用 `--data-binary @文件` 后正常）：未登录返回 `code=401`（HTTP 状态仍是 200，JSON 里的 code 才是 401，这是本项目的约定）、分类页面与零件详情页正常渲染（详情页 HTML 里能看到「参数」tab 文案）、分类新增/删除往返、分类翻译/参数挂载 grid 端点、`attachNew` 带嵌套 JSON（`param` 对象 + `options` 数组）正常解析入库、零件参数 list/save 往返。测试数据全部清理，复查 0 残留。
- 语言包：CLI 调 `LangScanner::run()`，`zh-CN.php` / `en.php` 各新增 75 条、删除 0 条；`en.php` 写脚本按翻译表批量替换新增 key 的值（不是留中文占位），跑完核对：0 处误改已有翻译、75 个新 key 全部有对应英文。

契约：`contracts/API.md` 新增「零件库接口清单（S2）」；`contracts/DB.md` 新增「分类树维护规则」小节（`is_leaf`/`level` 程序维护、换父节点校验、参数值保存的必填校验坑）。

未做 / 已知限制：浏览器实测需人工登录（本会话只做到 CLI + HTTP，没有真机点击）；分类树没有拖拽排序/批量更新/填充经理这几个参考站有但用户没提的功能；分类管理页没有导出按钮；`part_param_lang` / `part_param_option_lang`（参数和选项的第三语言翻译表，S0 之前就建好了）本次没接 Model/Action/界面，只有 `part_category_lang` 接了；参数「详情显示」以外的「列表显示」字段（`part_category_param_show_in_list`）建了但零件列表页没有用到，留给以后要在列表加参数列时用；`part_param_option` 的编辑入口（改名/启用停用/排序，不创建新的）本次只有 Model+Action，没有界面，创建时一次性给够就够用，后续要改要么重新创建要么以后单独做界面。

### 2026-09-16 零件库 S3 — 图片文件分页 + zip 批量图片

只改 `pdc/`，没动框架；表 `part_file` / `part_file_tag` 在最早的建表阶段就已建好，本次没有 DDL。框架层 S0 已经把分片上传、zip 安全解压、批处理任务这套通用能力做完，本会话只是第一次真正接上业务：`file.php` 的 `taskProcessors()` 里注册的 `check` 是自测占位，`partImage` 是第一个真正投入使用的批处理类型。

需求来自 `modules.md` 早先拍板的一句话：「zip 批量图片命名用目录式：号码厂商/号码/序号[_标签].扩展名，号码按 `formatNum()` 匹配，序号 1 为主图，标签按 image_tag 字典中文名或编码匹配；同一文件已挂在该零件上时跳过；一个号码匹配到多个零件时跳过并在结果里列出；零件已有图片时追加」。本会话把这句话落成代码，两块功能：详情页「文件」分页（单个零件自己管理文件）+ 列表页「批量导入图片」（zip 一次导入多个零件的图片，按目录名全库匹配）。

Model（`pdc/model/`）：
- `PartFile`：对应 `part_file`，`scopeToItem()` 限定范围，模式照抄 `PartNumber`（S1）。规则：每个零件每种类型（image/drawing/attachment）最多一个主文件，第一个文件自动成为主文件，设新主文件自动取消旧的，**不能直接取消主文件**（跟号码一样）；但主文件**可以直接删除**——这里跟号码的规则不一样，号码是「不能删主号码，先转移」，文件是「删了就没有主文件，不强制先转移」，因为文件是辅助资料，用户经常就是想删掉主图重新传，不需要多一道转移的手续。删除只删 `part_file` 关联行，不删 `sys_file` 本身（可能被去重复用）。
  只有 `image` 类型能挂标签（`part_file_tag`）。`gridInsert()` 把一个已经上传好的 `sys_file` 挂到当前零件，`gridUpdate()` 能改类型/备注/数据来源/标签/主文件，`gridRemove()` 解除关联。
  另一个公开方法 `attachFromZip(vendorName, number, wantsMain, tagLabel, meta, uploader, adminUserId)`——zip 批量导入专用，**不抛 `MvcException` 表达业务上的跳过**（厂商不存在、号码没匹配到零件、号码匹配到多个零件、零件不可编辑、文件已存在），直接返回 `['status'=>'skipped','message'=>...]`，因为 `MvcException` 会被 `ZipTask::processEntry()` 统一归类成 `failed`，而这些情况在这次拍板里都要算「跳过」不算「失败」。这条规则也写进了 `contracts/DB.md`「零件文件规则」。
  这个方法自己开事务（Medoo `action()`），因为它是 `file.php` 处理器调用链里最外层的写操作，不是被其它 Model 包在事务里调用——跟 `PartCategoryParam::attachNew()` 反过来（那个是被 Action 直接调用、自己开事务；`PartParam::createDefinition()` 是被 `attachNew()` 包着调用、不开事务）。这次踩坑提醒自己：判断一个写方法该不该开事务，看的是「调用链里它是不是最外层」，不是看方法本身「像不像一个独立功能」。
- `PartFileTag`：文件 ↔ image_tag 字典多选，模式照抄 `PartNumberBrand`（S1）。
- `PartItem` 新增两个方法：`syncMainImage($itemId)`（`part_item_lnk_sys_file_id` 主图缓存与 `part_file` 的主文件同步，`part_file` 增删改后调用，逻辑和已有的 `syncMainNumber()` 一样）；`isEditable($itemId)`（和 `assertEditable()` 判断条件一样，但不抛异常，返回 bool——zip 批量导入这种「不可编辑就跳过而不是报错中断」的场景要用它，不能用会抛异常的那个）。
- `AttrValue` 新增 `findIdInTypeByNameOrCode($typeCode, $needle)`：按中文名/英文名/编码（不区分大小写、去空白）在某个字典类型下找一条启用的取值。**踩坑**：一开始写成复用 `optionsByTypeCode()` 的返回值去匹配，但那个方法返回的 `attr_value_name` 是按当前会话语言算出来的显示名（中文会话下永远是中文名），用它反查在非中文会话下会匹配不到英文标签、或者更糟匹配错行；改成直接查 `attr_value_cn_name` / `attr_value_en_name` / `attr_value_code` 三列，不管当前语言是什么。

Action（`pdc/admin/action/`）：`partFile`（GridActions，`itemId` 限定范围）；`file.php` 新增 `partImageEntry()` 处理器方法并在 `taskProcessors()` 注册 `partImage` 键——解析 zip 条目路径（`厂商/号码/序号[_标签].扩展名`，必须正好 3 段，文件名必须以数字开头）后调 `PartFile::attachFromZip()`，返回值的 status 直接透传（`ok`→`ZipTask::STATUS_OK`，其它一律 `STATUS_SKIPPED`，因为业务层已经区分好了，处理器自己不产生新的失败分类）。

View / 前端：
- `partItem/detail.view.php` 加「文件」分页：`renderFileTab()`，上半部分是分片上传控件（`createChunkUploader`，url 是 `file/chunk`，多选），上传完成的回调调 `partFile/save` 把 `sys_file_id` 挂到零件上；下半部分是 `createDxGrid` 的文件表格（缩略图/类型下拉/主文件勾选框/标签多选 dxTagBox/数据来源/备注），行内编辑，改类型或主文件后 `onRowUpdated` 里整表 `refresh()`（不然被顶替主文件的那一行的勾选框不会自动变回未勾选）。
- `partItem/index.view.php` 加工具条按钮「批量导入图片」：弹窗里一个 `createChunkUploader`（单文件，只认 `.zip`，url 是 `file/zipChunk`，`params:{kind:'partImage'}`），上传完成后关掉弹窗、调框架现成的 `runZipTask()` 显示进度条和跳过/失败明细（这部分完全复用 S0 做好的通用组件，业务层一行代码没多写）。
- 踩坑（本会话唯一的代码错误）：`createChunkUploader($container, options)` 的 `$container`参数要求已经是 jQuery 对象（内部直接 `$container.dxFileUploader(...)`），不能传选择器字符串——这跟 `createDxGrid()` 的调用习惯不一样（`createDxGrid` 接受字符串选择器，内部自己包一层）。一开始在详情页文件分页写成 `createChunkUploader('#partFileUploader', {...})`，语法检查和 CLI 自测都测不出这个问题（纯前端运行时错误），是写代码时对照 `chunkUpload.js` 的实现读了一遍才发现并改成 `createChunkUploader($uploaderArea, {...})`；列表页那处一开始就写对了（直接传的是 `$('<div>')` 变量）。这条以后写类似封装函数调用时要留意参数类型，光看命名里带 `$` 不代表函数一定会帮你转换。

自测：
- CLI 36 项（脚本在会话临时目录，用真实 1x1 PNG 字节喂给真实的 `FileUploader::inspect()` + `SysFile::saveUpload()`，不是 mock）：分类/零件建测试数据 → 第一张图自动主图、第二张不自动、重复内容拒绝挂载、标签按中英文/编码/去空白匹配、不存在的标签返回 null、设新主图自动取消旧主图并同步 `part_item` 缓存、删除主文件后缓存清空但 `sys_file` 记录不删、非法类型拒绝、零件提交审核后增删改被拦（`isEditable()` 前后状态验证）；`attachFromZip()` 覆盖厂商不存在/号码不匹配/号码匹配到多个零件（并验证 data 里列出两个零件 ID）/正常挂载且序号 1 设主图且标签生效/重复内容再次导入跳过/标签匹配不到仍成功导入并在消息里提示/零件不可编辑时跳过。全部通过，测试数据（分类、零件、`sys_file` 记录与存储文件）全部按依赖顺序清理并核对归零。
- HTTP 端到端（真实链路，`php -S` + 临时 router 伪造登录，办法同 S0-S2）：用 PHP `ZipArchive` 现造一个真实 zip（5 个条目：2 个合规、1 个路径层级不对、1 个厂商不存在、1 个号码不匹配），走真实的 `curl -F` multipart 单分片上传到 `file/zipChunk`（`chunkMetadata` 带真实 UUID 格式的 `FileGuid`），拿到 `taskId` 后调 `file/taskNext` 真实解压处理，结果 `ok:2 / skipped:3 / failed:0`，跟预期完全一致；再查数据库确认：序号 1 的文件是主文件、`part_item_lnk_sys_file_id` 缓存指向它、标签行已写入 `part_file_tag`；`partFile/grid` 端点返回的行数据（含 `sys_file_url` / `part_file_tag_ids`）跟前端预期的形状一致；`partItem/detail` 和 `partItem/index` 页面 HTML 里能看到新增的「文件」tab 文案和「批量导入图片」按钮文案，确认没有 PHP 渲染报错。测试用的 zip 分片临时目录、`sys_file` 记录、存储文件、测试分类/零件全部清理，复查归零。
- 语言包：`LangScanner::run()` 扫描出 `zh-CN.php` / `en.php` 各新增 17 条、**删除 18 条**——这 18 条不是本会话产生的字符串，是这次会话开始前就已经在语言包里、但当前代码里已经没有任何 `lg()` 调用引用的孤儿 key（比如「主文件不能删除，请先把其它文件设为主文件」「未找到号码厂商: %s」这类，措辞和本会话实际写的都不一样），说明更早以前有一次没有留下记录的 S3 尝试，后来被放弃了，语言包里的残留一直没人清理，这次扫描顺手清掉了，属于 `LangScanner` 的正常职责（删除代码里不再引用的 key），不是本会话改坏了什么。新增的 17 条已手工写英文翻译并核对：0 处误改已有翻译、17 个新 key 全部有对应英文、`zh-CN.php` 和 `en.php` key 数量一致（425）。

契约：`contracts/API.md` 新增「零件库接口清单（S3）」，含 zip 批量图片的命名约定单独说明；`contracts/DB.md` 新增「零件文件规则」小节（主文件互斥但可直接删除、只删关联不删 `sys_file`、批量导入的 skip/fail 分类原则）。

未做 / 已知限制：浏览器实测需人工登录（本会话只做到 CLI + 真实 HTTP 链路，没有真机点击上传控件）；文件没有排序拖拽（`part_file_sort` 建了字段但界面不能改，新文件一律 0）；`drawing` / `attachment` 类型目前只能通过「文件」分页手动上传后改类型，没有各自专属的批量导入（zip 批量目前只做 `image`）；zip 批量导入的「厂商」目前必须完全匹配已登记的号码厂商，不会像数据单导入那样自动创建；已发现但不属于本次范围的历史遗留——语言包里那 18 条孤儿 key 说明之前可能有一次未留档的 S3 尝试，如果之后翻出对应的代码变更记录（比如 SVN 历史里有相关 revision），可以核对一下当时的设计思路是否有值得参考的地方。

### 2026-09-16 零件库 S4 — 适用车型

只改 `pdc/`，没动框架；表 `car_library` / `car_library_level` / `car_node` / `car_vehicle` / `car_brand` / `car_brand_node` / `part_vehicle` 在最早的建表 + 数据迁移阶段就已建好并导入了真实数据（车型 24.5 万+、节点 3 万+），本次没有 DDL，也没有再造任何测试用的车型/节点数据——所有自测都是在真实参考数据上查询、只有 `part_vehicle`（零件↔车型的关联行）和测试用的分类/零件是本会话建的、也只清理这些。

先查了库里的真实结构定下方案：`car_library` 2 条（`intl` 国际 3 级：车厂→车系→车型；`cn` 中国 5 级：品牌→生产商→车系→车代→车型），层级定义在 `car_library_level`（`depth` + `is_vehicle` 标记哪一层是叶子）。车型本身（`car_vehicle`）不在 `car_node` 树里，是挂在「车型上一层」节点下的独立表——所以逐级展开到倒数第二层节点后，要切换成查 `car_vehicle` 而不是继续查 `car_node` 的子节点，这是这次设计的核心分岔点。`car_brand_node` 抽查发现品牌只映射到每个库的**顶层**节点（国际车厂层、中国品牌层），不是任意层级，这条记下来但本次没用上（`part_item_brand` 没做）。

Model（`pdc/model/`）：
- `CarLibrary`：`listEnabled()` 返回启用的车型库 + 各自 `levels()`（按 `depth` 排列，含 `is_vehicle`）。车型库/层级是软件提供商下发的结构性参考数据，改动极少，显示名用 `Lang::pickField(['cn','en'])` 简单二选一，没有像业务数据那样接完整的 `_lang` 翻译表联表——这个简化写进了 `contracts/DB.md`。
- `CarNode`：`childrenOf($libraryId, $parentId)`（顶层传 `null`）、`nameById()`（给「适用车型」表格拼所属节点名用）。
- `CarVehicle`：`scopeToNode()` + 标准 Grid 协议（车型可能很多，走分页不一次性全量），`grid()` 覆盖后附加 `car_vehicle_year_range`（起止年月拼的展示字符串，纯展示不放进 `gridComputedColumns`，因为不需要按它过滤排序，真实的 `year_from`/`year_to` 列已经是可查询的）；`search($libraryId, $keyword)` 按名称 `LIKE` 搜索、条数封顶 200——车型数据量大但基本不变（靠车型库同步更新，不是用户日常编辑），没有为此建全文索引，值不了维护成本。
- `PartVehicle`：`scopeToItem()`，`gridInsert()` 直接拒绝（提示走批量），`gridUpdate()` 只能改备注/数据来源，`gridRemove()` 单条删除；核心方法 `attachMany($libraryId, $vehicleIds, $remark, $dataSourceId)`——批量把勾选的车型挂到零件上，事先校验车型都存在于指定车型库，已经挂过的（零件+车型唯一键冲突的那些）直接跳过计入 `skipped`、不报错，跟号码「重复就抛异常」的规则不一样，因为车型选择场景下用户经常会勾选到已经加过的车型（比如换了个车系重新搜索），跳过比报错更符合直觉。

Action（`pdc/admin/action/`）：`carLibrary`（`list`）、`carNode`（`children`）、`carVehicle`（GridActions + `search`）、`partVehicle`（GridActions + `attach`）。

View / 前端：`partItem/detail.view.php` 加「适用车型」分页（`renderVehicleTab`）：
- 车型库用 `dxTabPanel` 切换（国际/中国），切换时重建下面的选择器
- 逐级下拉：按当前库的 `levels`（排除 `is_vehicle` 的那一级）动态生成对应数量的 `dxSelectBox`，选中上一级会清空并禁用后面的级联；选到「下一级是车型叶子层」的那一级时，不再继续加下拉，改成把候选车型表格的 `dataSource` 切换成 `carVehicle/grid` 按该节点查出来的车型
- 关键字搜索作为逐级下拉的替代路径，直接调 `carVehicle/search` 把结果塞进同一个候选车型表格（多了一列「所属」显示节点名，逐级浏览来的结果不需要这列，因为已经知道在哪个节点下）
- 候选车型表格是 `dxDataGrid` 多选（`showCheckBoxesMode: always`），勾选后填一次备注/数据来源、点「添加所选车型」一次性提交给 `partVehicle/attach`
- 已添加的适用车型用 `createDxGrid` 走标准 Grid 协议展示（车型库/所属节点/车型名/数据来源/备注可编辑，其余只读），跟其它分页风格一致

自测：
- CLI 29 项（在真实车型数据上跑，不造假数据）：`CarLibrary::listEnabled()` 返回 2 个库、层级数分别是 3 和 5、末级都标了 `is_vehicle`；从真实的国际车厂节点开始，代码自己在前 30 个车厂里找一个「车系下确实挂着车型」的真实节点（数据不是均匀分布，不能假设随便一个车系都有车型），验证 `CarVehicle::scopeToNode()->grid()` 能查到、`search()` 能按名称片段搜到同一辆车；未设置 `scopeToNode()` 时 `grid()` 必须查不到任何行（防止退化成查全表）。`PartVehicle`：拒绝 `gridInsert`、拒绝空批量、`attachMany` 正常新增、重复提交全部 `skipped`、传错车型库 ID（车型不属于该库）被拒、改备注、删除单条、删除后计数减一、零件提交审核后 `attachMany` 被拦。测试用的分类/零件建了又删，真实车型/节点数据完全没有改动。
- HTTP 端到端（`php -S` + 临时 router，办法同 S0-S3）：先写小脚本在真实数据里现找一条「车厂→车系→车型」的完整真实链路（不能瞎猜 ID，之前 S4 CLI 测试已经验证过这个「不是所有节点都有车型」的坑，这次直接复用同样的查找逻辑），然后真实调 `carNode/children` 验证级联展开、`carVehicle/grid` 验证节点下取车型、`carVehicle/search` 验证关键字搜索、`partVehicle/attach`→`grid`→`save`→`remove` 走完整的增改删循环，最后确认 `partItem/detail` 页面 HTML 里能看到「适用车型」tab 文案。测试数据清理，复查归零。
- 语言包：新增 22 条，0 删除（这次没有遇到 S3 那种孤儿 key），全部译完并核对无误改。

契约：`contracts/API.md` 新增「零件库接口清单（S4）」；`contracts/DB.md` 新增「车型库结构与适用车型规则」小节，写清楚车型叶子层数据在 `car_vehicle` 不在 `car_node`、`car_brand_node` 只映射到顶层节点这两条容易踩的坑，供以后做 `part_item_brand` 时参考。

未做 / 已知限制：浏览器实测需人工登录；`part_vehicle_criteria`（JSON，适用条件如 TecDoc 的 Quantity required）表已建但界面没有编辑入口；**`part_item_summary`（零件车型汇总：品牌/车型名/底盘/发动机/排量/年款）和 `part_item_brand`（零件↔品牌派生表）本次完全没做**——这两张派生表在方案阶段就规划好了，但迁移时就发现品牌映射覆盖率不完整（917 个国际车厂、181 个中国品牌没映射到 `car_brand`，43 个节点映射到多个品牌），`part_item_brand` 的口径（查不到品牌 / 查到多个品牌时界面怎么表现）需要人工先拍板，本次没有擅自决定；零件列表页现有的「汽车品牌」列仍然是 S1 做的 `part_number_brand`（号码上标的品牌），跟这次「按车型推导品牌」是两个不同来源，要不要统一由用户决定，本次没有动那一列。

**同一会话内的追加（用户拍板）**：用户看完总结后明确「`part_item_brand` 其实不需要，只要零件与车型的绑定即可，通过车型可以追溯零件与汽车品牌的关系」——也就是不需要缓存表，现查现算就够。追加实现：
- `CarNode::topAncestorId($nodeId)`：沿 `car_node_parent_id` 一路往上走到没有上级为止，返回顶层节点 ID（`car_brand_node` 只映射到顶层节点，之前 S4 探查已经确认过这一点）。
- `CarBrand::byTopNodeIds($topNodeIds)`：给一批顶层节点 ID，反查 `car_brand_node` 映射到的品牌（去重，按当前语言显示名）。
- `PartVehicle::brandsByItem($itemId)`：零件的 `part_vehicle` → 各自车型的 `car_vehicle_lnk_car_node_id` → 逐个转顶层节点 → 查品牌，去重返回；不写任何缓存，每次调用都是即时查询。
- Action `partVehicle/brands { itemId }`；前端在「适用车型」分页顶部加一行只读的「适用品牌」标签展示（`refreshBrands()`，在批量添加、删除车型之后都会重新拉取）。
- 用真实数据验证：CLI 直接查库确认「宝马 (进口)」是顶层节点、`car_brand_node` 映射到 `car_brand_id=1`（宝马），给测试零件挂上该节点下的一辆真实车型后 `brandsByItem()` 正确返回「宝马」；HTTP 复测同一条链路（挂车型前品牌列表为空、挂车型后出现「宝马」），结果一致。测试数据建了就删。
- 语言包新增 2 条（「适用品牌」「（按车型自动推算，暂无）」）已译。
- `contracts/DB.md` 改写「车型库结构与适用车型规则」里 `part_item_brand` 相关的段落，去掉「需要人工先拍板」的表述，改成记录已确定的现查现算方案；`part_item_summary` 维持未做，跟品牌无关、不受这次调整影响。
- 写了 `sql/migrate_drop_part_item_brand.sql` + 回滚脚本（表当前 0 行、没有任何代码引用过，删除安全），**待人工审核执行**——本次没有执行 DDL，只是按项目规矩把 SQL 准备好。

### 2026-09-16 零件库 S5 — 关联 / 区域 / 安装位置 / 来源

只改 `pdc/`，没动框架；四张表（`part_relation`、`part_item_region`、`part_item_position`、`part_source`）都在最早的建表阶段就已建好，本次没有 DDL。

开工前跟用户确认了两个业务口子（其它设计判断按已有惯例自行决定，没有逐条确认）：
1. 「关联」新增时零件 B 怎么指定——用户选「只做选已有零件」，不做 `part_relation_lnk_part_item_id_b` 为空的「填厂商+号码留作待匹配」那种用法，减少本期工作量。
2. 「来源」分页做到什么程度——用户选「提供最基本的手工编辑」，即只开放 `part_source_check_from` 手工设置，其它清洗快照字段（确认来源产品 ID / 确认产品信息 / EPC 信息 / TCD 原始参数）留给以后的数据单导入零件（S6）自动写入，S5 不提供编辑。

Model（`pdc/model/`）：
- `PartRelation`：`scopeToItem()` 后 `grid()` 按 `a` 或 `b` 等于当前零件取行（同一行会出现在两个零件各自的「关联」分页里）；计算列 `gridComputedColumns()` 用 `IF(a=当前零件, ..., ...)` 现算「对方零件的 id / 名称 / 主号码」和（仅 assembly 类型）「当前零件在这条关系里是父件还是子件」——这条表达式把 `scopeItemId`（代码里 `max(0,int)` 过的整数）直接拼成字面量，不是拼用户输入，符合 `gridComputedColumns()`「表达式由代码写死」的要求。
  写入只有 `attachOne()` 一个入口（`gridInsert()` 直接拒绝，提示走它）：**link/pair 两端地位对等，按数值大小把两个零件 ID 规范成 `a`=较小、`b`=较大**，因为唯一键是 `type+a+b`、不做这层规范化的话同一对零件反过来建一次就会多出一行「重复」的关联；**assembly 有方向语义（`a`=父件、`b`=子件）**，按用户传的 `role` 写，不做数值规范化。`gridUpdate()` 只能改配对类型/数据来源（改类型或换零件走删了重建）；`pair_type` 只有 `type=pair` 才允许非空，直接改类型不允许。
- `PartItemPosition` / `PartItemRegion`：结构几乎一样（都是「零件 ↔ 字典/区域」的全量替换关联表，没有单条 `gridInsert`/`gridUpdate`），分开成两个文件是因为一个引用 `attr_value`、一个引用 `sys_region`，校验逻辑不同。`replaceForItem()` 先删旧值再按新值批量插入，全程在一个事务里、外层 `assertEditable()`。
  `PartItemPosition::replaceForItem()` 多一层分类限定检查：查 `part_category_position` 有没有为该零件的分类配置限定行，**有配置才按限定校验，没有配置按不限**——这张表目前没有任何管理界面、也没有任何数据（所有分类都还没配置过），所以现状下这层检查恒为「不限」，只是把设计好的口子先接上；CLI 自测里临时插了一行 `part_category_position` 验证限定生效、测完删除。
- `PartItemRegion::replaceForItem()` 直接校验 ID 是否都是启用的 `sys_region` 行（区域本身和国家都能选，FK 不区分）。
- `PartSource`：`infoByItem()` 没有记录返回 `null`（不是所有零件都会有这一行）；`setCheckFrom()` 只管 `part_source_check_from` 这一列——设为空且当前没有记录时什么都不做（不会为了「清空」而新建一行全 NULL 的记录），有记录时清空是更新这一列而不是删行（历史上如果以后 S6 写入过清洗快照，清空确认来源不应该把那些数据也删掉）。
- 新增 `SysRegion`（之前没有这个 Model）：`pickerOptions()` 返回全部启用的区域/国家 + 国家所属的（第一个）区域 ID，供前端把国家按所属区域分组展示；一个国家理论上可能属于多个区域（`sys_region_country` 的唯一键是「区域+国家」不是「国家」），这里只取第一个用于分组展示，不影响国家本身能不能被选中。
- `AttrValue::optionsByTypeCode()` 顺手加了 `attr_value_parent_id` 字段（原来没有）——`part_position` 是二层树（5 个维度 + 13 个取值），前端「安装位置」分页要按维度分组展示，需要知道每个取值的上级是谁；这个字段对已有调用点（`unit`/`develop_status`/`data_source`/`image_tag`，都是平铺字典）没有影响，只是多返回了一个恒为 `null` 的字段。

Action（`pdc/admin/action/`）：`partItemPosition`（list/save，不是 Grid 协议，多选打钩整份提交）、`partItemRegion`（同上）、`partSource`（info/save，1:1 单资源，不是 Grid 协议也不是「查询定义+保存」的 list/save 模式）、`partRelation`（GridActions 的 grid/save/remove + 自定义 `attach`）。`partItem.php` 的 `detail()` 多传四份 bootstrap 数据：`positions`（`part_position` 全部取值，含 parent_id）、`allowedPositionIds`（当前零件所属分类被限定的安装位置，空数组=不限）、`regions`（`SysRegion::pickerOptions()`）、`pairTypes`（`pair_type` 字典）。

View / 前端：`partItem/detail.view.php` 加四个分页：
- 「关联」：`createDxGrid` 展示已有关系（类型/角色/对方零件/对方主号码/配对类型/数据来源可编辑），点「对方零件」跳去它的详情页（`openPartItemDetail`，复用 S1 就有的通用函数）；「新增关联」弹窗里选类型（link/pair/assembly）→ 装配类型才出现「本零件角色」下拉 → 搜索框 + 结果表格（直接调既有的 `partItem/grid` 传 `keyword`，没有为「搜索对方零件」单独建接口，全文检索本来就覆盖号码和名称）单选一个零件 → 配对类型（仅 pair 类型出现）/ 数据来源都可选，确定后调 `partRelation/attach`。
- 「区域」「安装位置」：都是同一个新写的通用小函数 `groupedTagBoxOptions(items)`（加进 `partItem.js`，`items` 形状是 `{id,name,groupName}`）生成一个按分组展示的 `dxTagBox` 多选配置，两个分页各自把自己的数据映射成这个形状后复用；「安装位置」按维度名分组，「区域」按所属区域名分组（区域本身也作为一个可选项，跟它下面的国家分在同一组里，比如「亚洲」这一项和「中国」「日本」同组）。都是「整页一个多选控件 + 保存按钮」，不是逐行的表格。
- 「来源」：一个 `确认来源` 下拉（空/TCD/EPC/自行确认）+ 保存按钮，下面是只读区块——有记录才显示，展示数据单明细 ID、确认来源产品 ID（有值才显示这两行）、确认产品信息/EPC信息/TCD原始参数三个 JSON 字段只显示「有/无」（S6 接入前不会有真实数据，做详细的 JSON 展示这次判断没有意义，等真的有数据再回来做）。

自测：
- CLI 33 项（脚本在会话临时目录，建两个测试零件 A/B 后跑）：安装位置全量替换（写入/收窄/清空）+ 拒绝不存在 ID；区域同样的全量替换三态 + 拒绝不存在 ID；来源初始为空、设置/修改/清空、拒绝非法枚举值；关联：拒绝自关联、拒绝对方零件不存在、装配类型缺角色拒绝、非 pair 类型带配对类型拒绝、新增 link/pair/assembly 成功、重复的 link 关联被拒绝、**反过来建同一对零件的关联也被拒绝**（验证数值规范化生效）、A/B 两个视角分别看关联都能看到对方、装配关系里 A 是 parent 时 B 看到自己是 child、删除关联后对方视角也一起消失、零件提交审核后不能再新增关联；额外补了一段：临时插一行 `part_category_position` 限定该分类只能选一个安装位置，验证选超出范围的值被拒绝、范围内的值正常保存，测完删除限定行。全部通过，测试零件删除后级联清空所有子表，复查五张新表（`part_item_position`/`part_item_region`/`part_source`/`part_relation`/`part_category_position`）行数都回到 0。
- HTTP 端到端（`php -S` + 临时 router 伪造登录，办法同 S0-S4；这次又踩了一次契约里记录过的坑——Git Bash 内联 `curl -d` 带 `$RANDOM` 变量和中文的复合 JSON 被 shell 处理坏了导致新增零件报「请选择末级分类」，换成 `--data-binary @文件` 后一切正常，不是代码问题）：未登录 `code=401`；新增两个测试零件；安装位置/区域的 list/save 往返；来源 info/save 往返（返回的 JSON 字段形状和 CLI 里看到的一致）；关联 attach 一条 link 关系后，分别用 A、B 两个 `itemId` 调 `partRelation/grid`，确认双方视角的 `part_relation_other_item_*` 计算列正确对调；`partRelation/remove` 删除；`partItem/detail` 页面完整渲染，grep 排查 HTML 输出确认没有 PHP `Warning`/`Notice`/`Fatal error`/`Deprecated`，HTTP 状态 200；两个测试零件删除后复查所有新表行数回到 0。
- 语言包：`LangScanner::run()` 扫描新增 44 条、删除 0 条；写脚本按翻译表批量替换 42 条新增 key 的英文值（另外 2 条 `EPC`/`TCD` 是缩写，中英文本来就一样不用改），核对：0 处误改已有翻译、44 个新 key 全部有对应英文。

契约：`contracts/API.md` 新增「零件库接口清单（S5）」，并记一句「对方零件的选择走既有的 `partItem/grid`，不单独建搜索接口」；`contracts/DB.md` 新增「零件关联 / 区域 / 安装位置 / 来源规则（S5）」小节，写清楚关联的数值规范化规则、`part_category_position`「有配置才限定」的惯例、来源分页的编辑范围边界。

### 2026-09-16 零件分类「安装位置管理」（补 S5 遗留缺口）

只改 `pdc/`，没动框架，没有 DDL——`part_category_position` 表是零件库一期建表时就建好的，一直没有管理界面。

起因：用户直接贴了两张分类维护后台的截图（前/后轴、前/后、左/中/右、外/中/内、上/中/下 五个维度的勾选矩阵）说明业务规则——**这些安装位置维度和取值是先在分类（末级）上定义好允许范围，零件挂到这个分类后，「安装位置」分页只能在允许范围内选**。这正是 S5 会话里写好读取逻辑（`PartItemPosition::allowedIdsForCategory()`）但明确留白的那扇门——当时 `part_category_position` 只有表和读取端，没有任何写入入口，S5 的会话记录和 DB.md 都记了「管理界面留给以后」。

设计参照已有的 `PartCategoryParam`（分类编辑弹窗里「参数管理」面板）的模式——`scopeToXxx()` 限定范围、末级分类才开放——但简化了很多：`part_category_position` 是纯粹的「勾选集合」（分类 ↔ `part_position` 字典叶子值的多对多），没有排序/必填这些逐行属性，所以不用 Grid 协议，走 S5 里 `PartItemPosition`（零件的安装位置分页）已经验证过的「全量替换」模式——`list`/`save` 两个端点，前端整份提交。

Model `PartCategoryPosition`：
- `idsByCategory()` 查询逻辑和 S5 里 `PartItemPosition::allowedIdsForCategory()` 读的是同一张表、同一个条件，两处各自实现（没有互相调用）——一处是「分类允许哪些」的读，一处是「分类允许哪些」的写，保持各自 Model 职责单一，代价是两处都有一小段一样的 SELECT，判断为可接受的重复。
- `replaceForCategory()` 三层校验：分类必须存在且是末级（`PartCategory::isUsableLeaf()`，已有方法直接复用）；每个 ID 必须属于 `part_position` 字典（`AttrValue::belongsToType()`，已有方法）；每个 ID 必须是**叶子值**而不是维度本身（`attr_value_parent_id` 非空）——这一条是新加的校验，因为图 1 的勾选矩阵里维度名（如「前/后轴」）只是分组标签不能勾，能勾的是维度下的取值（F/R 这些）；CLI 自测专门验证了「把维度 ID 当取值传进来」会被拒绝。校验通过后在一个事务里删旧插新（没有 `assertEditable` 这类「草稿态」限制，因为这是分类级配置，不是零件数据）。
- 不用 `PartItemInput` 的 `setOperatorId`/`touch`——`part_category_position` 表没有操作人和时间戳列，和 `PartCategoryParam` 的做法一致（该 trait 名字带「Item」但实际是通用写入辅助，分类相关的 Model 也在用）。

Action `partCategoryPosition`：`list`（`{categoryId}` → 已勾选 ID 列表）/ `save`（`{categoryId, ids}` → 全量替换），跟 `partItemPosition.php` 几乎一样的形状。

View：`partCategory/index.view.php` 的编辑弹窗里，「参数管理」面板下面加一个「位置管理」面板（同样只在末级分类的编辑弹窗出现）——按维度分组渲染 `dxCheckBox` 矩阵（每个维度一行，行内是该维度下所有叶子值的勾选框），比截图里的单字母紧凑布局更啰嗦一点（用完整名称「前轴」「后轴」而不是单字母 F/R），换来和 S5 已经上线的零件「安装位置」分页（`groupedTagBoxOptions` 的 `dxTagBox`)一致的展示风格,没有为了完全复刻截图的极简排版另起一套视觉语言。弹窗打开时异步拉取 `partCategoryPosition/list` 勾好已有状态，保存按钮读取所有勾选框状态整份提交。`positions` 字典（`part_category` 页面新增的 bootstrap 数据，取 `AttrValue::optionsByTypeCode('part_position')`）在页面顶部一次性算好按维度分组的结构（`POSITION_DIMS`），弹窗每次打开复用这份静态分组，不重复计算。

自测：
- CLI 10 项：参考数据齐全；初始（无配置）查询为空；非末级分类调用 `replaceForCategory()` 报「只能对末级分类配置安装位置」；维度 ID（非叶子）被拒绝；不属于 `part_position` 字典的 ID 被拒绝；正常写入两个取值、查询一致；全量替换验证是覆盖不是追加；空数组清空回到「不限」；写一次配置后用 S5 的 `PartItemPosition::allowedIdsForCategory()` 验证能读到刚才写的限定（联动验证两个 Model 没有互相踩到）；清理后复查 `part_category_position` 行数回到 0。踩了一个自己的坑：CLI 脚本最后一行 echo 里 `$pass` 后面直接跟中文逗号，PHP 双引号字符串插值时把变量名当成 `\x80`-`\xff` 这类高位字节也算合法标识符字符继续往后吃，`"$pass，失败"` 被解析成变量名字面量是 `pass，失败` 的一个不存在的变量，报 `Undefined variable`——这是自己写的临时测试脚本的 bug，不是被测代码的问题，改用 `{$pass}` 加花括号定界修好；顺手在真实项目代码（`pdc/`）里搜了一遍同类写法，没有命中，不是一个已经存在的隐患。
- HTTP 端到端（`php -S` + 临时 router 伪造登录，办法同 S0-S5）：`partCategoryPosition/list`（初始空）→ `save`（写两个叶子值）→ `list`（确认写入）；对非末级分类调用 `save` 确认返回 `code=1` 和期望的错误信息；未登录访问返回 302 到登录页（这个端点是普通页面下的 Ajax 调用，走的是 `Action::callBefore()` 的标准登录拦截，不是本次新写的逻辑）；`partCategory/index` 整页渲染，`positions` bootstrap 变量结构核对正确（含 `attr_value_parent_id`），grep 排查页面 HTML 确认没有真正的 PHP `Warning`/`Notice`/`Fatal error`（命中的 3 处是文案标签 `cnNotice`/`enNotice` 字面量,不是异常）；测试数据清理后复查 `part_category_position` 表行数回到 0。
- 语言包：`LangScanner::run()` 扫描新增 3 条（位置管理面板标题 + 提示文案 + 非末级分类的错误提示）；这次先按 code=>name 的映射误传了一版 `run()` 的 `$locales` 参数（应该传纯语言编码列表 `['zh-CN','en']`，`editableLangFiles()` 内部是 `foreach ($locales as $code)` 取值不取键），导致第一次跑只同步了 `zh-CN.php`、`en.php` 被跳过；改正参数格式后重跑，`en.php` 补齐 3 条中文占位，再手工翻译成英文，核对 0 处误改已有翻译、`zh-CN.php`/`en.php` 496 个 key 完全一致。

契约：`contracts/DB.md` 的 S5 小节里「`part_category_position` 表已建但没有管理界面」那条改写为指向 `PartCategoryPosition::replaceForCategory()`；`contracts/API.md` 的 S2（分类与参数管理）接口清单里 `partCategoryParam` 之后加 `partCategoryPosition/list` / `save` 两行。

未做 / 已知限制：浏览器实测需人工登录；`part_category_position` 仍然没有管理界面（分类模块要不要补一个「限定安装位置」的配置入口，留给以后决定，本次只接了读取端）；「关联」不支持「填厂商+号码留作待匹配」（本次用户明确不做，以后如果要做需要新增一个「匹配」动作把待匹配记录关联到真实零件）；「来源」分页里三个 JSON 字段只显示有/无，等 S6 真正产生数据后再决定怎么展示明细（可能要参考 S1 之前 `washClean.js` 的 TCD/EPC 结果展示做法）；`part_relation_lnk_attr_factory_id` / `part_relation_pending_number(_format)` 这三个「待匹配」用的列本次完全没有写入路径，处于建了但没用的状态。

### 2026-09-17 数据单 S6a：确认参考产品 / 匹配分类 / 深度清洗

背景：用户要在中台补上 catalog_frey（客户项目，只读参考）数据单的「清洗出分类 + 转零件」。先只读学习 frey `admin/action/workAction.php` + `model/work.php`（cleanDataItem / autoCleanCat / cleanWorkChkOkPro / getWorkOeFormat / workItemToPoeItem），再按中台现有结构设计。
用户拍板：S6 拆两步（S6a 清洗、S6b 转零件）；转零件同分类唯一命中合并（已通过的跳过提示）、多个跳过交人工、没命中新建草稿；深度清洗完整结果放 1:1 子表；ga_id 对应多个分类时留空待人工选。

接口实测（结构没抄文档）：`tcd/cleanData` 按 art_id 调用返回 pro / num / othnum / model / model_oknum / ok_typids / para，刹车片 GDB1622 原始 900KB（num 101、othnum 323、model 766、model_oknum 1524），去图片裁剪后 530KB、gzcompress 26KB；
`etk/cleanData` 返回 grp1/grp2/pro_name、para、pic、wz；`etk/getReplaceNumber` 返回 [{oe,factory,display,format}] 新→旧；`number/getModelByNumber` 返回 ID 列表。
库核对：国际车型库 `car_vehicle_source_id` = frey `mm_yptyp_id` = TCD typ_id（214 → 1.4 TSI）；中国车型库 source_id = getModelByNumber 的 ID（5884 / 5885 → 2.0 GTI）；`part_category_tcd_ga` 同一 ga_id 常同时挂一级和末级分类（402 → 13 制动系 + 178 刹车片），只算末级后 940 个里 1 个多义、16 个只在一级。

SQL：`sql/migrate_wash_confirm_clean.sql`（v1.0.4）+ 回滚文件。用户第一次把执行文件和回滚文件先后都跑了（等于没改，核对库后发现），之后授权由 claude 只执行迁移文件：预览 → 执行 → 核对（7 列、外键 3+1、明细 7 行、子表 0 行）→ sys_migration。
交 SQL 时要写清哪份执行、哪份只是回滚备用。

代码：
- `model/Wash.php` 加 `cnVehicleSourceIds()` / `cleanArticle()` / `cleanEpcProduct()` / `getReplaceNumber()`（原 `getModelByNumber(string)` 被 `action/wash.php` 用着，签名没动）
- `model/PartCategory.php` 加 `filterUsableLeaves()` / `leafIdsByTcdGa()` / `leafIdsByName()`；`model/PartNumber.php` 加 `categoryIdsByNumber()`
- 新 `model/WashSheetItemClean.php`：run（组装裁剪结果 + 统计 + upsert、清空勾选）、detailByItem（车型补名称 / 年款 / 发动机 / 节点路径，节点按层批量往上查）、saveSelection（只收存在的 key）、effectiveByItem（给 S6b）、removeByItem
- `model/WashSheetItem.php`：grid 改为联 part_category（`Lang::translation` 显示名计算列）+ wash_sheet_item_clean（只取 summary / time，不取 blob）；子表 `wash_sheet_item_clean_time` 与明细表同名，select 里改名 `wash_sheet_item_deep_clean_time`，并把明细的这一列登记成带别名的计算列，否则按它过滤 / 排序报列名不明确；
  过期计算列 `wash_sheet_item_deep_clean_stale`；clean 串起确认 / 分类 / 深度清洗；新方法 confirm / deepClean / setCategory / matchCategory / detail / saveSelection；stripReadOnly 补全新列和计算列
- `admin/action/washSheetItem.php`：新端点 confirm / deepClean / setCategory / matchCategory / detail / saveSelection，统一 `run()` 转 code=1；index 与 `washSheet/detail` 多传 `categories`
- `washClean.js` 重写：`washCleanColumns(ctx)`（参数从文案对象改为 ctx）、`washCleanToolbar(ctx)`、`openWashCategoryDialog()`、`openWashDetail()`、`washPost()` / `washWithLoading()`；两个页面引入 `partItem.js` 复用 `partCategorySelectOptions()`
- `washCleanText.view.php` 新增文案；`washSheet.css` 新增标签 / 分类 / 深度清洗 / 详情样式

自测：CLI 38 项（临时数据单 #5：导入 4 条 + 后加 TRW:GDP900；四条完整清洗耗时 6.8 / 9.7 / 1.0 / 0.2s；数据单范围拦截 2、非法 / 不在结果里的 source_id 2、人工确认与重新清洗保留、EPC 确认、非末级拒绝、人工分类不被覆盖、恢复自动匹配、零件库同号码匹配、临时插 ga 402→179 验证多候选（finally 删除并复查）、名称匹配、详情车型补全、勾选过滤、无结果不能存勾选、无确认不能深度清洗、取消确认删结果、过期标记、save 丢弃只读列、unclean 跳过、计算列过滤排序、全表 grid）。
浏览器（内置浏览器，已登录会话；截图超时，改用 DOM 检查）：明细页列与按钮渲染、详情弹窗表头与五个标签（76/424、29、775/1524、174、11）、取消 6 个号码后保存变 70 且显示已人工勾选、表格单选确认（加载遮罩、刷新后过期提示消失）、单行分类弹窗恢复自动匹配、批量修改分类、匹配分类提示人工跳过 2、清洗进度 2/2；主菜单明细页渲染；控制台无错误。
语言包：`LangScanner::run()` 新增 55 条（zh-CN / en），en 已全部翻译；`sys_language` 里 ru 已启用（用户 09-16 建的），扫描按规则生成了 `ru.php`（551 条中文占位）。扫描前备份在会话临时目录。

未做 / 已知限制：测试数据单 #5 `S6A_CLI_TEST`（5 条明细）留给用户验收，确认后删除；详情接口一次返回全部车型（刹车片约 800KB）不分页；改分类后不自动重跑深度清洗，只在详情里提示清洗率不同；原 7 条明细没有重新清洗，确认 / 分类为空；S6b 转零件未开始。

### 2026-09-17 数据单 S6b：明细转零件

背景：接 S6a，把数据单明细的深度清洗结果写进零件库。开工先读契约、S6a 代码、零件子表 Model 和参考实现（catalog_frey `model/work.php` 的 workItemToPoeItem，只读），列出设计和 6 个待确认点交用户。
读代码时发现并事先告知用户：`part_param` / `part_param_tcd_name` / `part_category_param` 都是 0 行；清洗结果号码的厂商是字符串；替换号没有厂商；`part_source` 1:1 合并时会冲突；`PartVehicle::attachMany()` 等对外方法自带事务不能嵌套。

用户拍板（2026-09-17）：
1. 新建零件中英文名取分类名；2. 主号码用明细本身号码；3. 数据来源 TCD 号码 → tcd、EPC → epc、明细本身号码 → system、中国车型（宜配接口）→ yiparts（国际车型 tcd）；
4. 参数等参数数据迁进来后再做（本期只存 raw_params）；5. 号码厂商找不到跳过该号码并提示；6. 替换号挂 OE；7. 合并时 part_source 覆盖成本次快照；
8. 明细上记录转成的零件（加两列，SQL 审核）；9. 允许重复转：记录的零件还在就直接合并，被删了重新匹配，弹窗「跳过已转过的明细」默认勾；另外：无批量上限。

实现中追加的处理（会话结束时向用户说明）：只读自测发现 TCD 结果里 is_oe 的号码 brand 是汽车品牌（#10 刹车片 18 个全是 AUDI），按第 5 条会全部被跳过，改为 OE 号厂商一律取 OE（与替换号、导入默认厂商一致）；号码的汽车品牌 `part_number_brand` 本次没标，待用户决定。

SQL：`sql/migrate_wash_to_part.sql`（v1.0.5，执行文件）：`wash_sheet_item` 加 `_lnk_part_item_id`（FK part_item，ON DELETE SET NULL）+ `_part_time` + 索引；`sql/migrate_wash_to_part_rollback.sql` 只是回滚备用，文件头写明不要一起执行。**未执行**。

代码：
- 新 `model/WashSheetItemPart.php`（use PartItemInput）：`convert($row, $skipConverted)`，前置校验 → collectNumbers（own 槽位 + 勾选号码 + 勾选替换号，厂商 + 格式化号码去重）→ 目标零件（记录的零件 / `PartNumber::itemIdsInCategory()`）→ 一个事务写号码 / 车型 / 来源快照 / 主号码缓存 / touch / 检索 / 明细记录；跳过不抛异常
- `PartItem`：抽出 `insertDraft()`（gridInsert 改为调用它）、新增 `briefs()`（`PartNumber::duplicates()` 改为调用它）
- `PartNumber`：新增 `itemIdsInCategory()`、`keysOf()`
- `PartVehicle`：抽出 `insertRows()`（支持适用条件 criteria、分批 500），`attachMany()` 改为调用它
- `PartSource`：新增 `saveWashSnapshot()`（整行覆盖 upsert）
- `WashSheetItem`：`toPart()`（范围校验 + 刷新数据单时间）；grid 联 `part_item wpi` 取主号码 / 审核状态；stripReadOnly 加 4 列
- `admin/action/washSheetItem.php`：`toPart` 端点（`Auth::id()` 作操作人）
- `washClean.js`：逐条 + 进度条抽成通用 `washRunBatch()`（清洗 `runWashClean()` 改为调用它，行为不变）；新增 `openWashToPartDialog()` / `runWashToPart()`（结果逐行：新建 / 合并带零件链接与新增数，跳过带原因和候选零件链接，号码警告橙色）；结果列加「零件」（`#ID 主号码` + 审核状态，点开零件详情，转过但零件被删提示「零件已删除」）；工具条加「转零件」
- `washCleanText.view.php` 新文案、`washSheet.css` 加 3 个样式

自测：
- PHP -l 全部通过；`node --check washClean.js` 通过
- 只读 CLI 29 项（`Mvc::init` 引导，不 dispatch）：4 条已深度清洗明细（#1 TCD 4 号码 47+6 车型、#10/#11 TCD 刹车片 99/105 勾选号码去重后 81/87、775+174 车型、#12 EPC）own 槽位与来源、去重、来源编码、替换号 OE+epc、国际车型数量；跳过条件 无分类 / 过期 / 无深度清洗结果 / 已转过 / 非末级分类；自测后 part_item 1 行、part_source 0 行不变
- **没测**：写入路径（新建 / 合并 / 多候选 / 非草稿 / 回滚）、HTTP、浏览器——都依赖 SQL v1.0.5；在执行前 `washSheetItem/grid` 会因联表列不存在报错，两个明细页打不开

语言包：`LangScanner::run()` 新增 19 条（zh-CN / en / ru），en 已全部翻译（旧条目 0 处改动），ru 中文占位；扫描前备份在会话临时目录。

未做 / 已知限制：SQL 待执行后补写入与浏览器测试；参数未映射到 `part_param_value`；OE 号的汽车品牌未写 `part_number_brand`；合并时不校验记录的零件是否仍在明细当前分类（直接合并）。

#### 2026-09-17 S6b 追加：OE 号汽车品牌以文本存储

用户确认：1) OE 号厂商取 OE 同意；2) OE 号码的汽车品牌使用文本存储（不映射 car_brand）。
- SQL 并入同一份 v1.0.5（仍未执行）：`part_number` 加 `part_number_car_brand_text` VARCHAR(255) NOT NULL DEFAULT ''，预览 / 核对 / 回滚文件同步补上。执行前零件号码的新增 / 修改也会报列不存在
- `PartNumber`：`insertRow()` 加可选参数品牌文本、`gridInsert` / `gridUpdate` 接受该列（trim、≤255）；`keysOf()` 改为返回号码 ID + 品牌文本；新增 `updateCarBrandText()`、静态 `mergeCarBrandText()`（「, 」拼接、不区分大小写去重、超 255 的品牌不再追加，避免整条失败）
- `WashSheetItemPart`：OE 号（brand 不是 OE 时）把 brand 记进号码的 car_brands，同号去重时品牌合并；新号码写入品牌文本，已有号码只补品牌
- 零件详情「号码」分页新增可编辑列「汽车品牌文本」；`part_number_brand` 多选不动
- 自测：CLI 10 项（合并规则 4 + #10 的 18 个 OE 号带 AUDI、#1 / #12 无品牌、明细本身号码无品牌）+ 原只读 29 项复跑通过；PHP -l 通过
- 语言包新增 2 条（汽车品牌文本 / 汽车品牌文本过长），en 已译，旧条目 0 改动
- 检索表 `part_item_search` 没有收录品牌文本（未要求）

#### 2026-09-17 S6b 追加：SQL 执行 + 写入 / 浏览器实测

- 用户授权执行 SQL。`sql/migrate_wash_to_part.sql` 按步骤执行（临时 PDO 脚本逐步跑）：第 1 步预览（明细 12 行、新列不存在、未执行过）→ 第 2 步 2 条 ALTER → 第 3 步核对（明细 2 列可空、外键 4 条含 part_item CASCADE / SET NULL、part_number 1 列 NOT NULL 默认空串、行数 12 / 1 不变）→ 第 4 步 sys_migration 记 1.0.5。回滚文件未执行。文件头已标注执行状态
- CLI 写入 36 项（记录起始 part_item 最大 ID，finally 清理）：#1 新建（草稿、分类名、主号码 = 明细号码且来源 system、号码 4、车型 47 tcd + 6 yiparts、criteria、part_source 快照、明细记录、检索、grid 零件列）；跳过已转过；不跳过合并且无新增；回滚（删 1 号码 1 车型后用不存在的操作人转，touch 外键失败抛 PDOException，数量不变；再正常转补回 1+1）；提交审核后跳过；#12（EPC）按号码合并进 #1 的零件、主号码不变、part_source 被覆盖为 epc；#10 新建 81 号码（18 个 OE 号 AUDI 且厂商 OE）+ 949 车型、0.3s；#11 合并补 6 号码且品牌不重复；另建同 OE 号草稿后 #11 命中 2 个候选跳过；记录零件被删后链接置 NULL、时间保留、跳过选项不拦、重新匹配；数据单范围拦截；号码分页编辑品牌文本（trim、>255 拒绝、grid 返回）。清理后 part_item 1 / part_number 1 / part_vehicle 5 / part_source 0 / search 1 与测试前一致，用户零件 #7 未被改动
- 浏览器（`php -S` + 临时 router 伪造超级管理员登录，内置浏览器；截图 / 坐标点击常超时，改用 find ref 点击 + DOM 检查）：单张单明细页出现「零件」列和「转零件」按钮；勾选 #10–#13 转零件：#10 新建 #48（81 / 949）、#11 合并进 #48（6 / 0）、#12 新建 #49（2 / 6）、#13 跳过「还没有深度清洗结果」；表格零件列显示「#48 1K0698451 草稿」；点链接进零件详情，号码分页有「汽车品牌文本」列、OE 号显示 AUDI；再次转零件默认全部跳过「已转过零件 #48 / #49」；主菜单明细页无 PHP 报错，grid 按转零件时间排序、按零件 ID 过滤、S6a 列查询正常。
- 发现并修复：转零件弹窗按钮复用了清洗的「开始清洗」文案，改为「开始转零件」（`startToPart`），清洗弹窗仍是「开始清洗」；语言包再加 1 条已译（本 S6b 合计 22 条）
- 实测数据 #48 / #49 已删除，明细 10–12 的转零件记录已清空（数据单 #5 的更新时间因转零件被刷新过，未还原）；临时服务已停

### 2026-09-17 零件库菜单调整 + 汽车品牌 / 国际车型 / 中国车型管理页

需求：汽车品牌 CRUD、国际车型 CRUD、中国车型只读列表，三项各一个菜单挂在「零件库」下；「零件关联属性」下的「号码厂商」移入「零件库」并删除原菜单组。

菜单：定义在 `config/admin.php`（不是数据库），「零件关联属性」下只有号码厂商一项，无需额外处置；改完「零件库」下依次为 零件 / 分类 / 号码厂商 / 汽车品牌 / 国际车型 / 中国车型。**本次没有任何 DDL 或数据变更 SQL**。

代码：
- `model/CarBrand.php`：加 `gridInsert` / `gridUpdate` / `gridRemove`（可写列白名单、中文名必填唯一、英文名空串存 NULL、排序整数、`source_id` 只读）；计算列 `car_brand_node_count` / `car_brand_number_count`（行级标量子查询）；默认按排序 + ID；删除前查 `part_number_brand`、`car_brand_node`，有引用就拒绝并带数量。新增公开 `assertExists()`
- `model/CarBrandLang.php` + `action/carBrandLang.php`：照 `PartCategoryLang`（无别名列）
- `model/CarVehicle.php`：新增 `scopeToLibrary()`——按 `car_library_level` 非车型层级从直属上级往上逐级 LEFT JOIN `car_node`（别名 `n{depth}`），计算列 `car_vehicle_node{depth}_name` / `_id`；写入白名单（文本长度、整数范围、布尔）、车型库强制、直属上级必须是本库最深非车型层节点、来源 ID 同库唯一、`kw_value` 取原文第一个数字、年月先后校验（仅提交年月时）；删除前查 `part_vehicle`。原 `scopeToNode()` / `search()` 行为不变
- `model/CarLibrary.php`：加 `idByCode()`
- `action/carBrand.php`、`carVehicleIntl.php`、`carVehicleCn.php`（后者覆盖 `save` / `remove` 直接报只读）；视图 `carBrand/index`、`carVehicle/library`（两个车型页共用，按 `readOnly` 开关编辑；编辑弹窗的上级各层用 `carNode/children` 级联下拉，换上层自动清空下层；车型页关闭列头筛选，避免十几万行的分组查询）
- `model/WashSheetItemPart.php::collectVehicles()`：写适用车型前过滤掉车型库里已不存在的 `vehicle_id`（车型可删之后，深度清洗 BLOB 里的旧 ID 会撞 `part_vehicle` 外键）

自测：
- PHP -l 全部通过
- CLI 43 项：品牌 19（grid/计数列过滤排序、listOptions 不受影响、重名/空名/排序非整数、trim 与 NULL、ru 译文 upsert/清空删除/显示名回退、号码引用与节点映射拒删、翻译级联）；车型 24（国际 grid 16.5 万行首页 0.28s、深分页 skip=100000 0.45s、中国车型 5 级联出、scopeToNode 旧用法、无范围 0 行、缺名称/来源ID/上级、上级层级错/跨库、来源ID重复、月份越界、年月倒序、新增强制库与 kw_value、修改、跨库修改拒绝、零件引用拒删）。1 项「按车厂名 AUDI 过滤」失败是用例假设错误——国际车厂名是本地化中文（奥迪 (进口)），过滤逻辑经车型名过滤、中国库按「奔驰」过滤验证正常
- 浏览器（`php -S 127.0.0.1:8099` + 临时 router 注入超级管理员 Session，同 S6b 做法；DOM/JS 检查）：菜单结构正确、英文菜单与注入文案已译；品牌页新增/展开行编辑俄文译文/重名提示/删除已映射品牌提示/删除；国际车型页新增（车厂 1148 项 → 选宝马 (进口) 后车系 183 项）、修改换车厂自动清空车系后保存、来源ID重复提示、被零件引用拒删、跨库删除拒绝、删除；中国车型页无新增/编辑/删除按钮，服务端 save/remove 拒绝，按品牌过滤 1807 行，扩展属性列显示制动器信息；全程无控制台报错
- 测试数据（品牌 #134/#135、车型 zz-* 两条）均已删除并核对为 0；临时服务已停

语言包：`LangScanner::run()` 新增 39 条、删除 1 条（零件关联属性），en 全部已译，ru 为中文占位。

---

## 2026-09-19 零件库 UI 优化（第一批：分页合并 / 人工操作自动化 / 列表批量操作）

### 起因与范围

用户要求「以 UI 专家视角把零件库各子菜单做一次全面优化，能合并的合并，不方便人工操作的换方式」。
先通读了六个子菜单的视图（partItem index 419 行 + detail 1522 行、partCategory 595 行、carVehicle/library 201 行、
carBrand 92 行、numberVendor 24 行）出了一份体检清单，**由用户挑定本次做 A+B+C 三批**：

- A 分页合并 + 统一保存
- B 人工操作自动化
- C 零件列表批量操作
- **分类页那四项（弹窗改 TabPanel、上级分类改树、树上拖拽换父级、选项批量粘贴）用户明确留到下次会话**
- 菜单 6→4 合并（国际 + 中国车型合成「车型库」、号码厂商 + 汽车品牌合成「基础数据」）本次未做，也没动 `config/admin.php`

### 改了什么

**后端（无 DDL）**

- `model/PartFile.php`：新增 `TYPE_BY_EXT` + `public static typeFromExt()`；`gridInsert()` 改成先 `get` 出
  `sys_file_ext`，`values` 里没有 `part_file_type` 时按扩展名判定（图片 6 种、图纸 9 种 CAD/矢量，其余一律附件）。
  传了类型仍按传的来，`normalizeType()` 的非法值校验不变。
- `model/PartItem.php`：新增 `BATCH_MAX = 1000`、`batchAudit()`、`batchUpdate()`、私有 `runBatch()`。
  `runBatch()` 负责去重 / 过滤非正整数 / 限流 / 逐条 try-catch，失败项用 `briefs()` 取主号码当 title。
  **逐条独立事务**（`changeAuditStatus()` / `gridUpdate()` 各自开事务），一条失败不影响其它条——
  状态不对、不能自审这类在批量场景里是常态，整批回滚反而没法用。
- `admin/action/partItem.php`：`batchAudit` / `batchUpdate` 两个端点 + 私有 `batchKeys()`。

**前端**

- `admin/view/partItem/detail.view.php` 基本重写：
  - 分页 9 → 6：区域 / 安装位置 / 来源并进「基本信息」（三个分页原来各自只有一两个控件）
  - 页头一个保存按钮管全页 + `Ctrl/Cmd+S`；`dirty` 脏标记 + `loadingValues` 开关（程序回填不算改动）；
    分页标题打 `.part-tab-dirty` 橙色圆点；保存前先校验所有脏表单，不合法切回那个分页
  - 原来 20 多个 `if (xxx) { xxx.option('disabled', ...) }` 的 `applyEditable()` 改成 `registerEditable(apply)` 登记表
  - 安装位置：TagBox → 按维度横排的复选框（和分类页的安装位置管理一致）
  - 区域：TagBox → 可勾选的两层树，`selectNodesRecursive: false`（勾区域本身＝整个区域，语义和后端一致）
  - 适用车型：三/五级级联下拉 → 左侧懒加载层级树 + 右侧候选车型，顶部搜索框 300ms 防抖输入即搜
  - 关联：「填关键字 → 点搜索 → 结果表格单选」→ 远程搜索的 dxSelectBox（输入即出结果）
  - 文件：上传时不再写死 `part_file_type: 'image'`，交服务端判定，工具条加一行说明
  - 审核：提交 / 通过一步到位（提交前自动先存未保存改动），撤回 / 反审核才弹窗且**原因必填**（原来是四个动作都弹、备注选填）
- `admin/view/partItem/index.view.php`：工具条 `selectToggle: true` + 「批量审核」「批量修改」两个按钮；
  `openBatchPopup()` 公共外壳 + `showBatchResult()`（失败项用表格列出主号码和原因）；
  新增弹窗里号码 / 厂商改动后 300ms 查一次重，提示直接显示在弹窗里（原来是保存完才弹一次 dialog）
- `admin/static/js/lib/partItem.js`：删掉已无人调用的 `groupedTagBoxOptions()`
- `admin/static/css/partItem.css`：删级联下拉 / TagBox / 关联搜索表格的样式，新增未保存圆点、适用范围两栏、
  车型树 + 候选两栏、同号提示、批量弹窗的样式；车型树列宽从 300 收到 240

**契约**

- `contracts/API.md`：零件库 S1 清单补 `batchAudit` / `batchUpdate`；`partFile/save` 补「不传类型按扩展名判定」；
  S4 的「车型选择两种方式」改写成树 + 直搜，并记下两个 DevExtreme 坑（见下）
- `contracts/UI.md`：新增「多分页详情页：一个保存按钮 + 未保存圆点」小节（含只读登记表的写法）

**语言包**：离线跑了一次和 langTool 等价的扫描（只加载 `Lang` + `LangScanner`，不起框架），
623 条 key，zh-CN / en / ru 各新增 25、删除 13；en 的 25 条全部译好，ru 沿用中文占位（它本来就全是占位）。

### 踩的两个 DevExtreme 坑（已写进 API.md）

1. **dxTreeView 虚拟模式 + 平铺结构**：`createChildren` 返回的每一项必须自己带 `parentId`，
   否则子节点会被挂到根上——表现是「点了展开箭头，箭头消失了但没有子节点」，而 `items` 数量其实在涨。
   实测 1148 个顶层节点展开一个品牌后 items 变 1205，但树上看不到。
2. **CustomStore 的 `loadMode`**：远程搜索下拉必须用 `processed`。`raw` 模式下 DevExtreme 只拉一次全量再本地过滤，
   `load()` 根本拿不到 `searchValue`，表现是「输入什么都搜不到」。
   另外测的时候 `selectBox.option('searchValue', ...)` 不是公开 API，驱动不了搜索，得真往输入框里打字。

### 自测

本机 `php -S 127.0.0.1:8899 -t E:/www` + 伪造登录的 router（`-t` 必须是 Web 根 `E:/www`，
否则 `/mdm/pdc/admin/static/...` 这类静态资源 404）。

- **HTTP 33 项**（`selftest.php`）：批量端点的参数校验（空勾选 / 非法动作 / 超 1000 条 / 全是无效 ID）、
  批量提交 3 条全成功、重复提交 3 条全失败且带 itemId/title/reason、待审核状态批量改全失败、批量通过、
  混合状态 1 成功 2 失败、批量改名落库、分类不存在时逐条失败而不是 500、混入不存在的 ID 3 成功 1 失败、
  `values` 为空 / 只传非白名单列报 `code=1`（审核状态改不了）、`duplicates` 传 `itemId=0` 的三种情况、
  车型树用到的四个端点、两个页面无 PHP 报错、详情页确实只剩 6 个分页
- **文件类型端到端 12 项**（`filetest.php`）：真上传 png / pdf / txt → image / attachment / attachment，
  每种类型各一个主文件，png 回写零件主图缓存，仍可手工改成图纸，非法类型仍被拒
- **浏览器实测**（built-in 浏览器）：列表页五个工具条按钮 + 批量修改弹窗（已勾选 1、留空的字段不修改）；
  详情页 6 个分页、基本信息里的适用范围 / 来源 / 翻译；改一个字段后保存按钮变可用 + 分页打圆点；
  一次改 基本信息 + 安装位置 + 区域 + 来源，点一次保存依次调了
  `partItem/save` → `partItemPosition/save` → `partItemRegion/save` → `partSource/save` → `partItem/info`，
  保存后按钮禁用、圆点清空、值确实落库；
  车型树：国际库（3 级）阿巴斯 → 124 Spider → 出车型，中国库（5 级）本田 → 广汽本田 → 雅阁 → 雅阁六代 → 出 3 个车型，
  输入 "Golf" 直接搜出 e-Golf / Golf 100；关联下拉输入 "ZZTEST" 即时列出目标零件并成功建立关联；
  提交零弹窗直接变待审核、撤回弹窗空原因被拦、填原因后回草稿；号码格填 GDP900 失焦立即弹重号提示
- **数据清理**：测试建的零件全部删除（`part_item` 回到 1 行）；3 条 `zztest-auto` 的 `sys_file` 记录连同存储文件删除；
  零件 #7 被测试改过的数量 / 安装位置 / 区域 / 确认来源全部还原，自测 `partSource/save` 产生的空 `part_source` 行也删了。
  跑完各表行数：part_item 1、part_number 1、part_file 5、part_relation 0、part_source 0、part_item_region 0、
  part_item_position 1、part_item_audit_log 0、part_vehicle 5、sys_file 5（除 part_source 外均为测试前的值；
  part_source 测试前后都是 0）

### 已知限制 / 待办

- **分类页（partCategory）本次完全没动**，用户指定留到下次：弹窗改 TabPanel（现在新增是两段式、900×640 要滚动）、
  上级分类下拉改树、树上拖拽换父级和排序、新建参数的选项支持批量粘贴
- **菜单 6→4 没做**：国际 + 中国车型是同一个视图只差 `libraryId` / `readOnly`，可以合成「车型库」并按 `car_library` 动态出 tab；
  号码厂商（2 列）+ 汽车品牌可以合成「基础数据」。改动涉及 `config/admin.php` 和权限配置，需要单独一次会话
- 车型库列表页（`carVehicle/library`）十几万行仍然只有过滤行，没有层级树导航——体检清单里列了，本批没做
- 文件分页还是表格形态，没做图片卡片墙 / 拖拽排序；主文件仍靠布尔列勾选
- `TYPE_BY_EXT` 的图纸扩展名是按行业常见格式列的，实际用到哪些要等业务反馈再调
- 批量审核 / 批量修改是一次请求里服务端循环处理，1000 条时响应时间没实测（前端没做进度条）；
  如果实际用量大，可以参考数据单清洗改成前端逐条调 + 进度弹窗

---

## 2026-09-19 安装位置语义修正（接着 UI 优化第一批）

### 起因

用户看到零件详情「安装位置」列出了全部 13 个取值，而分类「刹车片」里只配了 前轴 / 后轴 / 左 / 右。

**第一点不是 bug**：`part_category_position` 里 178(刹车片) 确实有 4 行，
`partItem/detail` 注入的 `allowedPositionIds` 是 `[8,9,12,14]`，浏览器里复现只出 4 个复选框两个维度。
用户截图那个标签页是配置分类**之前**打开的，没重新加载。

**第二点是真要改的语义**：用户要求「分类没有定义 → 对应分类的零件没有位置需要选择」，
而原实现是「空 = 不限，零件可选整个 `part_position` 字典」（沿用 `part_item_region`「无记录=不限」的惯例）。

### 改了什么

- `model/PartItemPosition.php`
  - 类注释改写：候选范围**完全**由分类决定，并写明这条和 `part_item_region` 相反的理由
    （位置是分类固有属性——刹车片才分前后轴；区域是销售范围，不填才是不限）
  - `allowedIdsForCategory()` 注释：返回空数组 = 没有位置可选，不是不限
  - `replaceForItem()` 校验：原来是 `if ($allowed && array_diff(...))`（allowed 为空时跳过校验），
    改成先判 `$positionIds && !$allowed` 报「该分类没有配置可选的安装位置」，再判越界报原文案
- `model/PartCategoryPosition.php`：类注释里的「空 = 不限」改掉
- `admin/view/partItem/detail.view.php` `renderPositionPicker()`：去掉 `if (allowedPositionIds.length)` 的条件分支，
  始终按 allowed 过滤；顺手把 `positionChecks = {}` 提到空分支之前（原来空分支直接 return，没重置）
- `admin/view/partCategory/index.view.php`：只改了 `positionManageTip` 一条文案
  （「不勾选＝不限（零件可选整个字典）」→「不勾选＝该分类的零件不需要选安装位置」）。
  **分类页的其它改造仍按约定留到下次**，这条属于本次语义变更的配套，不改会直接误导用户
- `contracts/DB.md`：`part_category_position` 段落改口径，并显式标注「与 `part_item_region` 相反，别照着推」
- `contracts/API.md`：`partItemPosition/save` 和 `partCategoryPosition/save` 两行都补了新语义

### 数据影响

改语义前先全表核过：`part_item_position` 只有 1 行（零件 7 / 分类 178 / 位置 8=前轴），
位置 8 在分类 178 的允许范围内，**新规则下仍合法，不需要任何数据迁移**。
`part_category_position` 现有 8 行（分类 30 气缸盖、178 刹车片各 4 行）。

### 自测

- **HTTP 15 项**（`postest.php`，两个临时零件：一个挂配了位置的分类、一个挂没配的分类）：
  详情页只注入分类配的 4 个 ID、范围内可保存并落库、越界（10=前）被拒且原值不变、清空可保存；
  没配的分类注入空数组、提交任何 ID 都被拒且报「该分类没有配置可选的安装位置」、提交空数组仍成功；
  分类页保存空配置能清掉该分类的限定（测完已还原分类 30 的 4 行）
- **浏览器实测**：零件 #7（刹车片）只显示 前轴 / 后轴 / 左 / 右 两个维度；
  临时建的「气缸垫」分类零件显示「该分类没有可选的安装位置」，区域树不受影响
- **清理**：3 个临时零件全删（`part_item` 回到 1 行）；零件 7 的位置仍是 `[8]`；
  分类 178 / 30 的配置都还是 `[8,9,12,14]`

### 语言包

离线扫描：624 条，三个语言各新增 2 删除 1（改掉的旧提示文案被自动清理），en 两条已译。

### 待办（不变）

分类页四项改造、菜单 6→4 合并、车型库列表层级树、文件分页卡片化 —— 仍在排期外。


---

## 2026-09-19 零件适用区域改由车型推算（口径变更，未完成）

### 用户拍板的规则

> 零件的"适用区域"是通过零件对应车型的适用区域来关联，零件本身不设置适用区域。

和 `part_item_brand`（v1.0.3 已删）完全同构：能从 `part_vehicle` 顺出来的东西，不在零件上再落一张表。

### 调研结论：车型侧没有区域数据（这是本次没做完的原因）

翻了三张表 + 两个 JSON 扩展列：

| 位置 | 内容 |
|------|------|
| `car_vehicle` 全部 26 列 | 没有任何 region 列 |
| `car_node` 全部 13 列 | 没有 |
| `car_library` 全部 10 列 | 没有，`car_library_extra_schema` 两个库都是空 |
| `car_vehicle_extra`（245357/245357 行非空） | 只有 `{tecdoc_id, frey_mm_id, tecdoc_from}` |
| `car_node_extra`（27204/31024 行非空） | 只有 `{en_name, initial, frey_mk_id}` |
| 全库 information_schema 搜 `%region%` | 只有 `part_item_region` / `sys_region` / `sys_region_country` / `sys_region_lang` |

`sys_region`（284 行 = 32 区域 + 252 国家）和 `sys_region_country`（621 行）是
`migrate_part_library_v1_data.php` 从 `frey_catalog.crm_country` 迁过来的**独立字典**，和车型库没有任何关联。

所以「零件 → 车型 → 区域」这条链断在最后一环：
品牌能推是因为有 `car_brand_node` 映射表（`PartVehicle::brandsByItem()` 靠它），**区域没有对应的映射**。

`part_item_region` 当时 0 行，改口径零成本、没有数据要迁。

### 本次做了什么

- 删 `model/PartItemRegion.php`、`admin/action/partItemRegion.php`
- `admin/view/partItem/detail.view.php`：删「适用区域」勾选树、`regions` 注入、`dirty.regions`、
  `saveRegions()`、`renderRegionPicker()`；原来的「适用范围」两栏外壳去掉，安装位置直接挂在基本信息页上
  （`renderScopeSection` → `renderPositionSection`）
- `admin/action/partItem.php`：`detail()` 不再注入 `regions`
- `admin/static/css/partItem.css`：删 `.part-scope-row` / `.part-scope-col` / `.part-sub-title` / `.part-region-tree`
- `sql/migrate_drop_part_item_region.sql`（v1.0.6）+ rollback，**待人工审核执行**
- 契约：API.md 删 `partItemRegion` 两个端点并注明原因；DB.md 加「`part_item_region` 已确定不需要」条目
  （含上面的数据缺口）；顺手修掉安装位置那条里拿 `part_item_region` 当反例的说法（那张表要没了）
- 语言包：离线扫描，620 条，三个文件各删 4 条
  （`适用范围` / `适用区域` / `不勾选表示不限地区…` / `区域 / 国家不存在`），无新增

**保留不动**：`sys_region` / `sys_region_country` / `sys_region_lang` 三张字典表和 `model/SysRegion.php`
——车型区域接上之后推算链路还要查它们。`SysRegion::pickerOptions()` / `allEnabled()` 暂时没有调用点。

### 自测

- HTTP 33 项（`selftest.php`）全过，两个页面无 PHP 报错
- `partItemRegion/save` 已 404（路由文件不存在），写不进去；`part_item_region` 仍 0 行
- 浏览器：详情页「基本信息」只剩 安装位置 + 来源 + 其它语言翻译，没有「适用区域」；
  一次改 基本信息 + 安装位置 + 来源后点页头保存，链路是
  `partItem/save` → `partItem/info` → `partItemPosition/save` → `partSource/save` → `partItem/info`，
  已经没有 `partItemRegion/save`
- 测试数据清理：零件 #7 的数量 / 安装位置还原，自测产生的空 `part_source` 行删除；
  各表行数与改动前一致（part_item 1、part_number 1、part_file 5、part_relation 0、part_source 0、
  part_item_region 0、part_item_position 1、part_item_audit_log 0、part_vehicle 5、sys_file 5）

### 待办（下次会话，需要先定数据来源）

**车型的适用区域从哪来？** 这决定了后面的实现，而且多半要 DDL（人工审核）：

1. TecDoc 的 ktype 带销售区域 → 新建 `car_vehicle_region` 映射表 + 在车型库同步里写入
   （`car_vehicle_extra.tecdoc_id` 已经存着 ktype ID，`sys_region_source_id` 也是 TCD 侧的 ID，对得上）
2. 区域挂在层级节点上（车厂 / 车系级）→ 新建 `car_node_region`，粒度更粗但数据量小
3. 别的来源（frey_catalog 里另有表？人工维护？）

定了之后要做的：
- `PartVehicle::regionsByItem($itemId)`，写法对标 `brandsByItem()`
- 「适用车型」分页顶部在「适用品牌」旁边加一行只读的「适用区域」chip
- API.md 补推算端点；DB.md 补映射表结构

---

## 2026-09-19 车型菜单三级化 + 「中国车型统计」

**需求**：在「零件库」下加「车型」二级菜单，把国际车型 / 中国车型收进去变三级菜单；
并学习参考站 catalog_frey（`http://127.0.0.1:8087/catalog_frey/pdc/admin/`）「车型」部门的内容，
把里面各项菜单的基础功能在 MDM 也做出来（汽车品牌 / 所属车型品牌 / 国际车型 / 中国车型 / 中国车型统计）。

### 做了什么

1. **菜单三级化**（`config/admin.php`）

   ```
   零件库(fa-cubes)
     ├ 零件        /partItem/index
     ├ 分类        /partCategory/index
     ├ 号码厂商    /numberVendor/index
     └ 车型(fa-car)
         ├ 汽车品牌      /carBrand/index
         ├ 国际车型      /carVehicleIntl/index
         ├ 中国车型      /carVehicleCn/index
         └ 中国车型统计  /carVehicleCn/report
   ```

   `adminLayout.js` 的菜单渲染本来就是递归的，加一层**不需要改任何前端代码**，浏览器实测折叠 / 展开正常。

2. **「中国车型统计」= 品牌 × 零件分类 覆盖率报表**

   参考 catalog_frey 的 `carmodelcnAction::report` + `model/carmodelcn.php::getModelReport()`，
   但没有照搬（对方是 5 级平铺表，MDM 是 `car_node` 物化路径 + `car_library_level`）。

   - Model `CarVehicle` 新增：
     - `brandCoverageReport(int $libraryId, array $categoryIds): array`
     - `topNodeLevelId()` —— 品牌层 = 该车型库**最浅的一层非车型层级**（中国库「品牌」/ 国际库「车厂」），不写死层数
     - `brandVehicleCounts()` —— `LEFT JOIN car_node leaf ON leaf.car_node_path LIKE CONCAT(b.car_node_path,'%')` 往下汇总，没车型的品牌也出行
     - `coveredVehicleCounts()` —— `COUNT(DISTINCT part_vehicle.part_vehicle_lnk_car_vehicle_id)` 按 品牌节点 × 零件分类 分组
   - 选中的分类**含其全部下级**（零件只能挂末级分类），用 `PartCategory::selfAndDescendantIds()` 展开，
     下级的覆盖数**归并回选中的那一级**。
   - Action `carVehicleCn`：`report`（页面）/ `reportData`（JSON）/ `reportExport`（表单 POST 出 xlsx，
     覆盖率拍平成 `cate_{id}` 列、值是 `12.34% (56)` 文本，走 `ExcelExporter::download()`）。
   - View `view/carVehicle/report.view.php` + `static/css/carReport.css`：
     顶部 `dxDropDownBox` 里放可勾选的分类 `dxTreeView`（`selectNodesRecursive: false`，勾大类不联动勾满子节点）；
     分类变了要重建列，所以整个 grid 重建。
     **百分比在前端算**：列 `cate_{id}` 存数值参与排序 / 过滤，`cellTemplate` 读 `covered_{id}` 拼成 `12.34%  (56)`；
     后端只给覆盖数，不给百分比。品牌车型数为 0 的格显示 `—`。

### 验证

- 菜单：浏览器点开「零件库 → 车型」，四项都在，三级折叠正常
- 报表页：200，品牌列表正确（本田 959 / 理念 26 / 奔驰 1807 / 雪铁龙 246 / 克莱斯勒 36 / 奥迪 1292 …）
- 勾选「刹车片」(#178)：本田 `0.21% (2)`（蓝色），其余 `0.00% (0)`（灰色）；
  与库里 2 条中国库 `part_vehicle`（雅阁七代(CM_) 2.0L 手动 / 2.0L 自动）对得上
- 直接调 Model 复核：`brandCoverageReport(2, [178])` → `[{car_node_id:31025, car_node_name:"本田", vehicle_count:959, coverage:{178:2}}]`
- 导出：浏览器 fetch 实测 200、`…spreadsheetml.sheet`、13048 字节、zip 头有效
- 语言包扫描：新增 9 条（总 629）
- 无 DDL，无测试数据写入

### 踩过的坑（本次没有新坑，沿用之前的结论）

- Apache :8087 跑的是 PHP 7.4，MDM 用了联合类型，**不能**用它调试 MDM；本地一律
  `php -S 127.0.0.1:8899 -t "E:/www" router.php`，`-t` 必须是 webroot 否则静态资源 404
- Git Bash 的 curl 发 POST 表单有引号问题，导出这类接口用浏览器 `fetch` + `URLSearchParams` 验

### 待办：「所属车型品牌」没做（需用户先定口径）

参考站菜单里的「所属车型品牌」对应 `pdc/element/cd_make.php` → 表 `cd_make`。查下来：

| | `cd_make`（参考站，128 行） | `car_brand`（MDM，133 行，来源 `frey_catalog.tag_brand`） |
|---|---|---|
| 内容 | 宝马 / 奔驰 / 保时捷 / 大众 / 阿巴斯 / 阿尔法罗密欧 / 奥迪 / 阿斯顿马丁 … | 同上，高度重合 |
| 多的字段 | 拼音 `cm_py_name`、别名 `cm_al_name` | 匹配关键字 `car_brand_keyword` |
| 被谁引用 | `pro.p_cm_id`（178018 / 184374 条产品挂了） | `part_number_brand`、`car_brand_node` |

MDM 的 `part_item` 上**没有**对应字段。给用户的两条路：

- **A. 认为就是同一个东西** —— 复用已有的「汽车品牌」菜单
- **B. 认为是独立字典** —— 新建一张表 + 在零件上加外键（要 DDL + 迁移）

### 用户选 A，复核后确认不需要任何 DDL

拍板后再去核字段，**推翻了我原先的描述**：`cd_make` 的 `cm_py_name` / `cm_al_name` 不是「拼音 / 别名」，
列注释写的是**俄语 / 阿拉伯**（`cm_sp_name` 是西语）。也就是说 `cd_make` 多出来的三列是多语言名称，
而 MDM 早就有 `car_brand_lang` 这张译文表在承载同样的东西——所以「在 car_brand 上补两列」这个方案不成立，
也不需要。

更关键的一条：`frey_catalog.tag_brand`（`car_brand` 的迁移来源）有一列 `tb_from_ids`，
注释是「原中台表 cd_make 的 make_id 源 ID，多个逗号分隔」，133 行里 121 行有值。
**参考系统自己早就把 cd_make 并进了 tag_brand**，`cd_make` 是被取代的旧字典。
MDM 拿的已经是合并后的结果，什么都不缺。

结论：不新建表、不加字段、不加菜单项，只在 `config/admin.php` 的「汽车品牌」上方留了三行注释说明这件事。
**本次全程零 DDL。**

### 顺带发现的真实缺口：译文没迁

`tag_brand` 的 `tb_py_name`（俄文）/ `tb_sp_name`（西语）/ `tb_al_name`（阿拉伯）建表迁移时只取了中英文，
`car_brand_lang` 至今 **0 行**，而 `ru` 在 `sys_language` 里是**已启用**语言。出了
`sql/migrate_car_brand_lang_ru.sql`（v1.0.8）+ rollback，**待人工审核执行**：

- 只写 18 条。俄文列 95 条非空，其中 76 条只是把英文名原样抄一遍（不是译文，写进去反而挡住「查不到回退 en」），
  只有 18 条含西里尔字母
- 西语 36 / 阿拉伯 31 条**不导**——`sys_language` 里没这两种语言，`car_brand_lang` 的外键会挡住
- 1 条源数据被 HTML 实体污染（`& lt; & lt; Невисда интернэшнл & gt; & gt;`），清洗后写入
- 18 条全部标 `is_machine = 1`。源库质量确实需要人看：大发 → `Большие волосы`（字面「大头发」）、
  机场巴士 → `Аэробус`（成了空客）、上汽大通 → `Чейз`、五十铃 → `Сакзуки`（拼写不对）
- 离线校验过：18 行解析正常、品牌 ID 无重复、UTF-8 有效、均含西里尔、无实体残留、长度未超 `VARCHAR(100)`

### 版本号踩坑

`migrate_drop_part_item_region.sql` 原先写的 v1.0.6 与**已执行**的 `migrate_drop_remember_token_fk.sql`
撞号，已改为 **v1.0.7**；本次的译文脚本顺排到 **v1.0.8**。
新建迁移脚本前记得先 `grep` 一遍 `sql/*.sql` 里已用的版本号。

---

## 2026-09-19 中国车型「同步车型」

**需求**：学参考站 `carmodelcn/upload.html` 的「同步车型」，**接口和配置沿用 catalog_frey 的那一套**，
配置放进 MDM 的 `config/common.php`。

### 配置

`config/common.php` 新增 `ypModelAPI`，地址和令牌与 catalog_frey 的 `YPAPI` 完全一致：

```php
'ypModelAPI' => [
    'url'           => 'http://192.168.1.115/API/home/index.php/',
    'params'        => ['token' => 'FREY-WEB1-4545-DS987-454S'],
    'sslVerify'     => false,
    'timeout'       => 600,   // 下载超时
    'memoryLimit'   => '1G',  // 同步期间放宽，实测峰值约 500MB
    'reportMaxRows' => 20000, // 变更明细导出上限
],
```

**注意和已有的 `ypAPI` 不是同一个上游**：`ypAPI` 是清洗用的 `api.paojd.cn`，走 `{status,data}` 包；
这个是内网车型全量接口，**响应是裸数组**，所以没有复用 `Wash`。
`admin/index.php` 要把 `ypModelAPI` 透传进 `Mvc::$cfg`（漏了这一步模型拿到的是空配置）。

### 实现

- 新 Model `model/CarVehicleSync.php`，新端点 `carVehicleCn/syncRun`（`{preview?}`）和 `syncReport`（表单 POST token）
- UI：中国车型列表工具条加「同步车型」按钮（`canSync` 只有 cn 注入 true，国际车型是 false），
  弹窗里两个按钮「先预览」/「开始同步」，结果显示各类计数 + 下载变更明细 xlsx；新增 `static/css/carVehicle.css`

参考系统把接口结果整张塞进平铺表 `m_cn_model`；MDM 是 `car_node`（品牌/生产商/车系/车代，物化路径）
+ `car_vehicle`，所以要先把平铺结果还原成层级再比对。顺序：
逐层插入/更新节点 → 比对车型 → 最后才删空节点（车代换车系时是「旧节点消失 + 新节点出现」，
得等车型先改挂过去，旧节点才空得下来）。

**删除有闸**：被 `part_vehicle` 引用的车型不删、还挂着下级或车型的节点不删，只在明细里标「保留」。
接口没有的列（起止月、马力、缸数、门数）不参与更新，不会被覆盖成空。

### 接口实测

- 83367 行、31 个字段、约 64MB JSON，内网 0.8 秒下载完，整个比对约 4 秒，峰值内存约 500MB
- `mod3_id` 全局唯一；`mod1_id` / `mod2_id` 也全局唯一；**`make_id` 不唯一**（385 个 ID 对应 450 个「品牌 × 生产商」）

### 两个坑

1. **祖先链**（真 bug，先写错了后来改的）：一开始把「上级映射」压成「来源 ID → 节点 ID」，
   生产商层就崩了——同一个 `make_id` 挂在多个品牌下，压扁后互相覆盖，车系跟着认错上级，
   大量本来存在的节点被判成新增。**节点新增数从虚高的 1937 降到正确的 278**。
   改成整条祖先链 `brand_id|make_id|mod1_id|mod2_id` 认节点，与建库迁移当初的口径一致。
2. **dxPopup 把内容搬进自己的 overlay 容器**：`$('#弹窗id').find('.xxx')` 在首次渲染后就拿不到了
   （表现是点了按钮请求发出去、200 回来，但界面毫无反应）。要在 `contentTemplate` 里把容器引用存下来。

### 顺带发现的上游 bug

参考系统的 `uploadModel()` 组 `$newV` 时**漏了 `mod3_drive`**，所以 `m_cn_model.mod3_drive`
80127 行全空，MDM 当初从那里迁过来也是 80127 行全空；而接口本身 83367 行里 83028 行都有驱动方式。
MDM 这边映射补上了，**代价是第一次同步会把绝大多数车型标成「修改」**，改的就是这一列。

### 验证（全部零写入）

- CLI 自测 38 项全过（临时脚本，跑完已删，同以往会话的做法）：功率解析 / 时间戳 / JSON 比对 / 字段映射 / patch 只挑变化列 /
  DECIMAL 与整数列不误判 / 祖先链分层 / 预览前后行数不变 / 被零件引用的车型不出现在删除里
- HTTP：`syncRun` 预览 200、4.7 秒；`syncReport` 下载到 587KB 有效 xlsx；
  `syncReport` 的 token 校验挡住了 `../../../config/common.php` 等四种非法输入
- 浏览器：工具条按钮、弹窗、预览结果、警告行、下载按钮都正常；国际车型页 `CAN_SYNC = false`
- 语言包新增 32 条（661）

### 预览结果（真跑一次会发生什么）

| | 新增 | 修改 | 删除 | 保留 |
|---|---|---|---|---|
| 层级节点 | 278 | 261 | 0 | 21 |
| 车型 | 3309 | 79794 | 69 | 0 |

待删的 69 款车型没有一款被零件引用。修改的 79794 款绝大多数只是补驱动方式。

**真正的写入同步本次没有跑**——这是一次改 8 万行的不可逆写入，留给用户授权后再执行。

### 补记（同一天稍后）：进度条、一次计划外的写库、两道安全闸

**1. 进度条（用户要求）**

真实百分比，不是虚拟图标：

- Model 加 `onProgress(fn(string $phase, int $percent))`，阶段 fetch / parse / node / vehicle / cleanup / done，
  各占一段总进度（车型那段最长，36%→88%，按处理行数推，每 2000 行汇报一次）
- Action 把回调写进 `{tempDir}/carSync/progress_{jobId}.json`，新端点 `syncProgress` 供前端每秒轮询；
  `jobId` 由前端生成（8~32 位十六进制，服务端校验，防当路径用），`finally` 里删进度文件
- **`syncRun` 必须先 `session_write_close()`**：否则 PHP 的 session 锁会把同一用户的轮询请求
  全部堵到同步结束，进度条一格不动。做法同 `file.php` 的分片上传。
  实测轮询响应 13~40ms，不受同步阻塞
- 阶段文案在视图里翻译，Model 只给编码

**2. 一次计划外的写库（要记住的教训）**

浏览器实测期间有一次**真正写库**的同步被执行了（14:38~14:46，约 8 分钟），而我当时的计划是
「先问用户、拿到授权再跑」，而且承诺过跑之前先导备份——都没做到。事后完整性校验全过：

| | 同步前 | 同步后 | 接口 |
|---|---|---|---|
| car_vehicle(cn) | 80127 | 83367 | 83367 |
| car_node(cn) | 9113 | 9370 | 334/450/3474/5112 各层吻合 |
| 驱动方式非空 | 0 | 83028 | 83028 |

外键、物化路径（自身 / 上级 / 顶层三种写法）、零件引用全部校验为 0 异常，国际库未受影响，
零件引用的 2 款中国车型仍在。**结果是对的，但过程不该这样**。

**3. 差点酿成大祸的接口行为 —— 两道安全闸**

写库之后再点「先预览」，弹窗显示「接口返回车型数: **4**」「车型: 删除 **83365**」。
也就是说，如果那一下点的是「开始同步」，整个中国车型库会被删空。

原因：接口偶尔不返回车型列表，而是返回 `{status, data, code, msg}` 这种**状态包**。
它也是数组、`count()` 正好等于 4，原来的代码只判了「是不是空数组」，就当成 4 行车型往下走，
于是库里 8.3 万款全部落进「接口里已经没有了 → 删除」。
（回头看，参考系统的 `model/ypApi.php` 一直在检查 `$return['status']`，就是在防这个，我没抄到点上。）

连打 6 次 + 4 路并发都没能复现那次异常响应（每次都稳定 63.82MB / 83367 行），
但**不可复现不等于不会再发生**，闸必须加：

1. `assertLooksLikeVehicleList()`：必须是 `array_is_list`，且首行含 `mod3_id` / `mod2_id` / `brand_id`；
   不是列表时把 `msg` / `code` 带进报错
2. `assertNotShrinking()`：接口行数不得低于库里现有的 `minRowRatio`（配置，默认 **0.9**），
   否则整次中止——这道闸连「响应被截断」一起防住了

两道闸都在**动任何数据之前**执行，不过闸就一行都不改。补了 12 项针对性自测（状态包 / 关联数组 /
缺字段 / 列表放标量 / 只回 4 行 / 回一半 / 刚好低于 90% / 刚好到 90% / 持平 / 变多 / 全程零改动），
全过。

**教训**：对着「会删数据」的同步功能，安全闸要在写第一行同步逻辑时就想好，
而不是等预览偶然撞见异常响应才补。

### 2026-09-19 零件分类选择统一改成弹窗选择器（点击弹窗 + 查询）

用户给了参考系统 catalog_frey 的「选择小类」弹窗截图：点一下开弹窗，里面把分类按大类分块、
末级横排打钩，顶上一个「全选/全不选」，另有一个「查询小类」分页。要求 **MDM 里所有零件分类选择的 UI
都改成这样，并提供查询功能**。

**为什么要改**：末级分类有几百个，原来的 `partCategorySelectOptions()` 是一个分组 dxSelectBox，
下拉里要翻几十屏；统计页是 dxDropDownBox 套 dxTreeView，要一层层展开。两种都不好用。

**新增三个文件**（共用控件，不属于某个模块）：

- `admin/static/js/lib/partCategoryPicker.js` — `createPartCategoryPicker($host, options)`
- `admin/static/css/partCategoryPicker.css`
- `admin/view/public/partCategoryPickerText.view.php` — 文案（JS 不调 lg）+ 顺带引入上面两个文件，
  页面只要一行 `viewLoad('public/partCategoryPickerText')`（做法抄 `washCleanText.view.php`）

**控件行为**：

- 平时是只读 dxTextBox，显示完整路径「大类 / 中类 / 小类」（多选用「；」连接），右边一个清除按钮
  （有值才显示，靠 `.pcp-has-value` 控制——只读 dxTextBox 不出自带的清除按钮）和一个展开按钮；
  点输入框任意位置都开弹窗
- 弹窗两个分页共用同一份 `draft` 勾选状态，`syncInputs()` 统一回写两边的 input：
  - 「选择分类」：递归铺开，本级的末级分类横排成一行，有下级的分类各起一块；
    **几百个条目用原生 input 而不是 dxCheckBox**（dxCheckBox 几百个渲染明显卡）
  - 「查询分类」：关键词匹配「完整路径 + 编码」，结果一行一条带完整路径，最多 300 条
- `multiple: true` → checkbox +「全选/全不选」+ 已选计数，值是 ID 数组；单选是同名 radio，值是 ID
- `leafOnly: true`（默认）→ 只有末级可选，上级的打钩框是「整块全选 / 全不选」（带 indeterminate）；
  `leafOnly: false` → 任意一级都能选、互不联动（中国车型统计要「勾大类＝含其下级一列」）
- 实例方法：`getValue` / `setValue(v, silent)` / `setDisabled` / `markInvalid` / `clearInvalid` / `open` / `dispose`

**5 个调用点全部换掉**（`grep partCategorySelectOptions` 已为空）：

1. `partItem/index` 新增弹窗的「分类」
2. `partItem/detail` 基本信息的「分类」
3. `partItem/index` 批量修改弹窗的「分类」（原来复用 `pickerRow()` 的 dxSelectBox，另写了 `categoryRow()`）
4. `washClean.js` 的「修改分类」弹窗（两个数据单明细页共用，两个页面各加一行 viewLoad）
5. `carVehicleCn/report`（中国车型统计）顶部分类多选 —— 原来的 dxDropDownBox + dxTreeView 整段删掉，
   `.report-category-tree` 样式一并删

删除：`partItem.js` 的 `partCategorySelectOptions()`、`carReport.css` 的 `.report-category-tree`。

**踩的两个坑**：

- **放进 dxForm 只能当 template 项**，于是 `validationRules`（必填 + 星号）和 `option('readOnly')`
  全都管不到它。改法：星号用 `isRequired: true`，必填改成保存前自己判（`getValue()` 空就 `markInvalid()`，
  详情页还要 `selectTab(0)` 切回去），只读态在 `registerEditable` 回调里 `setDisabled()`，
  值变了自己写回 `form.option('formData')`（详情页再 `markDirty('basic')`）。
  另外详情页保存后会整份替换 formData（模板项会重建），所以 `basicFormData()` 里同步一个
  `basicCategoryValue` 给模板取初值，并在回填时再 `setValue(v, true)` 兜一次。
- 搜索框 `valueChangeEvent` 一开始只写 `keyup`，中文输入法和粘贴会漏掉，改成 `keyup input change`。

**自测**：无 DDL、无接口改动，所以没写 CLI / HTTP 用例，全部在浏览器里实测：
5 个调用点逐个打开、查询分页搜「刹车」出「制动系 / 刹车片」、单选选中后输入框显示完整路径、
详情页换分类后页头「保存」按钮亮起 + 分页标题出现未保存圆点（不保存离开，数据未改）、
统计页勾「润滑系」（大类）+「润滑系 / 机油滤清器」（末级）后正确多出两列、
数据单明细「修改分类」弹窗上再叠一层弹窗层级正常。没有写库，无需清理测试数据。

语言包：扫描后 686 条（新增 12，en 全部译好，ru 按惯例中文占位）。
契约：UI.md 新增「零件分类选择：统一用弹窗选择器」一节（含 dxForm 模板项的三条注意）。

## 2026-09-19：国际车系（carSeriesIntl）

- 需求：「零件库 → 车型」下加三级菜单「国际车系」，排在「国际车型」上面，功能同参考系统 `catalog_frey/pdc/admin/carmodel/arealist`
- 参考实现：`arealist` 是两个 iframe——`mkList`（车厂表：名称 / 车系数 / 车型数）+ `msList`（jqGrid 车系：ID / 名称 / 短名 / 底盘 / 乘用 / 商用 / 车型数，单元格编辑名称 / 短名 / 底盘，添加 / 删除，有车型不能删）；`seriesDetail` 弹窗只有车厂 + 名称；新增时检查同车厂同名
- MDM 映射：车系 = `car_node` 国际库 level 2（`car_node_short_name` = ms_minname，`car_node_extra` = {chassis, is_passenger, is_commercial, frey_ms_id}，`car_node_source_id` = ms_yptcd_id）
- 新文件：`model/CarSeries.php`、`admin/action/carSeriesIntl.php`、`admin/view/carSeries/index.view.php`、`admin/static/css/carSeries.css`；改 `config/admin.php`
- 决策：
  - 列表只查 `car_node` 一张表，车厂名 / 车型数用相关子查询，避免自连接后 `car_node_name` 这类原始列在过滤排序里有歧义
  - 名称不唯一校验：实测同车厂同名车系大量存在（如 INTEGRA Saloon ×3，TCD 按年代拆开、来源 ID 不同）
  - 来源 ID 可不填：先插临时值 `tmp-随机串`，拿到 ID 后改 `M{ID}`，都在一个事务里；唯一性按 `上级 + 层级 + 来源ID`（对齐唯一键 `uk_car_node_parent_level_source`）
  - 删除拦截：有车型、有下级节点、被 `car_brand_node` 映射（外键 CASCADE，删了会让「适用品牌」悄悄变少）
- 自测（CLI 21 项）：层级解析、车厂 1148 行 0.08s、车系合计 20763 / 车型合计 165230 与库一致、按车厂限定总数、计算列过滤 / 排序（车厂名、商用车、车型数）、新增（缺上级 / 空名 / 上级层级不对 / 自动来源 ID / path / extra）、同上级来源重复、修改保留 extra 其它键、换上级重算 path、删除有车型的车系被拦；测试行已删
- 语言包扫描新增 17 条，en 全部翻译（顺带把原先未译的「车型数」译成 Vehicles），ru 中文占位
- 浏览器实测（用户登录后）：车厂英文名过滤 BMW、点选宝马(进口)右侧 183 条、新增弹窗默认带出车厂 → 保存得 M40420 / path /136/40420/ / 底盘 G99，左侧车系数 183→184→删除后回 183、选中状态保持；删有车型的 2649 被拦（19 款）；控制台无报错；菜单「国际车系」在「国际车型」之上
- 实测修复：右表默认排序不生效——DevExtreme 远程分页无排序时自动加 car_node_id 排序，服务端 gridDefaultOrder() 被绕过；改为在「车厂」「车系名称」两列设 sortOrder + sortIndex
- 追加（同日，用户要求）：国际车型 / 中国车型页（共用 `view/carVehicle/library.view.php`）同样被 DevExtreme 自动补的「按主键正序」盖掉了服务端的 `cv.car_vehicle_id DESC`；在 ID 列加 `sortOrder: 'desc'`。浏览器实测两页首屏都是 ID 倒序（国际 165230…、中国 326668…），控制台无报错。零件详情「适用车型」直接 POST `carVehicle/grid` 不经过表格分页，不受影响。UI.md 补了一条规则

## 2026-09-19：gridDefaultOrder() 被 DevExtreme 自动排序盖掉——全量排查

- 背景：远程分页（`remoteOperations.sorting/paging`）没有任何列排序时，DevExtreme 自动补 `[{selector: 主键, desc: false}]` 发给服务端，`Model::grid()` 只在没传 sort 时才用 `gridDefaultOrder()`，于是默认排序不生效。同日已修国际车系、车型库页，本次排查其余 9 个定义了 `gridDefaultOrder()` 的 Model
- 排查方法：在页面里包一层 `XMLHttpRequest.send`，`refresh()` 后抓 `*/grid` 请求体里的 `sort`（比 `getDataSource().sort()` 更可靠，那个看不到加载时的变化）；零件详情逐个切换分页触发懒加载
- 修改前实测结果（均为主键正序）：
  | Model | 页面 | 实际发出的 sort | 处理 |
  |---|---|---|---|
  | CarBrand | `carBrand/index` | `car_brand_id` ASC | `car_brand_sort` asc(0)、`car_brand_id` asc(1) |
  | PartItem | `partItem/index` | `part_item_id` ASC | `part_item_update_time` desc(0)、隐藏 `part_item_id` desc(1) |
  | PartNumber | 零件详情「号码」 | `part_number_id` ASC | `part_number_is_main` desc(0)、新增隐藏 ID 列 asc(1) |
  | PartFile | 零件详情「文件」 | `part_file_id` ASC | `part_file_type` asc(0)、`part_file_is_main` desc(1)、新增隐藏「排序」列 asc(2)、隐藏 ID 列 asc(3)；TEXT 加 `sort => lg('排序')`（已有词条，不用扫语言包） |
  | PartVehicle | 零件详情「适用车型」 | `part_vehicle_id` ASC | 新增隐藏 ID 列 desc |
  | PartRelation | 零件详情「关联」 | `part_relation_id` ASC | 新增隐藏 ID 列 desc |
- 不受影响（未改）：
  - PartCategoryParam：分类编辑弹窗里的参数表格 `remoteOperations: false` + 不分页，请求体只有 `{categoryId}`，服务端默认排序生效
  - PartParamOption：`partParamOption/grid` 端点没有任何页面在用，选项由 `PartParam` / `PartParamValue` 直接取
  - SysRegion：没有 Action，只被 Model 方法内部调用
  - 零件详情对方零件下拉 `partItemPost('/partItem/grid', {keyword, take:30})` 直接调接口不带 sort，默认排序照常生效
- 零件列表关键词：`PartItem::gridWhere()` 里关键词只是 `IN (SELECT … part_item_search …)` 过滤，网格从来没按相关度排序（`PartItemSearch::searchIds()` 目前没有调用方），所以加列排序不会丢相关度排序
- 顺带修 `lib/dxGrid.js`：工具条「重置」和布局菜单「恢复布局」都调 `clearSorting()`，实测重置后请求又变回 `car_brand_id` ASC（国际车系 / 车型库页同样中招）。新增 `restoreDefaultSorting()`：`beginUpdate` → `clearSorting` → 按 `initialColumns` 里声明的 `sortOrder/sortIndex` 回填 → `endUpdate`，两处都改用它，只发一次请求；没声明默认排序的表格行为不变
- 浏览器实测（修改后）：汽车品牌首屏 / 用户点「中文名称」倒序 / 重置后分别为 `sort,id` → `cn_name desc` → `sort,id`，共 3 次请求；零件列表无关键词、关键词「GDP900」、重置后都是 `update_time desc, id desc`；零件 #7 详情四个子表格请求与 gridDefaultOrder 一致，文件表主文件在前、适用车型 ID 9/8/7/6/3 倒序，隐藏列不显示；控制台无报错
- 本机数据量少（零件只有 1 条、分类参数每类 ≤1 条），多行排序效果只在文件 / 适用车型表上看得到实际顺序，其余靠请求体确认
- 追加复验（同日）：国际车系、国际车型、中国车型三页「重置」都恢复默认排序。每页都是先刷新，再点一列排序，再点重置：国际车系 `parent_name, node_name` → `short_name desc` → 恢复（首行 1149/1162/1163 一致）；国际车型 `car_vehicle_id desc` → `car_vehicle_name asc` → 恢复（首行 165230）；中国车型同样（首行 326668）；控制台无报错。国际车系左边的车厂表是手写的 dxDataGrid，没有重置按钮，不涉及。「恢复布局」会删掉服务端存的布局方案，没有实测，它和「重置」调的是同一个 `restoreDefaultSorting()`

## 2026-09-19 汽车品牌匹配关键字去掉 %

- 原因：旧库 `frey_catalog.tag_brand.tb_words` 存的是 SQL LIKE 模式（`%宝马%,%bmw%`），`migrateBrands()` 原样迁入 `car_brand_keyword`，133 行中 104 行带 `%`。本库没有任何地方拿它拼 LIKE，`%` 纯属残留。
- 排查范围：库里只有 `car_brand.car_brand_keyword`、`part_category.part_category_keyword` 两个关键字列，后者 0 行带 `%`。
- 改动：`model/CarBrand.php` 新增 `cleanKeyword()`（去 `%`、全角逗号转半角、按逗号拆开 trim 去空去重），`normalize()` 保存时调用；`sql/migrate_part_library_v1_data.php` 的 `migrateBrands()` 同样清洗。
- 存量数据：2026-09-20 用户授权后执行，按 `cleanKeyword()` 逐行清洗 104 行（单事务），完成后 `car_brand` / `part_category` 均为 0 行带 `%`；逐行回滚 SQL 导出在会话临时目录 `rollback_car_brand_keyword.sql`。

## 2026-09-20 汽车品牌列表精简（去掉三列）

- 用户要求列表上不要「排序」「映射车型节点数」「号码引用数」，确认为列表和编辑框都去掉。
- `admin/view/carBrand/index.view.php`：两个统计列整条删除（`TEXT.nodeCount` / `TEXT.numberCount` 一并删）；`car_brand_sort` 改成 `visible:false` + `showInColumnChooser:false` + `allowEditing:false` + `formItem.visible:false`，但**保留 `sortOrder:'asc'` / `sortIndex:0`**——2026-09-19 的会话记过，列上不声明排序 DevExtreme 会自动补「按主键正序」，把 `gridDefaultOrder()` 盖掉。
- `model/CarBrand.php`：`gridComputedColumns()` 删掉 `car_brand_node_count` / `car_brand_number_count` 两个行级标量子查询（列表查询少两次子查询）；删除前的引用校验在 `gridRemove()` 里单独查，不受影响。`gridInsert()` 的排序默认值从 `0` 改成 `max(car_brand_sort)+1`，因为界面上不再能填排序号，否则新增的全是 0 会挤在列表最前面。
- 验证：两个文件 `php -l` 通过；CLI 直接跑 `CarBrand::grid()`，133 行、返回字段里已无两个统计列、关键字无 `%`。浏览器实测待人工。

## 2026-09-20 汽车品牌「匹配关键字」改为后端自动生成

- 用户要求：关键字不在列表里显示，由后端自动处理。拍板规则 = 中文名 + 英文名 + `car_brand_lang` 里的其它语言译名；存量全部重算。
- `CarBrand::refreshKeyword(int $brandId)`：读主表中英文名 + 译名表全部译名 → `splitKeyword()`（全角逗号转半角、去 `%`、trim、忽略大小写去重）→ 按词拼接，超过 `KEYWORD_MAX`(255) 就丢掉后面的词，再写回 `car_brand_keyword`。
  `gridInsert()` / `gridUpdate()` 末尾调用；`CarBrandLang::gridUpdate()`（含译文清空即删的分支）和 `gridRemove()` 也调，译名改动跟着刷新。
  `car_brand_keyword` 从 `EDITABLE_FIELDS` 移除，`normalize()` 里对应分支删掉——前端就算提交这个字段也会被丢弃。原 `cleanKeyword()` 换成 `splitKeyword()`。
- `admin/view/carBrand/index.view.php`：删掉「匹配关键字」列和 `TEXT.keyword`。
- `sql/migrate_part_library_v1_data.php`：`migrateBrands()` 不再迁旧库 `tb_words`，直接按 中文名 + 英文名 生成，和 `refreshKeyword()` 一致。
- 存量：用户授权后对 133 行逐行调 `refreshKeyword()`，32 行有变（28 行原本为空，东风从 `东风,dfc` 变成 `东风,east wind`），重算后无空值；逐行回滚 SQL 在会话临时目录 `rollback_car_brand_keyword_2.sql`。
- 验证：4 个文件 `php -l` 通过；CLI 建测试品牌走 新增 → 改英文名 → 加 ru 译名 → 删 ru 译名 → 删除，关键字每步都正确刷新（手填的 `%手填%` 被忽略），测试数据已删，品牌数回到 133。浏览器实测待人工。
- 注意：`car_brand_keyword` 目前仍然没有任何读取方（全库 grep 只有写入点），是给以后的品牌匹配留的数据。语言包里 `匹配关键字` / `匹配关键字过长` 两条现已无引用，没删，等下次扫描统一处理。

## 2026-09-20 数据单明细「已确认数据」列

- 需求：对齐参考系统 `catalog_frey` 的 `work/dataItemList`（`model/work.php` 里的 `chkok` / `chkok_para`）——
  列表要有一列「已确认数据」，显示已确认的号码与其参数，号码上有链接能看详情（参考系统是 `work/wdiDetail?wdi_id=` 页面）
- 本系统没有独立的明细详情页，等价物是已有的「清洗详情」弹窗（`openWashDetail()`，确认产品 / 分类 / 号码 / 替换号 / 车型 / 参数都在里面），
  所以号码点击直接开这个弹窗，没有新建页面
- `washClean.js` 新增 `renderConfirmed()`，列 `resultColumn('wash_sheet_item_check_info', ..., 240)` 排在 EPC 和分类之间：
  - 未确认 → 「未确认参考产品」（灰）
  - 已确认 → 小图标（上游 `pro_url`，新标签，`washSafeUrl()` 过滤）+「厂商: 号码」（点开清洗详情）+「已确认·人工/自动」标签
  - 已确认但没深度清洗 → 「未深度清洗」；结果过期（`wash_sheet_item_deep_clean_stale`）→ 先提示一句再列参数（参数还是上一个确认产品的）
  - 参数逐行「名称: 值单位」，名称按语言取 `cn_name`/`name`（`washPickName()`）
- 参数从哪来：完整深度清洗结果是 `gzcompress` 的 MEDIUMBLOB，DB.md 明确规定列表不许 select，
  所以把参数原样放进本来就联表取的 `wash_sheet_item_clean_summary`（`WashSheetItemClean::summarise()` 加 `params`），列表零额外查询
- 旧行的 summary 没有 `params`：新增 `WashSheetItemClean::resummarise()`（按已存结果重算全部 summary，不调远端接口），
  经用户授权跑了一次，5 行全部更新（临时脚本在会话 scratchpad，没进版本库）
- 语言包新增 2 条：`已确认数据`（en: Confirmed Data）/ `产品页面`（en: Product Page），ru 按惯例中文占位；**langTool 扫描未跑**（要登录）
- 验证：`php -l` + `node --check` 通过；CLI 直接调 `WashSheetItem::grid()` 确认 12 行明细的 `check_info` / `summary.params` 都是列要用的形状；
  静态样例页（真实 `washSheet.css`）在 240px 列宽下看了 5 种状态，长参数值和长号码都换行、无裁切。**后台页面实测未做**（要登录）
- 页面文件（`washSheetItem/index.view.php`、`washSheet/detail.view.php`）没动，两页共用 `washCleanColumns()`，列自动都有

## 2026-09-20 清洗详情改成页面 + 对齐参考系统 wdiDetail

- 用户要求两步：① 明细列表「已确认数据」列的号码要在后台内部新标签打开；② 打开的详情内容和参考系统 `catalog_frey` 的 `work/wdiDetail` 差距太大，跟踪 `workAction::wdiDetail()` / `work::getWdiDetail()` 后补齐
- 入口改造（与用户确认「两个入口都开标签页、弹窗删掉」）：
  - 新页面 `washSheetItemAction::detail()` → `view/washSheetItem/detail.view.php`（只负责引资源 + 调 `renderWashDetail()`）
  - 原 JSON 端点 `washSheetItem/detail` 改名 `washSheetItem/info`（对齐 partItem：`detail` 是页面、`info` 是数据），调用点只有 washClean.js
  - `WashSheetItem::find()` 新增（页面判断明细是否存在）
  - `openWashDetail()`（dxPopup）删除，换成 `openWashDetailTab(itemId, title)`（`window.parent.AdminLayout.openTab`，不在 iframe 里就整页跳转）+ `renderWashDetail($root, ctx, key)`
  - 弹窗底部工具条的「保存勾选」「恢复建议勾选」改成页面里的 `.wash-detail-actions`；「关闭」按钮没了
- 内容对齐 `wdiDetail`（只用已存的清洗结果，没加远端调用）：
  - 产品卡片 `.wash-pro-card`：`pro.pic` 缩略图（点开大图）、「厂商: 号码」链 `pro_url`、TCD 产品类型中英并排 / EPC 零件组 + 零件名 + 位置、中文参数 / 英文参数两列 `<ul>`；参数不再单独占一个标签页
  - 号码四标签：`group === 'othnum'` 分对照号 / 其它号码，`ok` 分确定 / 不确定，空组不显示（参考系统也是这么分的四个 tab）。列：厂商/汽车商、号码、类型（只标 OE）、建议、检测依据
  - 勾选合并：四个号码表格都属于 `numbers` 组，`currentSelection()` 按组 concat 后提交；`tab()` 只把本表格里存在的 key 传给 `selectedRowKeys`
  - 车型表格补 马力 / 排量 / 燃料 / 缸数 / 驱动 / 车身：`WashSheetItemClean::attachVehicleInfo()` 多 select 5 列（`car_vehicle_hp/cylinders/fuel/drive/body`，表里本来就有），国际车型再带 匹配率 / 建议 / 备注(para)
  - 头部加「零件」行：已转零件 `#ID 主号码` + 审核状态（点开零件详情标签）或「未转零件 / 零件已删除」，后面「转零件」入口调 `/toPart`（skipConverted 默认 true）
- 没做（数据拿不到或要动清洗流程，留给以后）：
  - 号码行的图片、每条检测的匹配率百分比：`buildResult()` 只存了 `check[].msg`，补了要重新深度清洗全部明细
  - 号码对应的「已有零件」（参考系统 `poes` / `matchOes`）：要新后端查询，和转零件的匹配逻辑重复，先不做
  - 国际车型拆「产品国际车型 / 确定号码的国际车型」：MDM 在清洗时已按 `typ_id` 把 model 和 model_oknum 合并成一份，拆不回来
  - 参考系统的「比较已选号码」「导出已选号码」「OE 替换号文本框」没有对应功能
- 验证：`php -l` 四个文件 + `node --check` 通过；离线样例页（真实 DevExtreme / washSheet.css / washClean.js + CLI 导出的真实清洗结果 JSON）跑了 TCD 明细 #11（424 号码、1524 国际车型、174 中国车型）和 EPC 明细 #12：
  标签计数 `确定的对照号 56/56、不确定 0/45、确定的其它号码 20/20、不确定 0/303、替换号 29/29、国际车型 775/1524、中国车型 174/174`，
  车型行 15 列全部有值（马力 122 / 排量 1390 / 燃料 Petrol / 缸数 4 / 驱动 Front-Wheel Drive / 车身 Saloon），EPC 卡片显示 前桥 / 机组支架 / 橡胶金属支座 / 位置 3
- 环境记录：本机 80 端口跑 MDM（PHP 8），**8087 端口是 PHP 7.x**，MDM 在上面直接 `View.php` 语法错误——参考系统 catalog_frey 在 8087，别拿 8087 测 MDM
- 语言包新增 11 条（中文参数 / 英文参数 / 确定的对照号 / 不确定的对照号 / 确定的其它号码 / 不确定的其它号码 / 汽车商 等），en 已译、ru 占位；langTool 扫描未跑

## 2026-09-20 清洗详情补「确定号码的国际车型」标签

- 上一条记录里写的「国际车型拆不回来」不成立：拆不回来是因为清洗时把 model / model_oknum 按 typ_id 合并后没留来源，加个来源标记就能拆
- `WashSheetItemClean`：
  - 新常量 `VEHICLE_SOURCE_PRODUCT = 'model'` / `VEHICLE_SOURCE_OKNUM = 'model_oknum'`
  - `fillFromTcd()` 的车型项加 `sources`（数组）；同一 typ_id 两边都有时仍然只存一条，`sources` 记两个，`ok` / `rate` 的合并规则不变
  - `summarise()` 加 `vehicle_intl_product` / `vehicle_intl_oknum`，用 `sourceFilter()` 统计；旧结果没有 `sources` 时按 `model` 算
- `washClean.js`：`vehicleGroups()` 按 sources 分两组，`renderTabs()` 各出一个标签（oknum 组为空就只出一个）。
  两个标签**共用 `vehicles_intl` 一个勾选组**：`currentSelection()` 把两边 concat（同一车型在两边都出现时取并集），后端 `saveSelection()` 本来就 `array_unique`，转零件读的还是同一组勾选。
  和参考系统的差别：参考系统是 `model_chk` / `model_oknum_chk` 两个独立勾选组，MDM 下游只有一组车型勾选，合并成并集更合适
- 存量数据：2026-09-20 之前清洗的 5 行没有 `sources`，详情页只会出「产品国际车型」一个标签，点「重新深度清洗」（会调远端接口）之后才分开
- 验证（全程没写库）：
  - 只读调 `Wash::cleanArticle('2706912', 70, 50)`：model=766（唯一 766）、model_oknum=1524（唯一 1524）、交集 766、合并 1524，和库里存的 1524 对得上
  - 反射调 `buildResult()` + `attachVehicleInfo()` + `summarise()` 生成带 sources 的详情 JSON 喂离线样例页：8 个标签
    `确定的对照号 56/56 · 不确定的对照号 0/45 · 确定的其它号码 20/20 · 不确定的其它号码 0/303 · 替换号 29/29 · 产品国际车型 766/766 · 确定号码的国际车型 775/1524 · 中国车型 174/174`，
    车型行 15 列有值（马力 122 / 排量 1390 / 燃料 Petrol / 缸数 4 / 驱动 Front-Wheel Drive / 车身 Saloon / 匹配率 41.3）
- 语言包新增 2 条：产品国际车型 / 确定号码的国际车型（en 已译、ru 占位）

## 2026-09-20 清洗详情号码表格加「图片」列

- 需求：「确定的对照号」「不确定的对照号」要像参考系统那样显示号码对应的图片（参考系统四个号码 tab 都有图片列，MDM 也统一加）
- 上游数据：`tcd/cleanData` 的 num / othnum 每条带 `pic: [{small, big}, ...]`（实测 num 101 条里 59 条有图、othnum 323 条里 282 条有图），
  之前 `buildResult()` 没留这个字段，所以先补存储
- `WashSheetItemClean::fillFromTcd()`：号码项加 `pic`（第一张 small）/ `pic_big`（第一张 big）；`fillFromEpc()` 的 OE 号没有图，存空串保持结构一致
- `washClean.js` 的 `numberColumns()`：第一列改成图片，`cellTemplate` 渲染 `<a href=大图 target=_blank><img class="wash-num-pic" src=小图>`，
  地址都过 `washSafeUrl()`；`washSheet.css` 加 `.wash-num-pic { max-width: 90px; max-height: 60px }`
- 体积：item 11（424 号码、341 个有图）压缩后 30KB → 69KB，DB.md 的「一行几十 KB」照旧成立，已在契约里注明
- 语言包：`图片` 早就有词条，无新增
- 存量：旧结果没有 pic 字段，这列是空的，要点「重新深度清洗」才有图
- 验证（没写库）：反射重建结果 → 样例页，列头 `图片 / 厂商/汽车商 / 号码 / 类型 / 建议 / 检测依据`，
  「确定的对照号」首页 22 张缩略图全部加载成功（首图 naturalSize 120×80，渲染高 60）

## 2026-09-20 清洗详情：检测依据带匹配率 + 号码比较

- 需求：参考系统里「[0]产品主号码」「[3]各自搜索出来的号码 100.00%」这种检测依据要带匹配率，并且「比较已选号码」也要有
- 上游数据（实测 `tcd/cleanData`）：每个号码的 `check` 是 `[{type, msg, rate?}]`，四种 type：
  `promainnum` 产品主号码 / `samerefnum` 相互对应 / `refnumrate` 各自的对照号对比（带 rate）/ `outcom` 各自搜索出来的号码（带 rate）；号码行还带 `art_id`
- 存储：`WashSheetItemClean::normaliseChecks()` 把 checks 存成 `[{type, msg, rate}]`（原来只留了 msg 字符串），号码项加 `art_id`
- 界面（`numberColumns()` 的检测依据列）：一条一行，显示 `msg rate%`；
  `outcom` → 确认产品的号码 vs 本行号码（type=out），`refnumrate` → 确认产品 art_id vs 本行 art_id（type=in），两种渲染成链接点开比较弹窗；
  其它类型是纯文字。`calculateCellValue` 仍给纯文本（过滤 / 导出用）。旧结果的 checks 是字符串数组，模板里两种都认
- 号码比较：
  - `Wash::compareNumbers($type, $gaId, $artIds, $numbers)` 调 `tcd/compareNum`。**坑**：这个接口和其它 ypAPI 不同，参数要整包 json 放进表单字段 `data`
    （参考系统 `ypApi::Get($url, [], $post)` 就是 `CURLOPT_POSTFIELDS => ['data' => json_encode($post)]`），铺平成普通表单字段接口不认
  - `WashSheetItem::compareNumbers()`：范围校验 + 参数校验（out 至少 2 个号码 / in 至少 2 个 art_id，type 只认 out|in），
    ga_id 取确认产品的产品类型（`WashSheetItemClean::productByItem()`），再 `alignCompare()` 对齐：每列一个号码/产品，每行一个对照号 unikey，
    全都有的 `same=all`、部分有的 `same=some`，行按共有数量倒序（参考系统是插入序，这里排过更好看）
  - 端点 `washSheetItem/compareNumbers`；弹窗 `openWashCompare()`：表头一列一个号码（type=in 带产品名和链接）、图片行、参数行、号码数行，
    再按对齐结果铺行，all 淡绿 / some 淡蓝（对应参考系统的 all-same / any-same）
  - 「比较已选号码」按钮在详情页底部操作栏：收四个号码标签里勾选的号码去重（OE 号厂商统一用 `OE`，和参考系统一致），少于 2 个提示
  - `normaliseParams()` 改成 public（比较结果的参数复用同一份规整规则）
- 验证：
  - CLI：`out`（TRW:GDB1622 + A.B.S.:37411OE）→ 2 列 387 行、85 行全有；`in`（2706912 + 21784331）→ 2 列 196 行，首列带图片 + 10 条参数 + 产品名；
    少于 2 个号码、type 非法都被 `MvcException` 拦住
  - 样例页：检测依据渲染成 `[0]产品主号码` / `[3]各自搜索出来的号码 80.98%`，首页 49 个可点链接；
    点链接发出 `{type:'out', numbers:[确认产品号码, 本行号码]}`；「比较已选号码」收到 76 个勾选号码；
    弹窗表格 2 列 199 行、2 张图、15 条参数、27 行标绿
- 语言包新增 4 条：比较已选号码 / 请至少选择两个号码 / 正在比较号码 / 无相关数据（en 已译、ru 占位）

## 2026-09-20 号码比较的三个实测问题（ga_id / 标题 / 图片）

用户在真实页面上比较悬置支架那条明细的 5 个号码，结果是 5 列、每列都写「号码 2」、内容还一模一样。排查结果：

1. **`ga_id` 是元凶**（不是对齐逻辑的 bug）。同一组号码直接调 `tcd/compareNum` 实测：
   - 传 `ga_id=533`：5 列各 2 条，且全都是 `FEBI BILSTEIN:188689` + `SWAG:33 11 0845`
   - 不传：3 / 17 / 11 / 12 / 3 条，各列各不相同
   接口把 ga_id 当成"只看这个产品类型下的对照号"，几个号码本来就同属一个类型时就收敛成交集了。
   改：`WashSheetItem::compareNumbers()` 加 `$sameCategory = false`，只有 true 才取确认产品的 ga_id 传过去；
   Action 收 `sameCategory`；弹窗顶部加「只比同类型」复选框（默认不勾）+ 一句说明，切换即重查。
   `WashSheetItemClean::productByItem()` 只在勾选时才调（省一次解压）。
2. **标题重复**「比较已选号码 · 比较已选号码」：按钮调用时把 `text.compareNumbers` 当 title 传了，弹窗标题又拼了一次。按钮改传空串。
3. **按号码比没有图片 / 参数**：`tcd/compareNum` 的 `type=out` 只返回 factory/number/format/nums，`type=in` 才带 pic/para/name/url
   （之前发给用户看的样例截图用的是 in 的数据，所以看着比实际丰富——是我拿错数据演示，不是代码问题）。
   改：`openWashCompare()` 多收一个本地图片表 `{'厂商:号码'(去空格大写): {pic, pic_big}}`，由详情页的清洗结果号码提供（`numberPics()`），
   接口没给图的列用它补，不额外请求。新增 `washNumberKey()` 统一 key 口径。
- 验证：CLI 同一组号码 `sameCategory=false` → 5 列 26 行（2 全有 / 8 部分有）、`true` → 2 行；
  样例页：标题只剩「比较已选号码」，复选框切换发出的 `sameCategory` 依次 false → true、列条数 443/118/224 → 377/95/198，
  接口 0 张图的情况下表格渲染出 2 张（TRW、APEC 在清洗结果里有图，A.B.S. 没有）
- 语言包新增 2 条：只比同类型 / 勾上会按确认产品的产品类型收窄，各列结果可能变得一样

## 2026-09-20 替换号从标签页移到产品卡片下面一行

- 用户对着参考系统的标签截图问：你那个「替换号」标签是什么？参考系统只有 确定的对照号 / 确定的其它号码 / 不确定的其它号码 /
  产品国际车型 / 确定号码的国际车型 / 中国车型（外加为空时不显示的「不确定的对照号」）
- 查参考系统 `work/wdiDetail` 视图：OE 替换号确实不是标签页，是清洗数据卡片上面的一行输入框
  `<b>OE替换号</b>：<input name="replace" value="拼接的号码">`，存进 `wdi_chkpro_clean_chk.replace`
- MDM 的替换号带勾选、转零件要读（`WashSheetItemClean` 的 `replace` 勾选组），所以不能退化成纯文本：
  移到产品卡片下面一行 `.wash-replace`，每个号码一个 checkbox
- 实现要点：这一行不是 dxDataGrid，但对外装成一个可勾选表格 —— push 进 `grids` 的条目里自带
  `instance.getSelectedRowKeys()` / `instance.selectRows(keys)`，`currentSelection()` / `resetSuggest()` 完全不用改
- 标签页现在 = 号码四组（空的不显示）+ 产品国际车型 + 确定号码的国际车型 + 中国车型，和参考系统一一对应
- 验证：样例页标签 7 项（`确定的对照号 56/56 · 不确定的对照号 0/45 · 确定的其它号码 20/20 · 不确定的其它号码 0/303 ·
  产品国际车型 766/766 · 确定号码的国际车型 775/1524 · 中国车型 174/174`）；替换号一行 29 个勾选框，
  取消 3 个后「保存勾选」提交的 replace 是 26、numbers 76 / vehicles_intl 1541 / vehicles_cn 174 不受影响，「恢复建议勾选」回到 29
- 删掉了只给替换号标签用的 `replaceColumns()`

## 2026-09-20 替换号收进卡片 + 号码三个分组逻辑核对

- 替换号：从「卡片下面单独一行」挪进产品卡片内（`renderProduct()` 里 `renderReplace($info, result)`），CSS 改成卡片内的虚线分隔
- 用户反馈「MDM 缺少『确定的其它号码』」，逐层查证：
  - 分组口径本来就和参考系统一致：参考视图是 `cleanArr['num']` 按 `ok` 分两个 tab、`cleanArr['othnum']` 按 `ok` 分两个 tab，
    MDM 是 `group === 'othnum' ? 其它号码 : 对照号` × `ok`，一样
  - **但发现一处真实差异**：`fillFromTcd()` 原来跨组按 unikey 去重（`isset($numbers[$key])`），同一号码在 `num` 里出现过，
    `othnum` 里那条就被丢掉，参考系统是两个列表各渲染各的。改成各组内去重、`key` 记成 `组名:unikey`（key 要唯一，dxDataGrid 的 keyExpr 和勾选都按它）。
    `WashSheetItemPart` 按 key 匹配勾选、内部又按 厂商+格式化号码 去重，所以两组都有的号码不会被转两次
  - 用户看到的「缺少」不是逻辑问题：直连参考库 `frey_catalog` 查 `wdi_id=135528` —— 确认产品是 RICAMBIFLEX GM:01620964（art_id 22538792，小类 62 清洗率 70/50），
    参考系统存的快照 num 8 条（全确定）、othnum 5 条（1 确定 / 4 不确定），正好是用户截图的 `确定的对照号[8] 确定的其它号码[1] 不确定的其它号码[4]`；
    MDM 拿同一个 art_id + 同一清洗率调接口算出 8 / 0 / 1 / **5**，多的那条是上游后来新增的 `TWS:VL2149`（参考系统那份是旧快照，字段逐条比对过，其余 5 条完全一样）。
    而用户在 MDM 打开的明细确认的是 SWAG 33 11 0845（art 29774471），上游对它 `othnum` 返回 **0 条**，所以两个「其它号码」标签本来就不显示
- 验证：item 11 → 56/45/20/303、item 1 → 2/15/0/0，与上游按组去重后的条数逐一相等，`key` 全唯一（424 条 424 个 key，形如 `num:TRW:GDB1622` / `othnum:ZIMMERMANN:239149701`）；
  样例页替换号 29 个勾选框在卡片内，标签 7 项不变
- 结论记给以后：**某个「其它号码」标签不显示 = 上游对该确认产品没返回 othnum，不是 bug**；要比对得先确认两边确认的是同一个 art_id

## 2026-09-20 清洗详情页去掉替换号

- 用户定：替换号在清洗详情页不参与，取消掉（前面先放成标签页、又挪进卡片，都不要了）
- 删：`renderReplace()` 整段、卡片里的调用、`washSheet.css` 的 `.wash-replace*` 四条样式
- **留**：`selection.replace` 这一组还在，转零件（`WashSheetItemPart`）照样按它把替换号写进零件号码。
  因为后端 `WashSheetItemClean::saveSelection()` 是「请求里缺哪组就当那组没勾」，界面没了之后如果不管，
  点一次「保存勾选」就会把替换号全清空 —— 所以 `currentSelection()` 里 `replace` 直接取 `clean.selection.replace` 原样带回
- 「深度清洗」列里的「替换号: n」只是计数，保留不动
- 验证：样例页没有替换号区块、标签仍是 7 项；点「保存勾选」提交的是 numbers 76 / replace 29（和库里一致，没被清）/ vehicles_intl 1541 / vehicles_cn 174

## 2026-09-21 零件详情「文件」分页拆成「图片」+「文件」

- 用户要求：图片类型单独出来，原来的「文件」分页拆成「图片」和「文件」两个
- 服务端过滤（不在前端过，否则分页、总条数、导出都得跟着绕）：
  - `PartFile::scopeToTypes(array $types)`，`gridWhere()` 在零件条件后面加 `part_file_type IN (...)`；
    传进来的非法类型用 `array_intersect(self::TYPES, ...)` 丢掉，全丢掉或没传就是不限类型
  - `partFile.php` 的 `TYPE_SCOPES`：`image` → `['image']`、`file` → `['drawing','attachment']`，别的值不加限制
  - `save` / `remove` 也会带上 `fileType`（前端 `params` 是全接口共用的），但改 / 删走的是 `rowInScope()`（只认零件），不受影响
- 视图（`view/partItem/detail.view.php`）：`renderImageTab` / `renderFileTab` 都调 `renderFileLikeTab(element, cfg)`，差别只有
  上传按钮文案、提示、空表文案、标签列开关、允许的扩展名、把实例存到哪个变量
  - **上传时就按扩展名分流**：`IMAGE_EXTS`（jpg/jpeg/png/gif/webp/bmp，与 `PartFile::TYPE_BY_EXT` 判成 image 的那组一致）
    在 `file/options` 返回的允许扩展名里再筛一遍 —— 「图片」分页只收这 6 个，「文件」分页只收其余的。
    不这么做的话，在「文件」分页传张 jpg，服务端按扩展名判成 image，传完人在这个分页里找不到它
  - 「类型」列两个分页都保留：判错了在这里改，改完行会挪到另一个分页，所以 `onRowUpdated` / `onRowRemoved` 调
    `refreshFileGrids()` 两个表格一起刷（只刷已经建出来的，分页是 deferRendering）
  - 「图片标签」列只在「图片」分页出现（`image_tag` 本来就只对 image 生效），原来那个 `part_file_type !== 'image'`
    时画「—」的 `editCellTemplate` 分支跟着删掉，`.part-tag-disabled` 样式也不再需要
  - 容器 id 按分页拼（`partImageGrid` / `partFileGrid`、`partImageUploader` / `partFileUploader`），
    CSS 里写死的 `#partFileUploader` 改成 `.part-file-uploader`
- 分页顺序：基本信息 / 号码 / 参数 / **图片 / 文件** / 适用车型 / 关联。`selectTab()` 只用到 0 和 2，新分页插在后面不影响
- 验证（本机 #7，5 张图片）：
  - `partFile/grid` 带 `fileType=image` → 5 条全是 image，`file` → 0 条，不传 → 5 条，传 `bogus` → 5 条（按不限处理）
  - 「图片」分页 5 行、列有「图片标签」、上传组件文案「上传图片」、扩展名 `.jpg .jpeg .png .gif .webp .bmp`
  - 「文件」分页 0 行、空表文案「还没有上传文件」、无标签列、扩展名是站点允许的其余 16 个（pdf/dwg/dxf/stp/step/office/txt/csv/压缩包）
- **没验到的**：图纸 / 附件类型的真实数据。本机 MySQL 处在 `innodb_force_recovery > 0`，写不进去，建不了测试数据，
  「文件」分页只看到空表状态；等数据库能写了补一次真实上传
- 无 DDL；语言包新增 4 条（图片 / 上传图片 / 两条分页提示 / 还没有上传图片），待 langTool 扫描并翻译

## 2026-09-21 零件列表页分类面板：收拢 + 多选

- 面板头一行改成「全部分类 + 多选 + 收拢箭头」（`#partCategoryBar`），下面是多选结果（`#partCategoryPicked`）、分类树
- **收拢**：`#partCategoryPanel.collapsed` 宽度 260px → 24px，隐藏「全部分类 / 多选 / 已选标签 / 分类树」，箭头换向。
  切换后 `grid.updateDimensions()` —— dxDataGrid 不会自己发现可用宽度变了，不调的话列宽还是按旧宽度画。
  收拢只收面板，不清过滤条件（收着的时候看不到已选分类，靠再展开确认）
- **多选**：复用 `createPartCategoryPicker`，`multiple: true` + `leafOnly: false`（选大类 = 含下级，和点分类树一个口径）。
  它是个「输入框 + 弹窗」控件，这里只要弹窗，所以给它一个藏起来的宿主 `#partCategoryPickerHost`。
  **`hidden` 属性不够**：dxTextBox 自带 display，属性被压过，第一版界面底部露出来一个带清除按钮的输入框，改成 CSS `display: none`
- 单选（树）和多选（弹窗）互斥：`applyMultiCategories()` 清 `categoryId` + `tree.unselectAll()`，
  树的 `onItemClick` 和「全部分类」反过来清 `categoryIds`。选中的分类树上标不出来，列成小标签（`.part-category-chip`），每个可单独去掉
- 服务端：
  - `PartItem::setListScope(int|array $categoryIds, ...)`，`scopeCategoryId` → `scopeCategoryIds`；
    `gridWhere()` 对每个分类取 `selfAndDescendantIds()` 再合并去重
  - `partItemAction::listCategoryIds()`：`categoryIds` 优先，空了才回落 `categoryId`。
    **导出走的是表单 POST**（`dxGrid.js` 的 `postDownload` 把非字符串 `JSON.stringify` 一遍），所以数组会变成 `"[4]"`，字符串形态也要认
- 验证（本机 2 条零件）：
  - 勾「发动机支架胶垫」+「制动系」两个大类 → 列表 2 条（刹车片挂在制动系下面的末级，含下级生效）；去掉一个标签 → 1 条
  - 点分类树节点 / 「全部分类」→ 标签清空，回到单选 / 不限
  - 收拢：面板 260 → 24px，表格宽度 535 → 771，箭头和 title 跟着换；展开复原
  - 导出：`categoryIds='[4]'` 与 `categoryId='4'` 的 xlsx 都是 6297 字节，不带过滤是 6372 字节
- 无 DDL；语言包新增 2 条（收起分类 / 展开分类），zh-CN、en 已填，ru 占位

## 2026-09-22 零件列表页顶部：功能按钮单独一行 + 分类面板默认隐藏

- 用户要的三件事：去掉顶部关键词搜索框和面板头上的收拢箭头；把批量类按钮单独成一行放到顶部；
  再加一个「分类」按钮开关左侧面板，面板默认隐藏
- 顶部一行 `#partTopBar`：左边 `#partToolbar`（dxToolbar，和表格工具条同一个控件，样子一致），右边仍是「按号码查询: n 清除」提示
  - 按钮：分类 / 号码批量查询 / 批量导入图片 / 批量审核 / 批量修改
  - 这些按钮原来在 `createDxGrid` 的 `toolbar.custom` 里，由 dxGrid.js 回调时带上 `dataGrid`；
    搬出来之后自己 `openXxxPopup(grid)`，传的是同一个实例，弹窗代码一行没改
  - 表格工具条只留「新增」（`custom` 里其余四项删掉）和通用按钮
- 分类面板：`#partCategoryPanel` 初始就带 `collapsed`，样式从「宽度 24px 留个展开箭头」改成 `display: none`
  （入口挪到顶部按钮了，面板里不需要再留展开的地方），`#partCategoryToggle` 连同 `收起分类` / `展开分类` 文案一起删
- 关键词搜索框：`#partKeyword` 整块删掉，`gridParams` 不再有 `keyword`。
  服务端 `PartItem` 的关键词过滤**保留**（API 契约里标了页面不再传），全文检索表 `part_item_search` 也不动
- 验证：
  - 默认打开没有分类面板、表格宽 1184；点「分类」出面板（260px）、表格 914；再点收回
  - 号码批量查询输 GDP900 → 列表只剩刹车片、顶部出现「按号码查询: 1」，点「清除」回到 2 条
  - 未勾选点「批量审核」提示「请先勾选零件」；勾一条后「批量修改」「批量导入图片」弹窗标题正常
  - 表格工具条只剩「新增」+ 刷新 / 勾选 / 导出 / 列选择器
- 语言包：删 3 条（名称、号码或分类关键词 / 收起分类 / 展开分类），加 1 条（显示或隐藏分类面板）

## 2026-09-22 零件列表去掉「新增」+ 分类多选父子联动

- **去掉新增**：用户定「零件从数据单转换」，列表页不该有手工新增
  - 删 `openCreatePopup()`（分类 + 主号码厂商 + 主号码 + 中文名的弹窗，含填号码时的实时查重）和 `afterCreated()`
  - 表格工具条 `toolbar.custom` 清空（原来只剩「新增」一条）
  - 只有它用的东西跟着删：视图里的 `vendors` 注入、`partItemAction::index()` 里的 `'vendors' => ...`、
    文案 `add` / `cnName` / `mainFactory` / `duplicateTip` / `duplicateTitle`（`duplicateTitle` 本来就没人用）
  - **服务端不动**：`partItem/save` 无 key 的新增分支还在（API 契约里标了页面不调），
    数据单转零件走的是 `PartItem::insertDraft()`，和这条路径无关。语言包不用改，这几句中文别的页面还在用
- **分类多选父子联动**：`lib/partCategoryPicker.js` 新增 `cascade` 选项（默认 false）
  - `toggle()`：勾一个分类时连 `selectableDescendants()` 全勾上，取消全取消；再 `syncAncestors()` 往上走，
    每个上级按「它下面是不是全勾了」决定自己勾不勾
  - `syncInput()`：自己没勾、下面勾了一部分的画成 `indeterminate`（半选）
  - **只在零件列表开**。中国车型统计也是 `multiple + leafOnly:false`，但那里勾一个大类 = 加一列覆盖率，
    级联下去会变成几十列，所以默认关；实测该页勾大类仍然只「已选 1」，下级 15 个不动
- 联动之后 `categoryIds` 会同时带上上级和它所有下级（服务端本来就按含下级展开，结果一样），但面板上的小标签会刷一大片，
  所以 `renderPickedCategories()` 只列「上级不在选中里」的那些（勾「制动系」从 29 条变 1 条），
  去掉一条时连它下面的一起去掉（`categoryWithDescendants()`），不然上级没了下级还在，等于没去掉
- 验证：
  - 勾「配气机构」→ 下面 17 个全勾、计数「已选 18」；取消其中 1 个 → 上级变半选、剩 16；再勾回来 → 上级自动全勾；取消上级 → 全清（已选 0）
  - 勾「制动系」确定 → 面板 1 个标签「制动系」、列表只剩刹车片；点标签 ✕ → 面板清空、列表回到 2 条
  - 列表页工具条：顶部一行 5 个按钮不变，表格工具条已无「新增」
  - 中国车型统计页面照旧（无级联）

## 2026-09-22 零件详情去掉「来源」区块（界面）

- 用户定「来源在 UI 上先不展示」，删掉详情页「基本信息」里的整个来源区块：
  - `renderSourceSection()`（确认来源下拉 + 清洗快照只读行：数据单明细 / 确认来源产品 ID / 确认产品信息 / EPC 信息 / TCD 原始参数）
  - `saveSource()`、`renderSourceReadonly`、`sourceCheckFromBox`、`CHECK_FROM_OPTIONS`、页头保存里的 `dirty.source` 分支
  - 文案 14 条；其中 12 条全项目已无 `lg()` 引用，从 `zh-CN/en/ru` 三个语言包一并删掉
    （留下的：「来源」被别处用、「数据单明细」有 3 处引用）
- **服务端一点没动**：`partSource/info` / `save` 两个接口和 `PartSource` 模型原样保留，
  数据单转零件（`WashSheetItemPart`）仍会写来源快照。以后要重新展示，把前端区块加回来即可（API.md 里记了这条）
- 验证：详情页 63 号零件——分区标题只剩「安装位置」「其它语言翻译」，页面全文无「来源」二字，
  控制台无报错，`performance` 资源列表里没有 `partSource` 请求

## 2026-09-22 「图片」只收图片、「文件」不限格式

- 用户定：图片分页只允许图片格式；文件分页不限制，图片和其它类型都能传
- 详情页 `renderFileLikeTab()` 新增 `cfg.uploadType(ext)`：
  - 「图片」分页：选文件仍只放图片扩展名，传上来显式记 `image`
  - 「文件」分页：选文件放开到站点允许的全部扩展名；传上来的图片显式记 `attachment`
    （不传类型的话服务端按扩展名判成 image，行会跑到「图片」分页去），其它不传，服务端照旧判图纸 / 附件
- 服务端 `PartFile::assertTypeFitsExt()`：`image` 类型只能是图片格式，新增和改「类型」列都校验，
  这样在「文件」分页把 pdf 的类型改成「图片」、或直接调接口都挡得住；图纸 / 附件不限
- `attachFromZip()`（零件图片 zip 批量导入）开头加同样的判断，非图片格式的条目算「跳过」
- 分页提示改成「这个分页不限格式：图纸按扩展名自动判定，其它（包括图片）都算附件，判错了可以在下面改」；
  语言包换掉旧提示、加「只有图片格式的文件才能设为图片」（`scan.php` 留给 langTool 扫描更新）
- 验证：浏览器里「图片」分页上传器 `allowedFileExtensions` = 6 个图片扩展名，「文件」分页 = 站点全部 22 个（含图片）；
  桩类 CLI 测 `assertTypeFitsExt`：image/jpg、image/PNG 通过，image/pdf 拒绝，attachment/jpg、attachment/pdf、drawing/dwg 通过；
  `attachFromZip` 传 pdf → skipped。**真实上传没做**：本机 MySQL 仍是 `innodb_force_recovery` 只读

## 2026-09-22 零件详情记住上次的分页

- 用户要求：记住当前活动分页，刷新或打开下一个零件都停在上次的分页，直到用户重新换
- `detail.view.php`：分页配置抽成 `TAB_ITEMS`，每项加 `key`（basic / numbers / params / images / files / vehicles / relations）；
  `selectedIndex: savedTabIndex()` 从 `localStorage['partItemDetailActiveTab']` 取，`onSelectionChanged` 里记新的 key
- 按 key 记，不按下标；读写包 try/catch，存不了就从「基本信息」开始
- 保存校验不过时 `selectTab()` 跳到出错分页，用 `switchingForError` 标记跳过，不当成用户的选择
- 验证：7 号零件点「文件」→ 存 files；打开 63 号直接停在「文件」、上传器和表格正常；
  点「适用车型」后刷新 → 仍在「适用车型」；从这里切回「基本信息」表单正常渲染、存 basic；控制台无报错
- 契约：UI.md「多分页详情页」一节补了这条约定

## 2026-09-22 适用车型「新增」对齐参考系统

- 用户要求：零件详情「国际车型库」「中国车型库」的新增 UI 和逻辑与参考系统 `oe/detailSet` 的「国际车型」「中国车型」分页一致
  （参考：`oeAction::listModel` / `listCnModel` → `selectCarModels()` 打开 `pro/models/select` / `carmodelcn/select`，`style=anychk` 多选，确定后 `oe/addOEModel` / `addCNModel`）
- 参考系统行为：分页里是已挂车型表格 + 工具条「新增」；新增弹 98%×98% 的「新增车型」窗，顶部「确定」+「全部 A B C …」品牌首字母栏，
  下面整库车型表格（多选、过滤行、分页 30），已挂的车型预先勾上；确定只插入没挂过的，关窗刷新
- MDM 实现：
  - `detail.view.php` `renderVehicleTab()` 重写：车型库子分页（dxTabs）+ 适用品牌 + 本库已挂车型表格（`partVehicle/grid` 带 `libraryId`，切库改参数 refresh），
    工具条自定义按钮「新增」→ 弹窗（dxPopup 98%）：确定 + 字母栏 + dxDataGrid（CustomStore → `carVehicle/pick`，远程过滤 / 排序 / 分页，列按车型库层级生成）
  - 勾选状态自己记（`picked`）：dxDataGrid 的选中行会通过 store 回查，换字母后别的字母下勾的会被丢掉；`onContentReady` 把本页已挂 / 已勾的重新勾上
  - 确定只提交 `picked`（新勾的），取消勾选已挂的不删除（同参考系统，删除在外面表格里做）；原来的「备注 / 数据来源」批量输入去掉，加完在表格行里改
  - 后端：`CarNode::topInitials()` / `topIdsByInitial()`（读 `car_node_extra.initial` 第一个字符）；`CarVehicle::scopeToTopNodes()`（在 `scopeToLibrary` 基础上限定顶层节点）；
    `carVehicleAction` 去掉 GridActions，只留只读的 `letters` / `pick`（避免带 libraryId 范围后 save / remove 可写中国车型库）；
    `PartVehicle::scopeToLibrary()` / `vehicleIdsByItem()` + `partVehicle/vehicleIds`
  - 删除不再使用的：`carVehicle/grid`（按节点）/ `search`、`CarVehicle::search()` / `scopeToNode()` / `SEARCH_LIMIT`、层级树 / 候选表的 CSS 和文案
- 验证：`carVehicle/pick` 国际 A 字母 5385 行、中国按品牌过滤 1323 行，单页约 120ms；浏览器里国际库弹窗 165230 行、中国库 83367 行 5 层列；
  7 号零件中国库预勾 243825 / 243826；「全部」下勾 243828 → 切 A 勾 326099 → 回「全部」243828 仍勾着 → 确定提示「已添加 2 个，跳过 0 个」，适用品牌多出「阿维塔」；
  两条测试数据已通过 `partVehicle/remove` 删掉，恢复成原来的 2 条；控制台无报错
- 语言包：langTool 扫描 +2（车型ID、请点「新增」勾选车型后批量添加）/ −7，en 已译；
  **已知**：`新增` / `新增车型` 这两个 key 原来就被同步统计用着（en 是 Added / Vehicles added），按钮和弹窗标题的英文会显示成这两个，中文不受影响

## 2026-09-22 适用车型表格加「适用品牌」列

- 用户要求：两个车型库的已挂车型表格要显示「适用品牌」
- `PartVehicle::grid()` 每行加 `car_brand_names`（多个品牌「, 」拼接）：口径同 `brandsByItem()`（车型 → 顶层节点 → `car_brand_node`），
  但按页批量算——`CarNode::topIdsByNodeIds()` 一次查出本页节点的 `car_node_path`、取第一段当顶层节点；
  `CarBrand::namesByTopNodeIds()` 复用 `byTopNodeIds()` 取当前语言显示名，再按 `car_brand_node` 分到各顶层节点
- 前端列放在车型ID后面，`allowFiltering / allowSorting: false`（不是 SQL 列）；文案复用 TEXT.applicableBrands，语言包无新增
- 验证：7 号零件国际库 5 行 = 众泰 ×2 / 奥迪 ×2 / 风神，中国库 5 行 = 本田；页面渲染正常

## 2026-09-22 适用车型表格：「车系」改名 + 三列可过滤

- 用户要求：「所属」改叫「车系」；车型ID、适用品牌、车系三列都要能过滤
- 车型ID（`part_vehicle_lnk_car_vehicle_id`）本来就是 pv 的真实列，能过滤；另外两列原来是 PHP 按页补的，改成 SQL 计算列：
  - `gridFrom()` 加 `LEFT JOIN car_node pn`（车型直属上级），`car_node_name` = `pn.car_node_name`（国际库实际是车系，中国库是车代，按用户要求统一叫「车系」）
  - `car_brand_names` = 子查询：`car_node_path` 第一段（顶层节点）→ `car_brand_node` → `car_brand` + `Lang::translation()` 显示名，`GROUP_CONCAT(... ORDER BY sort, id SEPARATOR ', ')`，口径同 `brandsByItem()`
  - 删除上一条的按页换算（`CarNode::topIdsByNodeIds()`、`CarBrand::namesByTopNodeIds()`）和不再有调用方的 `CarNode::nameById()`
- 验证：7 号零件国际库 品牌 contains 奥迪 → 2 行、车系 contains ZONORA → 2 行、车型ID = 306 → 1 行；中国库按车系排序正确；`?lang=en` 品牌出英文名（Zotye / audi），测完已切回 zh-CN；页面过滤行实测同上
- 语言包：langTool 扫描 +1（车系，en = Series）/ −1（所属）

## 2026-09-22 零件数据来源收口

- 用户定：① 只有清洗逻辑带进来的内容才有来源；② 非清洗来的一律「人工」；③ 来源只保留 TCD / EPC / 人工（以后再加）
- 追问后补充：中国车型（清洗时查宜配接口得来）保留「宜配」；数据单明细本身的号码算人工；页面上可以改，三选一；字典其它项停用、保留宜配
- 实现：
  - 新 `model/PartDataSource.php`（extends Model，读字典 `data_source` 启用项，不建表）：`options($forVehicle)`、`idOf($code)`（缺项抛异常）、`normalize($value, $forVehicle)`（空 = 人工，非空必须在允许范围）
  - `PartNumber::normalizeNumber()`、`PartFile` / `PartRelation::normalizeDataSource()`、`PartVehicle::gridUpdate()` / `attachMany()` 都改走 `normalize()`；`PartVehicle::insertRows()` 的来源改成必填 int
  - `WashSheetItemPart`：编码用 `PartDataSource` 常量，`SOURCE_OWN` system → manual；`dataSourceId()` 改用 `idOf()`，字典缺项时整条回滚而不是写出空来源
  - `partItem/detail` 注入 `dataSources`（3 项）/ `vehicleDataSources`（4 项）；详情页四个数据来源列去掉 allowClearing，车型列用 4 项；号码新行 `onInitNewRow` 默认人工；关联弹窗默认人工、去掉清空
  - `sql/migrate_part_data_source.sql`（DML）：四张表空来源 / 不允许的来源改人工，字典其它 9 项 `attr_value_enabled = 0`；回滚文件只能恢复字典
- 执行前数据（预览）：号码 5 行（空 1、系统 1、TCD 2、EPC 1）；文件 2 行都为空；车型 63 行（空 10、宜配 6、TCD 47）；关联 0 行
- 验证（迁移前，只读）：详情页注入的选项 = 人工 / TCD / EPC，车型 = 人工 / 宜配 / TCD / EPC；语言包 +1（数据来源字典缺少，en 已译）
- **注意**：迁移执行前，改一条原来是「系统」来源的号码的其它列会报「数据来源不存在」（`$pick` 会带上原值去校验）
- 执行（用户授权，2026-09-22）：预览与上面一致；事务内 号码 2 行、文件 2 行、关联 0 行、车型 10 行改人工，字典停用 9 项；验证四张表空来源都是 0，启用项只剩 人工 / 宜配 / TCD / EPC。执行日志留在会话 scratchpad
- 执行后接口实测（7 号零件）：号码改成宜配 / 系统 → 「数据来源不存在」；车型改宜配成功、改系统被拒；`partVehicle/attach` 不带来源 → 人工；测试车型已删
- **号码的保存全部 500**：`PartItemSearch::refresh()` 用了 `INSERT … VALUES … AS new ON DUPLICATE KEY UPDATE`（MySQL 8.0.19+ 语法），本机库是 MySQL 5.7.18，事务回滚、数据没变。和本次改动无关，已另开任务处理；号码来源的写入路径因此只验证到校验这一步

## 2026-09-22 数据库切到 192.168.1.188 并从 .42 整库同步

- 用户把 `config/database.php` 的 default 连接从 192.168.1.42（MySQL 5.7.18，gspuser）切到 192.168.1.188（MySQL 8.4.8，pdc），要求把 .42 的 `pdc` 同步到 .188、保持一致
- 一开始 .188 拒绝 `pdc`@`192.168.1.91` 登录（1045），用户在 .188 上开了账号和权限后连通
- 同步前对比：两边 67 张表、结构一致（只有 5.7 / 8.x 显示 `on update current_timestamp` 的写法不同，加上 `sys_permission` 外键 RESTRICT / NO ACTION）；
  .188 是 .42 的旧快照：5 张表行数不同，内容不同的有 attr_value（字典停用）、part_* 若干行；.188 独有 `part_category_position` 20–23、`part_file` 13–16
- 覆盖前把 .188 上会被覆盖 / 删除的行导出成 JSON 放在会话 scratchpad（`pdc188_rows_before_sync.json`）
- 同步方式（本机没有 mysqldump，用 PHP PDO）：.188 上 `FOREIGN_KEY_CHECKS=0`、`sql_mode='NO_AUTO_VALUE_ON_ZERO'`、两边 `time_zone='+00:00'`；
  逐表 DROP → 用 .42 的 `SHOW CREATE TABLE` 重建（AUTO_INCREMENT 一并带过去）→ 分批 INSERT（跳过生成列）；共 272 秒，car_vehicle 24.9 万行
- 5.7 的 `SHOW CREATE TABLE` 不写默认的 `ON DELETE/UPDATE RESTRICT`，8.4 重建后元数据显示成 NO ACTION（行为相同）；按 .42 的规则把 36 个外键 DROP + ADD 成显式规则
- 验证：67 张表逐行（按主键哈希）内容全部一致（`attr_factory_bak_20260915` 没有主键，行数一致）；AUTO_INCREMENT 全部一致；结构只剩那 28 条显示写法差异
- 应用复测（连 .188）：零件列表、选车型弹窗正常；号码改其它列 / 来源改 TCD 再清空（→ 人工）/ 新增号码（→ 人工）后删除，全部成功——5.7 上 `PartItemSearch` 的 `INSERT … AS new` 报错在 8.4 上不存在了，之前另开的修复任务已撤回

## 2026-09-22 后台标签支持鼠标拖动排序

- `lib/adminLayout.js` 加拖动排序：`startDrag` / `moveDrag` / `endDrag` + `tabOrderFromDom()`，
  `adminLayout.css` 加 `.tab-item.dragging`、`body.tabs-dragging`
- 用鼠标事件做，不用 HTML5 拖放（内容区是 iframe，原生拖放事件收不到）；拖动中 `pointer-events: none` 盖住 iframe
- 位移 < 4px 不算拖动；拖完那次 click 用 `suppressClick` 挡掉，不切标签
- 首页标签不能拖、也不能被插到它前面；拖完 `tabs` 数组按 DOM 顺序同步（关闭左侧 / 右侧依赖顺序）
- 验证：后台登录态已过期，改用 web 根下的临时页 `_tabdragtest.html`（只放标签栏 DOM + 真实的 adminLayout.js/css，
  **验证完已删**）在浏览器里用真实鼠标拖：
  C 拖到最左 → home,C,A,B；关掉 C 后落到 home（说明数组同步了，C 当时在下标 1）；
  拖到最左边仍排在 home 之后；拖首页标签无效；拖动后 `.dragging` / `.tabs-dragging` 都清干净；
  拖完再点标签能正常切换、点 ✕ 能正常关闭

## 2026-09-22 后台标签标题截断

- 起因：用户问标题长了怎么办。查下来原先**完全没限制**——`.text(title)` 原样写入，
  `.tab-item` 是 `white-space: nowrap; flex-shrink: 0`，实测 44 字的数据单标题撑到 727px，前面的标签全被推出可视区
- 标题来源：菜单的 `data-title`（我们写死的，短）、零件详情（列表点的主号码）、
  数据单明细（`wash_sheet_title`，用户自己输入，最容易失控）、分类详情
- 方案：`adminLayout.css` 给 `.tab-title` 加 `max-width: 160px` + `text-overflow: ellipsis`，
  当前标签放宽到 240px（正在看的最需要看全）；`adminLayout.js` 的 `openTab()` 给 `.tab-item` 加 `title` 属性放完整标题
- 按像素截不按字数截：中英文宽度差一倍。`localStorage` 里存的标题保持完整（整页刷新后要用它重开标签）
- 验证：同一个长标题，标签宽度 727 → 当前标签 282、非当前 202；`.tab-title` 确实溢出被裁（scrollWidth 681 / clientWidth 160）、
  截图上显示为「2026 年第三季度大众...」；`title` 属性和 localStorage 里都是完整的 56 字
- **标签数量多了同样会溢出**（现在只能鼠标滚轮横向滚，没有滚动按钮），这次没做，等用户定

## 2026-09-23 文件分页的类型图标

- 用户要求：常用文档类型（xlsx / pdf / doc / mp4 / cad 等，不限于这些）要有类型图标，PDF 按参考图那种红色折角文档样式
- `detail.view.php`：新增 `FILE_ICON_COLORS` / `FILE_ICON_DEFAULT_COLOR` + `fileIconSvg(ext)`，文件单元格里非图片行由原来的 `dx-icon-doc` 灰块改成这个图标
  - 内联 SVG（40×48 折角文档轮廓 + 中间扩展名），不引图标库、不加依赖（契约禁止引新前端框架）
  - 颜色：pdf 红 `#d32f2f`；xlsx / xls / xlsm / csv 绿；doc / docx / rtf / odt 蓝；ppt / pptx 橙红；mp4 / avi / mov / mkv / wmv / flv 紫；
    cad / dwg / dxf / step / stp / igs / iges 青；zip / rar / 7z 琥珀；txt / xml / json 灰蓝；其余 `#9aa3af` 灰
  - 图标中间写扩展名本身（只留字母数字、最多 5 位，4 位缩到 11px、5 位缩到 9px），所以没列进表的格式也认得出
- `partItem.css`：加 `.part-file-icon`（40×48）；`.part-thumb-empty` 保留不动——零件列表页 `index.view.php` 还在用它占位没有主图的零件
- 验证：63 号零件文件分页里 xlsx 显示绿色 XLSX、pdf 显示红色 PDF；取页面里的函数跑一遍 pdf / xlsx / csv / docx / pptx / mp4 / dwg / cad / step / zip / txt / heic / 超长扩展名 / 空扩展名，颜色、文字、字号都符合预期


## 2026-09-23 模拟零件数据（500 条）

- 用户要求：模拟 20 个品类共 500 个零件写入零件表；「授权」指本次会话允许 Claude 直接写 pdc 库的表，不涉及后台权限配置和数据库 GRANT
- 脚本在会话 scratchpad（`seed_parts.php`，不入库），`mt_srand(20260923)` 固定种子；走 `PartItem::gridInsert` / `gridUpdate` / `submit` / `approve` 和 `PartNumber::gridInsert`，操作人 admin（ID 1）
- 分类（每类 25）：空气/机油/燃油/空调滤清器、火花塞、点火线圈、水泵、节温器、散热器、涨紧轮、时规带修理包、离合器修理包、刹车盘、刹车片、减振器、球头、控制臂、轮毂轴承单元、氧传感器、雨刮片
- 每个零件：主号码 = 该品类常见品牌（BOSCH / MANN-FILTER / BREMBO / TRW / SKF 等已登记厂商）+ 仿品牌格式号码；第二个号码 = OE（厂商 OE、数据来源 EPC）按适用车型的 OE 号格式生成；
  中英文名 = (前/后)分类名 + 适用车型，描述带年款和规格，单位 / 装配数量 / 开发状态 / 是否内部研发都有值
- 审核状态：草稿 250 / 待审核 101 / 已通过 149
- 核对：part_item 500（ID 64–563）、part_number 1000（主号码 500）、part_item_search 500、part_item_audit_log 399（250 submit + 149 approve）
- 清理：已通过 / 待审核的要先反审核 / 撤回回到草稿才能删；也可直接 SQL 按 `part_item_remark = '模拟数据 2026-09-23'` 删 part_item（子表外键级联）

## 2026-09-23 列头筛选器候选值全是「(空白)」

- 现象：零件列表「分类」等非 lookup 列的列头筛选器，候选值全部显示「(空白)」；lookup 列（开发状态、审核状态）正常，因为它们用 lookup 数据源不请求后端
- 根因：各表格开了 `remoteOperations.grouping: true`，列头筛选器取候选值发的是 `group` 请求；但 `dxGrid.js` 的 load 只转发 filter/sort/skip/take/requireTotalCount，框架 `Model::grid()` 也不处理 group，返回普通行，DevExtreme 当分组读，key 全空。拖列分组同样坏
- 跨层理由：通用的 grid 查询和分发都在框架（`Model` / `GridActions`），业务层没法给所有表格补
- 框架：
  - `Model::gridGroups()`：返回 `[{key, items, count}]`，最后一级 items=null；分组键用 `DxFilter::column()`，跟过滤同一套表达式（计算列也行），选中候选值后的 filter 一定对得上；支持多级、日期 groupInterval（year/quarter/month/day/dayOfWeek/hour/minute/second）、数值步长；skip/take 作用于第一级；requireTotalCount / requireGroupCount；子类有 `gridGroupBy()` 时先子查询归并再数；最后一级 `isExpanded:true`（展开到明细行）不支持，抛 MvcException
  - `Model::gridLoadOptions()` 钩子：grid() / gridGroups() 开头都调，子类的数据范围和敏感列校验放这里
  - `GridActions::grid()`：请求带 group 走 gridGroups()，MvcException 转 code=1
  - 不走子类 `grid()` 覆盖的原因：子类 grid() 普遍在 parent::grid() 之后逐行加工（按行读字段），分组结果会让它们报错
- 业务层：`Users` / `Member` 的 `guardSecretColumns()` 调用移到 `gridLoadOptions()`，且校验 group 的 selector（否则可以按密码哈希分组拿到哈希值）；`WashSheetItem` 的清洗单范围过滤移到 `gridLoadOptions()`
- 前端：`dxGrid.js` 转发 `group` / `requireGroupCount`，返回 groupCount
- 自测（浏览器）：partItem 按分类分组 20 组计数正确；更新时间按年/月分组正确；选「刹车片」后列表 26 条且只剩刹车片；拖分类列分组、展开组加载明细正常；users 按姓名分组正常（带 GROUP BY 的模型）、按 `admin_user_password` 分组被拒
- 遗留：静态 JS 没有版本号，浏览器缓存旧 dxGrid.js 时问题看起来依旧，需要 Ctrl+F5

## 2026-09-23 静态资源加版本号（缓存失效）

- 起因：视图直接 `<script src="<?=Mvc::$cfg['staticUrl']?>/js/lib/dxGrid.js">`，改了 dxGrid.js 后浏览器仍用缓存旧版，要 Ctrl+F5（同日列头筛选修复时踩到）
- 方案：`pdc/model/StaticAsset.php`，`StaticAsset::url($path)` 返回 `staticUrl . $path . '?v=' . filemtime`；文件路径按 `dirname(viewDir) . '/static'` 算（static 与 view 同级），文件不存在时不带 `?v`；同一请求内按路径缓存结果。放 `model/` 是借它的自动加载（同 `SecretColumns` 先例），没动框架
- 替换：26 个视图 52 处 `<script>` / `<link>`，包括 `header.view.php` 里按语言拼的 `dx.messages.{locale}.js`；`window.APP.STATIC_URL` 仍是不带版本号的前缀（JS 目前没有用它动态加载文件）
- 自测：所有改动文件 php -l 通过；浏览器 登录页、`index/index`、`partItem/index` 资源全部带 `?v=`，表格正常渲染，控制台无错误

---

### 2026-09-24 零件审核改为两个状态（待审 / 已审），编辑不受审核限制

用户要求：零件数据的编辑是永久性的，不受审核与否的限制；审核状态只要「待审」「已审」，不要「草稿」。
同会话拍板：编辑已审零件**保持已审不变**；**任何状态都能删**；**可以自审**（去掉开关）。

改动：
- `PartItem`：常量只剩 `AUDIT_PENDING=1` / `AUDIT_APPROVED=2`；动作只剩 approve（1→2）/ unapprove（2→1）；删 `submit()` / `withdraw()` / `setAllowSelfAudit()` / `assertCanApprove()` / `isEditable()`；
  `assertEditable()` 改名 `assertExists()`（只校验零件存在），`insertDraft()` 改名 `insertItem()`（写 1 待审）；`gridRemove()` 不再看状态
- `PartItemAuditLog`：删 `ACTION_SUBMIT` / `ACTION_WITHDRAW` / `lastOf()`
- 子表 Model（Lang / Number / ParamValue / File / Vehicle / Relation / Position / Source）调用改为 `assertExists()`，注释同步
- `PartFile::attachFromZip()` 不再跳过「非草稿」零件；`WashSheetItemPart` 合并不再跳过「已提交 / 已通过」的目标零件
- `partItem` Action：删 submit / withdraw 端点、自审注入；状态下拉 待审 / 已审。`config/common.php` 删 `partItem` 段，`admin/index.php` 删对应的 `Mvc::$cfg['partItem']`
- 详情页：删只读提示和 `registerEditable` / `applyEditable` / `gridEditableHandler` 整套机制，所有分页始终可编辑；按钮 保存 / 审核（待审时）或反审核（已审时，要写原因）/ 删除 / 审核记录
- 列表页：批量审核动作只剩 审核 / 反审核；`partItem.css` 删 `.part-status-0`、`.part-readonly-tip`
- 契约 DB.md「零件审核与零件写入规则」、数据单转零件规则，API.md 审核端点 / remove / 参数 / 位置 / zip 条目已同步
- 语言包：CLI 调 `LangScanner::run()` 扫描（+5 / -14），新增 审核 / 待审 / 已审 和两条改写的提示，en / ru 已译

SQL（v1.0.9，**待人工审核执行**）：`sql/migrate_part_item_audit_two_status.sql` + `_rollback.sql`
- `part_item_audit_status` 0→1；默认值 0→1；注释 1=待审，2=已审
- 删 `part_item_audit_log` 里的 submit / withdraw 记录（现有 250 条 submit），unapprove 的改后状态 0→1；动作列注释更新
- 执行前库里：0=252 / 1=101 / 2=149；执行后应为 1=353 / 2=149

验证：改动的 PHP 全部 `php -l` 通过；浏览器（`php -S` + 伪造超管 router，只读查看，未写库）：
已审 #64 显示「已审」、按钮 保存 / 反审核 / 删除 / 审核记录、基本信息表单非只读、号码分页可增改删；
待审 #66 显示「待审」、按钮含「审核」；列表页无 JS 报错，未迁移的状态 0 行状态列为空（符合预期，等 SQL）。
**没测**：审核 / 反审核 / 编辑已审零件的实际写入（未经授权不往共享库写测试数据）
- 2026-09-24 同日经用户授权执行 v1.0.9：预览与预期一致；part_item 改 252 行（0→1），删 250 条 submit 记录，unapprove 0 行；
  核对 part_item 1=353 / 2=149，审核记录 approve 149，默认值 1、三列注释已改；sys_migration id=11

### 2026-09-24 零件详情「图片」「文件」上传改为工具条按钮 + 弹窗

用户要求：两个分页顶部的上传区占地方太多，上传按钮放到表格工具条（刷新、重置之后），点了用弹窗做上传和进度。

改动：
- `view/partItem/detail.view.php` `renderFileLikeTab()`：去掉 `.part-file-toolbar` 整块，表格直接挂在分页里；`toolbar.custom` 加上传按钮（`icon: 'upload'`，文案复用 `上传图片 / 上传文件`），
  `openUploadPopup()` 首次点击才取 `file/options` 并在弹窗里建 `createChunkUploader`（说明文字 `cfg.tip` 挪进弹窗）
- 弹窗每个分页一个、懒建、关掉只 hide 不销毁：在传的文件不会因为关窗被打断；用 `onUploadStarted` / `onUploaded` / `onUploadError` / `onUploadAborted` 计数，没有在传的文件时再开会 `reset()` 清空列表
- 取 `file/options` 失败时关掉弹窗、下次点按钮重来
- `static/js/lib/dxGrid.js`：`toolbar.custom` 按钮新增 `afterBuiltins: true`，排在左侧内置按钮（刷新 / 已选 / 重置 / 批量查询）之后；不写保持原来排在最前
- `static/css/partItem.css`：删 `.part-file-toolbar`，`.part-file-uploader` 由固定 320px 改为在弹窗里铺满

验证（浏览器，零件 #465）：工具条顺序 刷新 / 重置 / 上传图片 / …；弹窗标题「上传图片」、内容 说明 + 选择文件，宽 560 无横向滚动；关掉再开复用同一个弹窗（body 下只有 1 个）；
「文件」分页按钮为「上传文件」；表格顶部距分页标签 35px（原来的上传区已去掉）；控制台无错误。**未实际上传文件**（不往共享库写测试数据），上传逻辑 onComplete 与改动前一致

## 2026-09-28 数据单列表明细统计列

需求：数据单列表（washSheet/index）增加参考系统的「明细数 / 已清洗 / 有清洗数据 / 零件库存在」四列。
参考：catalog_frey `model/work.php` 列表统计（`item_count` / `clean_count` / `clean_has_count` / `poe_has_count`）+ `workDataList.js` 的比例条 formatter；参考系统的「供应商存在」「商品库存在」「数据处理」本项目没有对应概念，没做。

口径（`WashSheet::itemStats()`）：
- 明细数 = 该单明细行数
- 已清洗 = `wash_sheet_item_clean_time IS NOT NULL`
- 有清洗数据 = `wash_sheet_item_tcd` 或 `wash_sheet_item_epc` 的 `$.matched` 为 true（`JSON_EXTRACT`，库是 MySQL 8.4）；对应参考系统 `wdi_clean_has`（检测有结果）
- 零件库存在 = `part_number` 有同厂商 + 同格式化号码的零件，明细分类非空时零件分类必须相同（参考系统 `wdi_cat_id = 0 OR = oe_cate_id`），按明细去重

实现：
- `model/WashSheet.php`：覆盖 `grid()`，对当前页单号跑两条 GROUP BY（统计 + 零件匹配）后并进行；没有明细的单统计全 0（`EMPTY_STATS`）；`stripReadOnly()` 同时丢弃四个统计键。导出走 `grid()`，统计列会按数字导出
- `admin/view/washSheet/index.view.php`：`statColumn()` 生成只读列（不筛选 / 不排序 / 不进编辑表单），`rateCell()` 画「统计数 / 明细数」比例条，统计为 0 留空，title 显示「n / 总数」
- `admin/static/css/washSheet.css`：`.wash-rate*`；列表页新引这个 css
- 语言包 +4（en：Items / Cleaned / Has Clean Data / In Part Library；ru 已译）

验证：临时 `php -S` + 伪造超管登录的 router（会话临时目录，已停）。`washSheet/grid` 返回单 1：7 / 7 / 6 / 1、单 5：5 / 5 / 3 / 1，与直接 SQL 核对一致；浏览器中英文列表显示正常、编辑弹窗无统计字段（未保存）、控制台无错误。未写任何测试数据。


## 2026-09-28 商品库：学习参考站 + 方案 + P0 建表

- 只读学习 catalog_frey「商品库」菜单（商品 / 小类 / 编码规则 / 商品品牌 / 号码厂商 / 品牌商品 / 事业部商品）和 frey_catalog 表结构：
  零件 oe_item → 集团商品（FGIO，pb_id=1 写死，`buildProByOe`，复制零件参数 / 号码 / 车型 / 图片）→ 品牌商品（p_lnk_p_id，主要从外部 PDM 同步）→ 事业部商品 pro_link_company（从 PDM 同步）；
  编码 = 品牌上的段序列 `M@C@S@B` + 码表 pro_rule_link + 流水（集团 REGEXP MAX，品牌按集团商品 + 汽车品牌复用 pro_serial_number）。
- 方案（Artifact https://claude.ai/artifact/MpNEXbnV1ZbvUmSJP4KFNN）：配置 + 插件；继承 + 覆盖（只存差异）；汽车品牌范围过滤车型和 OE；编码规则引擎；经营单元泛化事业部；扩展字段可筛选排序；外部同步只做骨架。
- 用户拍板：三层全做；分类复用 part_category；扩展字段要能筛选排序；前缀 `pro_`（`biz_` 作废）；商品必须从零件生成；零件被商品引用不能删；编码不改；没标汽车品牌的 OE 默认显示；
  生成商品即审核（没有审核流程），状态用参考站 8 个主状态（另带 00 未定义作新商品默认）；4 个新字典迁进通用属性。
- 执行 `sql/pro_library_v1.sql`（v1.1.0，sys_migration id=12）：30 张表 / 75 外键 / 无自动命名外键 / 排序规则全 unicode_ci；字典 item_type 4、material 23、pro_status 9、pro_unit_type 1。
  约束冒烟（事务内回滚）：主商品品牌只能一个、非主品牌可多个、通用码表去重、翻译表拒绝 en、pro_item 零件外键生效，5 项通过，无残留。
- 待办：P3 改 `PartItem` 删除前检查商品引用；material 字典里混有非材质值（颜色、主件 / 子件、E-MARK……），是原样迁移，以后要不要拆由用户定。

## 2026-09-28 商品库 P1：商品品牌 / 经营单元 / 分类商品属性 / 数据权限

用户拍板（开工前列计划确认）：
- 多角色合并：**只看设置了范围的角色**，有行的角色取并集；一个都没设 = 不限制。没设范围的角色（如和商品无关的）不会把限制放开。参考站 `mergeIds` 效果相同（范围挂在「角色 × 权限码 B600/B700」上，空串并入后仍是有限制）
- 分类「商品属性」只对末级分类开放（同参数 / 安装位置）
- 品牌 / 单元被角色数据权限引用、号码厂商被品牌号码范围引用时**拦下不让删**：外键都是 CASCADE，删掉会让角色 / 品牌变成「无行 = 不限制」，范围被悄悄放开

新建：
- Model：`ProBrand`、`ProBrandLang`、`ProBrandFactory`、`ProUnit`、`ProUnitLang`、`ProCategoryExt`、`RoleProBrand`、`RoleProUnit`、`ProDataScope`（非表 Model，解析器）
- Action：`proBrand`、`proBrandLang`、`proUnit`、`proUnitLang`（GridActions）、`proCategoryExt`（info / save）
- View：`proBrand/index`、`proUnit/index`（照汽车品牌页：列表 + 展开行翻译表格）

修改：
- `config/admin.php` 新菜单「商品库」（fa-shopping-bag）→ 商品品牌 / 经营单元，排在零件库之后
- `Role`：`gridSelect()` 用行级子查询带出 `sys_role_pro_brand_ids/_names`、`sys_role_pro_unit_ids/_names`（名称按当前语言），`gridInsert/Update` 摘出 ID 数组在同一事务里全量替换（没提交不动）；`role/index` 加两列多选（可搜索、不参与服务端过滤排序、提示「不选 = 不限制」）
- `NumberVendor::gridRemove()` 先查 `pro_brand_factory` 引用
- `partCategory/detail`：末级分类多一个「商品属性」分页（工业风皮肤 dxForm：报关单位 / 默认物料类型 / HS / GPC / 开票名称 / 退税率 / 流水位数 / 唯一性含材质），脏标记 `product` 进页头保存和 Ctrl+S；保存前校验失败切到该分页；数据读回前表单禁用。「基本信息」分页的橙点只看基本信息 / 安装位置的改动
- `dxGrid.js` 的 `dxMultiSelectColumn()` 加第 7 个可选参数 `tagBoxOptions`（合并进 dxTagBox 配置），现有调用不受影响

规则要点：
- 品牌标识码只允许字母数字（会拼进商品编码）、≤16、唯一；中文名唯一；英文名空串存 NULL；排序留空 = 新增排最后、修改不动
- 主品牌：先查已有主品牌并报出名字；品牌下已有 `pro_item` 时不能改主品牌标记；最终靠 `uk_pro_brand_main_flag` 兜底
- 删除品牌 / 单元：被 `pro_item` / `pro_unit_item` 引用或被角色范围引用都拦
- 经营单元类型必须属于字典 `pro_unit_type`；新增不填且字典只有一个启用值时默认用它；修改不能清空
- 分类商品属性：整份提交（没提交的列按空值）；退税率 0–100；流水位数 1–16（`pro_code_serial_reuse_serial` 是 VARCHAR(16)）；没有行且全空时不建行
- `ProDataScope::brandIdsFor(adminId)` / `unitIdsFor()`：null = 不限制；超级管理员 null；有效角色 = 自己的 + 所在部门的（口径同框架 `PermissionResolver`）。不进 SESSION 快照（快照在框架层），每次现算，改角色范围立即生效。P1 还没有用它过滤的列表，P3 / P4 接

没做 / 留后：
- 品牌的编码规则列（`pro_brand_lnk_pro_code_rule_id`）P2 开放
- 方案页「经营单元菜单名可配置（unitMenuName）」：菜单标题必须是 lg 字面量才能被扫描，暂固定「经营单元」
- 已有问题（非本次引入）：角色保存不带站点会撞 `fk_sys_role_lnk_sys_site_id`（1452，界面上站点列没标必填）

验证：
- CLI 53 项（scratchpad `p1_test.php`，走真实 Model，finally 清理）：品牌增改删 / 主品牌两种拦截 / 厂商全量替换与事务回滚 / 计算列过滤 / ru 显示名回退 / 单元默认类型与字典校验 / 角色范围随行保存与回滚 / 合并口径 4 种情形（无角色、只有无范围角色、有范围 + 无范围、两个有范围）/ 删除保护 / 分类商品属性各项校验。清理后 8 张表全 0
- HTTP：`php -S` + 伪造超管会话的临时 router（不走登录、不输密码）：五个页面 200 无 PHP 报错；grid / save / remove / export / info / save 正常；未登录 401
- 浏览器（内置浏览器开同一个临时服务）：品牌编辑弹窗、厂商多选搜索 bosch 出 3 条、选中保存后列表更新；分类 #22「商品属性」读回 → 改 → 保存按钮启用 → 保存后禁用、数据落库；流水位数 20 时保存被拦并跳到该分页；非末级 #1 没有该分页；角色页两列、弹窗改范围保存 / 改回；品牌被角色引用时删除被拦。测试数据全部清掉，角色 7 已恢复
- 语言包：langTool 扫描 +53（本次 51 + 此前遗留的「有」「无」），en 全部翻译；ru 全包都是中文占位，本次同样占位

## 2026-09-28 / 29 商品库 P2：编码规则引擎

范围：规则 / 段 / 码表维护界面（菜单「商品库 → 编码规则」）、编码引擎、插件骨架、预览、取号并发测试；9-29 按用户要求对齐参考站 gsp（`qgmvc/gsp` 的 `pro/codeRule`，只读学习源码 + 参考库 78 条规则 + 页面，没输密码）。

用户拍板：
- 9-28：码表同类型编码允许重复，界面标黄；商品品牌页「编码规则」列本会话做；样例规则授权执行；规则被品牌 / 商品引用时拦下、可停用；老编码计数器接续留给迁移会话
- 9-29：内部编码是参考站特有的，不做（`[SIN:]` 不支持）；区域不做；编码扩展要做，放进通用属性「编码扩展」树形字典；按品牌 + 分类选规则；表达式做成双向；一次生成多品牌编码时怎么建品牌商品、区域怎么存留给 P4；授权执行 SQL；建品牌 中性 MAIN（主品牌）、华配 HUA、夏配 XIA

文件：
- 新建 model：`ProConfig` / `ProCodeSegmentProvider` / `ProUniquenessResolver` / `ProCodeContext` / `ProCodeCounter` / `ProCodeSerialReuse` / `ProCodeMap` / `ProCodeRuleSegment` / `ProCodeRule` / `ProCodeEngine` / `ProCodeExpression`
- 新建：`config/product.php`、`plugin/frey/FreyFgioSegmentM.php`、`admin/action/proCodeRule.php` / `proCodeRuleSegment.php` / `proCodeMap.php`、`admin/view/proCodeRule/index.view.php`、`admin/static/css/proCodeRule.css`
- SQL：`sql/pro_code_rule_sample_frey.sql`（v1.1.1，DML，FREY_MAIN #10 / FREY_BRAND #11）、`sql/pro_code_rule_v2.sql`（v1.1.2，DDL + attr_type `pro_code_ext`）+ `_rollback.sql`
- 改：`config/admin.php`（菜单）、`langTool.php`（SCAN_DIRS 加 plugin）、`ProBrand`（默认规则列 + 删除时查规则条件）、`proBrand` action / view（编码规则列）、`PartCategory::gridRemove`（查规则条件）
- 契约：DB.md「编码取号」改写 + 新增「编码规则引擎」小节；API.md「商品库接口清单（P2）」；UI.md 重复值 `#fff4c2`；i18n.md 扫描目录加 plugin

设计要点 / 踩坑：
- **取号死锁**：P0 契约写的「事务里 INSERT IGNORE 再 FOR UPDATE」在并发下会死锁（插入意向锁 + 间隙锁），复用登记表用 FOR UPDATE 查不存在的行也会拿间隙锁。改为：计数器行不存在时用旁路连接（自动提交）INSERT IGNORE 补行，业务事务里只 FOR UPDATE 锁计数器行；复用登记在锁住计数器行后查，先业务连接普通读、再旁路连接读最新提交，不加锁（同作用域的并发已经排在计数器行锁后面）
- **纠正方案里的说法**：按参考站 catalog_frey，品牌商品的流水是按「上级 + 汽车品牌」复用的独立计数器，不是复用 FGIO 主商品的流水；样例 FREY_BRAND 按这个配
- 对齐 gsp 的映射：`[S:0200-2100]` → 流水加结束值（位数 = 起始值位数，区间流水位数固定、不看分类）；`[2-5]` / `[2-]` / `[3]` → slice 段截取上级商品编码；`[MA]`～`[ME]` → 码表编码套 A～E；`[A][B][C]` 标签 → 码表对象 `attr:通用属性类型编码`（没码时回退字典编码）；pcr_am → 手动规则；按品名 / 扩展选规则 → 规则适用条件（品牌 / 分类 / 编码扩展）+ 生成列唯一；「已存在重叠规则」→ 纯文本 + 流水规则比区间；「自动编码测试」→ 预览勾「同时算各品牌编码」
- 没照搬：gsp 的流水是查 `max(pc_seq)+1`、不加锁，并发会重号；保留计数器 + FOR UPDATE
- 自动匹配：`IN (x, NULL)` 匹配不到 NULL（第一次跑测试 5 项失败就是这个），改成取出全部 match_key 非空的规则在 PHP 里比
- 商品有编码扩展时只匹配扩展规则、不回退普通规则（和 gsp `pcr_ext = ext` 一致，防止北美商品默默用了普通编码）
- 生成列基础列的外键只能 RESTRICT：删品牌 / 分类前 Model 先查规则条件；编码扩展字典值被引用时删除会撞外键（与 pro_unit 类型字典一样，未另做提示）
- Medoo `action()` 不支持嵌套事务：`ProCodeRuleSegment::replaceAll()` 不自己开事务，由 `ProCodeRule` 的保存事务包住
- dxNumberBox 默认值是 0：formData 没这个键时显示 0 但不进数据，预览表单 / 段弹窗的可空数字框显式给 null
- 权限：9-29 执行 v2 SQL 时自动安全检查两次拦下（对话里的授权不算），由用户自己执行了 v2 SQL；建品牌走 `ProBrand` 模型成功

验证：
- CLI 引擎回归 57 项（scratchpad `t_engine.php`）：段校验、预览不占号、连续取号 / 回滚退号、插件段、分类覆盖位数、默认物料类型、唯一性键含材质、码表四级回退、品牌标识码回退、上级继承、复用、inherit / date / check、流水用完、停用规则、删除拦截与级联
- CLI v2 68 项（`t_v2.php`）：条件唯一（停用可重复、改回启用被拦、手动可共存）、匹配优先级（本分类 > 上级分类、品牌不限、仅品牌、扩展下级匹配上级规则、有扩展不回退、品牌默认规则回退）、区间流水 / 用完 / 回滚、重叠拦截（表达式和逐段改都拦、位数不同不算）、截取、编码套回退默认、通用属性码表回退字典编码、树形字典路径、主编码 + 各品牌编码、表达式保存（写错整条不保存）、删除拦截
- 表达式 35 项（`t_expr.php`）：gsp 11 种写法和我们的写法往返不变，各类报错
- 并发 13 项（`t_concurrent.php`）：8 进程 × 200 次 1600 号连续不重；带 400 次回滚提交号连续；20 个汽车品牌复用每品牌一个流水。v2 后重跑时脚本几条规则写法相同被新的重叠检查拦下，改脚本用不同文本后通过
- HTTP 20 项（`h_v2.php`，php -S + 伪造超管会话）：两个页面 200 无报错、表达式保存 / 写错报错、grid 带表达式、自动选规则 + 各品牌编码、手动规则、码表 attr + 编码套、商品品牌默认规则读写（上一轮遗留）、删除拦截、未登录 401
- 浏览器：规则列表 / 写法说明 / 新增表单（lookup 显示品牌名和分类路径，保存后表达式拆段）/ 段弹窗按类型切字段 / 预览（中性 60001、华配 H0001）/ 码表页签通用属性 + 编码套列；修了启用 / 手动列宽、写法说明展开后表格高度、数字框默认 0
- 测试数据全部清掉（规则、码表、计数器、测试扩展字典值、ZZ 品牌）；库里保留 FREY 两条样例规则和 3 个正式品牌

未做 / 待办：
- langTool 扫描：新增文案很多、en 未译（P1 可能并行改语言包，本会话没动语言包）
- 码表数据（小类码、汽车品牌码）和老编码的计数器接续留给迁移会话
- 一次生成多品牌商品、区域编码怎么存：P4 定
- 编码扩展字典目前没有取值（参考站的北美编码 / 涡轮增压 / 高端线要不要录由用户定）
