# 实现进度交接 — 客户模块 /implement 流水线(2026-09-03) > 用途:交接给**全新会话**继续 `/implement .scratch\customer-module`。 > 与同目录 `REVIEW-HANDOFF.md`(spec 复核清单)**不同用途**——那份是 spec 阶段的 grill 交接,本份是**代码实现进度**交接。 > 权威决策仍在:`map.md`(D1–D29)+ `tickets/NN-*.md`(13 票验收项)+ `issues/NN-answer.md`(逐票定稿)。本份**不重复**其内容,只记进度、状态、陷阱。 --- ## 1. 全局进度(13 票流水线) | 票 | 状态 | 测试 | 备注 | |---|---|---|---| | 01 模块骨架 | ✅ | ping | — | | 02 主表 CRUD + 三层查重 | ✅ | 17 | CustomerDedupService 三层(L1/L2/L3) | | 03 联系人子表 | ✅ | 17 | CustomerContactServiceIntegrationTest | | 04 三 workspace + 归属流转 | ✅ | 14 | CustomerWorkspaceOwnershipIntegrationTest | | 05 客户阶段三态 CAS + port seam | ✅ | 7 | CustomerStagePortIntegrationTest | | 06 详情 8 页签 + 跟进 + oplog | ✅ | 8 | CustomerFollowDetailIntegrationTest | | **07 客户交割两段式** | ✅ **本会话完成** | **12** | CustomerTransferIntegrationTest;crm-dict 种子 +1 组 | | **08 客户导入** | 🚧 **本会话进行中(约 85%)** | 0 | **详见第 3 节,接手第一件事=编译** | | 09 crm-rule 客户规则族 + Job | ⏳ | — | 依赖票 06 锚点 last_valid_follow_time | | 10 saved-view 接入 | ⏳ | — | 3 scope_key + CustomerSavedViewFilter | | 11 商机侧切真源 | ⏳ | — | CustomerOpportunityQueryPort/StagePort/OwnerSyncPort 真实现替换 Noop | | 12+13 收尾 | ⏳ | — | 全量测试 + 冒烟 + 文档核对 | | 14 code-review + 提交 | ⏳ | — | — | **crm-customer 模块测试总数:75 全绿**(7+17+8+17+12+14,票 07 收工时)。 **crm-dict DictDataInitializerTest:8 全绿**(24 组 / 229 项,票 07 加 transfer_reason)。 --- ## 2. 票 07 收工记录(本会话完成,验收全过) 新增/修改文件: - `crm-customer/.../controller/CustomerTransferController.java`(6 端点,issues-08 §D 契约) - `crm-customer/.../service/impl/CustomerTransferServiceImpl.java`(两段式核心;本会话修了 3 处编译错,见第 4 节陷阱 ①②③) - `crm-customer/.../service/ICustomerTransferService.java`(加 `assignOne` 到接口——票 04 范式:单条动作必须在接口上,batch 才能经 `self.` 走事务代理) - `crm-customer/.../test/.../CustomerTransferIntegrationTest.java`(12 测试) - `crm-dict/.../config/DictDataInitializer.java`(+transfer_reason 组,sortNo 24;**code 用语义码 resign/transfer_post/region_adjust**,非 `_NN` 形态——因 TransferInitiateDTO 的 @Schema 已写死该口径) - `crm-dict/.../config/DictDataInitializerTest.java`(9 处断言同步:23→24 组、226→229 项、188→191 项) **票 07 设计定案**(实现已落,供后续票参考): - `preview()` **无参**(issues-08 §D 写的 `{id}/preview` 是笔误——发起前还没有交接单 id);服务/Controller 均为 `GET /transfer/preview` - `initiate` 不带 customerIds:系统按「当前用户名下可交接客户」整批计算(与 preview 同口径,防前端传值不一致) - `assign` 批量走 `BatchRunner.run(customerIds, id -> self.assignOne(...), ...)`;`self` 是 `ICustomerTransferService`(接口,非 Impl) - oplog 全部 `writeSystem`(操作人显「系统」+ detail 带交接编号,issues-08 §F) - D22 总监推导 = `sys_dept.leader_user_id`(不改 crm-auth);D23 自动完成 = `assignedCount == totalCount → status=1` --- ## 3. 票 08 当前状态(🚧 接手重点) ### 3.1 已落盘文件(13 个 + pom) 已逐个核验存在,但**全部未经编译验证**;`controller/CustomerImportController.java` 核验为 MISS(待写)。 ``` crm-customer/pom.xml +easyexcel(父 POM 管版本 4.0.3) .../domain/entity/CustomerImportTask.java ⚠ 覆盖了旧版(87 行,见 3.3) .../domain/entity/CustomerImportFail.java .../mapper/CustomerImportTaskMapper.java .../mapper/CustomerImportFailMapper.java .../domain/excel/CustomerImportRow.java 客户 sheet 行(9 列 @ExcelProperty index) .../domain/excel/ContactImportRow.java 联系人 sheet 行(5 列) .../domain/dto/ImportPreviewDTO.java 含内嵌 Row .../domain/dto/ImportResultDTO.java .../domain/param/ImportPageParam.java .../service/ICustomerImportService.java 6 方法(upload/confirm/result/failures/page/template) .../service/impl/CustomerImportAnalyzer.java ★ 判定器(upload 与执行共用,297 行) .../service/impl/CustomerImportServiceImpl.java 349 行 .../task/CustomerImportExecutor.java @Async 执行器,328 行 ``` ### 3.2 待办(按序) 1. **`controller/CustomerImportController.java` 未写**(上一次 Write 被取消)。6 端点(issues-09 §D): `POST /api/customer/import/upload`(MultipartFile + @RequestParam importMode)/ `POST /{taskId}/confirm` / `GET /{taskId}` / `GET /{taskId}/failures` / `GET /page` / `GET /template`(返回 `ResponseEntity`,对称 `crm-file/controller/FileController.thumbnail` 风格:ContentDisposition + xlsx MediaType + ContentLength)。 2. **编译**(票 08 所有新文件从未编译过——最大风险点)。 3. **H2 集成测试** `CustomerImportIntegrationTest`(骨架抄 `CustomerTransferIntegrationTest`:H2 mem + MybatisConfiguration + MetaObjectFillHandler + ReflectionTestUtils 填 baseMapper/entityClass)。 SCHEMA 需要:`customer`(全列 DDL,抄票 07 测试的 SCHEMA)+ `customer_contact` + `customer_oplog` + `customer_import_task` + `customer_import_fail`。 mock:`SecurityUtils`(静态)、`IAuthUserService`、`ISysDeptService`、`CustomerNameSimilarityPort`(桩:返回命中即触发 SUSPECT)、`FileApi`(upload→FileInfoDTO.fileId;download→FileDownloadDTO 带 ByteArrayInputStream)。 **Executor 是 @Component 无 self 代理**——测试直接 new + 反射填 9 个依赖 + 直调 `executeImport`(@Async 无代理时天然同步,不 flaky)。 建议覆盖:模板表头校验拒绝 / 三模式判定(APPEND_ONLY 命中→FAIL「客户已存在」;UPDATE_ONLY 未命中→FAIL「未找到可更新客户」;UPSERT 名称相似→SUSPECT 不写入)/ 编号与信用代码命中不同客户→冲突 FAIL / 文件内多行匹配同一客户→重复组全 FAIL(Q14)/ 更新空白单元格不改值 + 值相同计 unchanged / 联系人 D26 四规则(本客户电话→UPDATE;他客户→照常 INSERT + suspect 明细;电话空同名同职务→SUSPECT;同文件同客户同电话两行→FAIL)/ 联系人失败不回滚主记录 / 状态机 DRAFT→RUNNING→DONE + 重复 confirm 67014 / page 仅本人任务。 4. **BOM 扫描** + 全模块测试 + `mvn compile`。 5. 票 08 **不涉及 crm-dict**(无新字典分组)。 ### 3.3 关键设计决策(本会话拍定,实现已落) - **文件存储走 crm-file**:upload 时 `fileApi.upload(bytes, name, contentType, "customer-import")` → 任务只存 `fileFileId`(String)+ `fileHash`(SHA-256 hex)。执行时 `fileApi.download(fileId)` 重新取流解析。 ⚠ `CustomerImportTask.java` 原有旧版(存 `fileName` + `failDetailJson` longtext)已被本会话覆盖——旧版**没有文件引用**,执行时无内容可解析,属设计缺陷;失败明细改落独立表 `customer_import_fail`(可分页/下载,优于 JSON blob)。 - **模板版本锚点 = 表头文本逐列比对**(不用单独锚点行):`CUSTOMER_HEADERS` 9 列常量,任一列不符 → 67013「模板版本不匹配」。模板生成 = 双 sheet(客户/联系人)纯表头无示例行(示例行会被当数据读入)。 - **判定与执行分离但共用同一 Analyzer**:预计数与实际结果同口径(避免「预校验说能导、执行却失败」)。 - **异步无登录态**:`MetaObjectFillHandler` 用 `SecurityUtils.getUserId()`(非 getRequired),无登录态审计字段留 null 属正常(该类注释明说);owner 快照从 `task.creatorId` 反查 `userService.getById`;客户级 oplog 用 `writeSystem` + detail 带任务 ID。 - **导入创建客户**:`ownerUserId` = 提交人(issues-09 §C 普通销售固定)、`customerStage` = STAGE_POTENTIAL(不得指定)、编号行内优先否则 `nextCustomerNo()` 续号(KH+yyyyMMdd+4 位,对称交割 JG)。 - **更新白名单列**:customerType/provinceCode/cityCode/industryCode/customerStarLevel/remark(负责人/阶段/工商字段/信用代码不入模板 → 天然禁改)。空白 = 不修改;值相同 = `unchangedCount++` 不写库。 - **疑似重复双义**:客户行 SUSPECT(名称相似)= **不写入** + suspectCount;联系人他客户电话命中 = **照常写入** + suspectCount + fail 表明细(D26「软提示不禁止导入」)。fail 表 reason 以「疑似重复」前缀区分。 - **联系人挂接**:`customerIdByNo` 映射(新增行落库后回填 / 命中行直接放);归属客户未成功导入 → 联系人行 FAIL「归属客户未成功导入」。 --- ## 4. 本会话新踩坑(务必先读,全是已修复的编译/测试错) | # | 坑 | 现象 | 修法 | |---|---|---|---| | ① | **MP `BaseMapper.updateById` 返回 `int`**(非 boolean) | `!customerMapper.updateById(c)` → 「一元运算符 '!' 的操作数类型 int 错误」 | `if (customerMapper.updateById(c) <= 0)`。注意 `IService.updateById`(`this.updateById`)返回 boolean——**mapper 级与 service 级签名不同** | | ② | **batch 的单条方法必须在接口上** | `self.assignOne(...)` 找不到符号(self 声明为接口类型) | 把 `assignOne` 提升到 `ICustomerTransferService`(票 04 范式:claim/assign/releasePool 等单条动作全在接口) | | ③ | **DTO/Param 包路径** | `import ...domain.dto.TransferPageParam` 找不到 | Param 类在 `domain.param` 包(TransferPageParam/ImportPageParam 等),DTO 在 `domain.dto` | | ④ | **`BaseParam` 分页字段是 `current`/`size`**(不是 pageNo/pageSize) | 测试 `setPageNo(1L)` 编译错 | 有默认值(current=1/size=10),通常不用设 | | ⑤ | **`PageResult` 取列表是 `getContent()`**(非 getRecords) | 测试编译错 | `PageResult` 字段:content/total/size/current/pages/empty | | ⑥ | **Mockito `when(svc.list(any()))` 二义性** | 「对 list 的引用不明确」(IService 有 `list(Wrapper)` 与 `list(IPage)` 重载) | `Mockito.>any()` 显式限定 | | ⑦ | **Mockito 未 stub 的集合方法返回空集合** | `userService.list(...)` 未 stub → assignable 列表恒空,断言失败 | 显式 stub;这是 assignableUsers 测试失败的根因 | | ⑧ | **`TransferInitiateDTO.reason` 必填** | 测试 `new TransferInitiateDTO()` 直接 initiate → 67011「交接原因必填」 | 测试统一走 `initiateCmd()` helper 设 `reason="resign"` | | ⑨ | **mvn 输出经 PowerShell 管道后 ExitCode 误报** | `Select-Object -Last N` / `Select-String` 后 `$LASTEXITCODE=1` 但测试其实全绿 | **以 surefire 报告为准**:`Get-Content target\surefire-reports\<类全名>.txt` 看 `Tests run: N, Failures: 0, Errors: 0` | 历史坑(前几次会话,仍适用):MP `updateById` 默认 NOT_NULL 策略跳过 null 字段(清空字段须 `update(entity, wrapper)` + `LambdaUpdateWrapper.set(col, null)`,wrapper 必须自带 `eq(id)`);高频副作用更新不 bump version 用 `update(null, wrapper)`;`@TableLogic` 软删行查询不可见(验证软删须原生 JDBC);H2 测试 SCHEMA 必须含实体**全部列**(含 BaseEntity 审计列 creator_id/updater_id/deleted);`.java` 必须 UTF-8 无 BOM。 --- ## 5. 构建 / 测试命令(本机固定范式) mvn 不在 PATH;JAVA_HOME 指向 IDEA 自带 JBR。**PowerShell 用 `;` 不用 `&&`**。 ```powershell # 编译(多模块) $env:JAVA_HOME='D:\IntelliJ IDEA 2025.1.3\jbr'; & G:\apache-maven-3.8.4\bin\mvn.cmd -q -s e:\code\crm-backend-matt\settings.xml compile -pl crm-customer,crm-dict -o 2>&1 | Select-String "ERROR|BUILD" | Select-Object -First 8; echo "EXIT=$LASTEXITCODE" # 单测试类 $env:JAVA_HOME='D:\IntelliJ IDEA 2025.1.3\jbr'; & G:\apache-maven-3.8.4\bin\mvn.cmd -q -s e:\code\crm-backend-matt\settings.xml test -pl crm-customer "-Dtest=CustomerTransferIntegrationTest" -DfailIfNoTests=true -o 2>&1 | Select-Object -Last 12 # 结果以 surefire 报告为准(见陷阱 ⑨) Get-Content "E:\code\crm-backend-matt\crm-customer\target\surefire-reports\com.crm.customer.service.impl.CustomerTransferIntegrationTest.txt" | Select-Object -First 4 # 全模块测试 ... test -pl crm-customer -o Select-String -Path "E:\code\crm-backend-matt\crm-customer\target\surefire-reports\*.txt" -Pattern "Tests run:" | ForEach-Object { $_.Line } # BOM 扫描(每票收工必跑) $files = Get-ChildItem -Recurse -Filter *.java -Path "E:\code\crm-backend-matt\crm-customer\src","E:\code\crm-backend-matt\crm-dict\src"; $bad = @(); foreach ($f in $files) { $b = [IO.File]::ReadAllBytes($f.FullName); if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) { $bad += $f.FullName } }; echo "TOTAL=$($files.Count) BOM_BAD=$($bad.Count)" ``` 编译错误定位技巧:`2>&1 | Select-String "ERROR.*\.java" | Select-Object -First 10`(只取带文件名的错误行,避开 PowerShell 中文乱码噪声)。 --- ## 6. 接手建议顺序 1. 读本文第 3、4 节 → 读 `tickets/08-customer-import.md` + `issues/09-answer.md`(定稿,勿凭记忆)。 2. 写 `CustomerImportController.java`(3.2 第 1 项)。 3. `mvn compile -pl crm-customer -o` → 按第 4 节陷阱表逐个修(**预期有编译错**,14 个新文件从未编译)。 4. 写 `CustomerImportIntegrationTest`(3.2 第 3 项的用例清单)→ 单类跑绿 → 全模块跑绿(预期 75 + 新增)。 5. BOM 扫描 → TodoWrite 置 t08 COMPLETE → 票 09。 6. 票 09–14 依次推进(票 09 起要动 `crm-rule`,注意其 CONTEXT.md 与既有单例范式)。 **TodoWrite 当前状态**:t01–t07 COMPLETE(含 t04a–t04e 子任务)、t08-import IN_PROGRESS、t09–t14 PENDING。 --- ## 7. Suggested skills | 场景 | skill | |---|---| | 继续实现(默认) | `/implement .scratch\customer-module`(本次流水线的主 skill,base: `C:\Users\Administrator\.agents\skills\implement`) | | 票 08 测试想用 TDD 补齐(Analyzer 判定逻辑是天然 seam) | `/tdd` | | 票 08/09 设计有疑义(如联系人挂接边界、规则族单例形态)需要压测决策 | `/grill-me` 或 `/grill-with-docs`(后者会同步产出 ADR + 术语表) | | 票 09 要在 `crm-rule` 落「客户规则族」单例,涉及跨模块架构决策 | `/codebase-design`(deep module 词汇)或 `/domain-modeling`(记 ADR) | | 票 12–13 收尾前自查 | `/code-review`(两轴:Standards + Spec;spec 轴对照 `tickets/*.md`) | | 收尾提交 | 直接 git(**不要**用 `/commit` 类 skill 跳过 review);用户明确要求过 code-review 后再提交 | | 卡住/回归排障 | `/diagnosing-bugs` | **不要**调用:`/handoff`(本文件即是产物,避免套娃)、`/to-spec`/`/to-tickets`(spec 与 13 票已定稿冻结,重开会推翻已拍板的 D1–D29)。 --- ## 8. 参考路径速查 - 决策索引:`.scratch/customer-module/map.md`(D1–D29 自包含) - 票清单:`.scratch/customer-module/tickets/01..13-*.md` + `README.md`(通用要求:UTF-8 无 BOM / 孤立单大写字母检查 / 依赖方向单向 opportunity→customer / 每票 mvn compile + H2 集成测试) - 逐票定稿:`.scratch/customer-module/issues/NN-answer.md` - 工程纪律:`AGENTS.md`(BOM 规则、JPA 命名策略陷阱、edit 工具降级、stream dropout 应对) - 领域上下文:`crm-customer/CONTEXT.md`、`CONTEXT-MAP.md` - ADR:`docs/adr/0018`(per-module data-scope)、`0021`(CAS)、`0022`(读写分离深模块)、`0023`(批量与视图统计)、`0024`(Bruno 分组)、`0028`(BatchRunner)、`0029`(port 单向依赖解环) - 对称参照实现:`crm-opportunity`(商机图已完成)、`crm-lead`(线索状态机 + self 代理范式源头) - 测试范式源头:`crm-customer/src/test/.../CustomerWorkspaceOwnershipIntegrationTest.java`(703 行,H2 + mock auth + ReflectionTestUtils 全套骨架)