You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

186 lines
16 KiB

4 days ago
# 实现进度交接 — 客户模块 /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<byte[]>`,对称 `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.<Wrapper<AuthUser>>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 全套骨架)