Browse Source

commit

master
luoweijian 2 days ago
parent
commit
746b032092
  1. 81
      .scratch/customer-rework/HANDOFF-2026-09-05-API-SUMMARY-WIP.md
  2. 72
      .scratch/customer-rework/RETEST-REPORT.md
  3. 16
      .scratch/customer-rework/audit-findings.md
  4. 65
      .scratch/customer-rework/bru-staging/客户公海/上传导入文件并预校验.bru
  5. 65
      .scratch/customer-rework/bru-staging/客户导入/上传导入文件并预校验.bru
  6. 71
      .scratch/customer-rework/bru-staging/我的客户/批量更新工商信息.bru
  7. 61
      .scratch/customer-rework/bru-staging/战略协议/删除战略协议.bru
  8. 78
      .scratch/customer-rework/bru-staging/战略协议/战略协议详情.bru
  9. 80
      .scratch/customer-rework/bru-staging/战略协议/战略协议页签列表.bru
  10. 69
      .scratch/customer-rework/bru-staging/战略协议/新增战略协议.bru
  11. 71
      .scratch/customer-rework/bru-staging/战略协议/编辑战略协议.bru
  12. 4326
      .scratch/customer-rework/bruno-recon-d.json
  13. 270
      .scratch/customer-rework/bruno_scan_d.py
  14. 37
      .scratch/customer-rework/deploy_bru.py
  15. 20
      .scratch/customer-rework/issues/09-audit-fixes.md
  16. 24
      .scratch/customer-rework/issues/10-regression-retest.md
  17. 40
      .scratch/customer-rework/issues/12-agreement.md
  18. 14
      .scratch/customer-rework/map.md
  19. 11
      crm-customer/src/main/java/com/crm/customer/constant/CustomerConstants.java
  20. 63
      crm-customer/src/main/java/com/crm/customer/controller/CustomerAgreementController.java
  21. 9
      crm-customer/src/main/java/com/crm/customer/controller/CustomerController.java
  22. 7
      crm-customer/src/main/java/com/crm/customer/controller/CustomerImportController.java
  23. 52
      crm-customer/src/main/java/com/crm/customer/domain/dto/AgreementDTO.java
  24. 54
      crm-customer/src/main/java/com/crm/customer/domain/dto/CompanyBatchUpdateDTO.java
  25. 3
      crm-customer/src/main/java/com/crm/customer/domain/dto/ImportPreviewDTO.java
  26. 55
      crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerAgreement.java
  27. 5
      crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerImportTask.java
  28. 11
      crm-customer/src/main/java/com/crm/customer/mapper/CustomerAgreementMapper.java
  29. 29
      crm-customer/src/main/java/com/crm/customer/service/ICustomerAgreementService.java
  30. 7
      crm-customer/src/main/java/com/crm/customer/service/ICustomerImportService.java
  31. 10
      crm-customer/src/main/java/com/crm/customer/service/ICustomerService.java
  32. 173
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerAgreementServiceImpl.java
  33. 13
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerContactServiceImpl.java
  34. 29
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerDetailServiceImpl.java
  35. 29
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportAnalyzer.java
  36. 16
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportServiceImpl.java
  37. 100
      crm-customer/src/main/java/com/crm/customer/service/impl/CustomerServiceImpl.java
  38. 3
      crm-customer/src/main/java/com/crm/customer/task/CustomerImportExecutor.java
  39. 439
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerAgreementIntegrationTest.java
  40. 32
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerContactServiceIntegrationTest.java
  41. 15
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerDetailMembersIntegrationTest.java
  42. 38
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerFollowDetailIntegrationTest.java
  43. 54
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerImportIntegrationTest.java
  44. 78
      crm-customer/src/test/java/com/crm/customer/service/impl/CustomerServiceImplIntegrationTest.java
  45. 3758
      logs/crm-app.log
  46. BIN
      logs/crm-app.log.2026-09-04.0.gz

81
.scratch/customer-rework/HANDOFF-2026-09-05-API-SUMMARY-WIP.md

@ -0,0 +1,81 @@
# 交接篇:票 11 API-SUMMARY 重写进行中(SESSION 2,2026-09-05)
> 换机交接。上篇 `.scratch/customer-rework/HANDOFF-2026-09-05.md` 交接到本会话已完成票 09/12/10;
> 本篇从**票 11 第 2 项(API-SUMMARY.md 整篇重写,素材已采齐、正文未落笔)**处交接。
## 0. 会话框架(先读)
- **模式**:wayfinder「Work through the map」,地图 `.scratch/customer-rework/map.md`
- **用户既定指令**:「继续做,把整张 map 做完」。票据在 `.scratch/customer-rework/issues/`
- **纪律**:每票收尾=票内追加 `## Answer` + `Status: resolved` → rework map.md「Decisions so far」追加一行索引。
- **Do not**:票据/ADR/map 已记录的内容不重述,按路径引用(下同)。
## 1. 当前进度
| 票 | 状态 |
|---|---|
| 09(F6/F7/F8/F10)、12(战略协议) | resolved(Answer 见各票文件) |
| 10 回归重测 | **resolved**——五项验收全过,报告 `.scratch/customer-rework/RETEST-REPORT.md`;冒烟 `tmp/smoke-t10.py` 43 步全绿;服务已停、环境已还原 |
| **11 文档同步** | **进行中**(五项清单见 `issues/11-docs-sync.md`):<br>① customer-module/map.md 返工增补段 —— 未做<br>**API-SUMMARY.md 重写 —— 素材齐备,正文未落笔(本篇 §2 是全部素材)**<br>`.scratch/customer-module/HANDOFF-2026-09-05.md` 新篇(旧篇不改写)—— 未做<br>④ crm-customer/CONTEXT.md 同步 —— 未做<br>⑤ URL 对账脚本零差异 + 票 11 Answer/resolved + rework map 加 11 行 + map Status 收官 —— 未做 |
## 2. API-SUMMARY 重写素材包(核心交接,事实均已在源码/冒烟实证)
### 2.1 权威端点清单
脚本产物 `tmp/dump_api_out.txt`(crm-customer + crm-rule + crm-preference 全部 controller:类级前缀 + 动词 + 路径 + 参数行),`tmp/dump_params_out.txt` 为路径-only 版。要点:
**crm-customer**(写=POST 表单、读=GET 查询参数;全部无 @PathVariable/PUT/DELETE):
- `CustomerWorkspaceController /api/customer/workspace`:POST `/page`、POST `/board/summary`、POST `/board/cards`——表单绑定 `CustomerWorkspacePageParam`
- `CustomerController /api/customer`:POST `/create`(CustomerCreateDTO)、POST `/edit`(**id + CustomerUpdateDTO**)、GET `/detail?id=`、POST `/page`(CustomerPageParam)、POST `/search`(CustomerSearchParam)、POST `/quick-create`(CustomerQuickCreateDTO)、GET `/contacts?customerId=`、GET `/check-name?name=&excludeId=`、GET `/check-credit-code?creditCode=`、GET `/company-lookup?companyName=`、POST `/company-lookup/batch-update?ids=`(F6)
- `CustomerDetailController /api/customer`:GET `/detail-head?id=`、GET `/member/list?id=`、POST `/member/add`(**id + MemberAddDTO{memberUserIds[]}**)、POST `/member/remove?id=&memberUserId=`、GET `/opportunity/page?id=`、GET `/oplog/page?id=`(+OplogPageParam)、POST `/follow/add`(id + FollowCreateDTO)、GET `/follow/page?id=`
- `CustomerContactController /api/customer/contact`:POST `/create`(ContactDTO)、POST `/edit`(**id + ContactDTO**)、POST `/delete?id=`、GET `/detail?id=`、GET `/page`(ContactPageParam=BaseParam,keyword 匹配姓名/电话/所属客户名)、GET `/search?keyword=&limit=`(**limit 默认 20**)、GET `/check-phone?phone=&excludeId=`(D26 软提示,受查重设置电话开关控制)
- `CustomerFocusController /api/customer`:POST `/focus|/unfocus|/star|/unstar?id=`、POST `/focus-batch?ids=`
- `CustomerOwnershipController /api/customer`:POST `/claim|/claim-batch`、`/assign`(**id+userId**)|`/assign-batch`(**ids+userId**)、`/release-pool|/release-pool-batch`、`/archive|/archive-batch`、`/restore?id=`(单数 id、批量 ids)
- `CustomerTransferController /api/customer/transfer`:GET `/preview`、POST `/initiate`(TransferInitiateDTO:reason 必填 resign/transfer_post/region_adjust + toDirectorId 选填 + remark)、GET `/detail?id=`、GET `/page`(TransferPageParam)、POST `/assign`(**id + customerIds + assignUserId**,BatchResult 行级部分成功)、GET `/assignable?id=`(**D-07 P1 超时已知缺陷**)
- `CustomerImportController /api/customer/import`:POST `/upload`(**MultipartFile file + importMode + duplicateStrategy 可选**)、GET `/template`、POST `/confirm?taskId=`、GET `/result?taskId=`、GET `/failures?taskId=`、GET `/page`(ImportPageParam:status/importMode)
- `CustomerAgreementController /api/customer/agreement`(票 12 五端点):POST `/create`(AgreementDTO)、POST `/edit`(**id + AgreementDTO**,归属不可换客户)、POST `/delete?id=`、GET `/detail?id=`、GET `/list?customerId=`
- `CustomerPingController`:GET `/api/customer/ping`
**crm-rule 客户规则族**:GET `/api/rule/customer/reminder` + POST `/api/rule/customer/reminder/save`(CustomerReminderRuleDTO 表单:masterEnabled/firstTriggerEnabled/firstTriggerDays/secondIntervalEnabled/secondIntervalDays);GET `/api/rule/customer/dedup` + POST `/api/rule/customer/dedup/save`(CustomerDedupRuleDTO 表单:masterEnabled/nameEnabled/nameMatchMode(1精确2模糊)/similarityThreshold/phoneEnabled)。(crm-rule 其余族=线索池/商机池规则/方案卡模板/阶段模板/地区——**不在客户域文档范围**,勿抄进客户 API-SUMMARY)
**crm-preference 平台偏好**(不在 ADR-0017 改造范围,**saved-view save 例外用 @RequestBody JSON**):GET `/api/preference/get?scopeKey=` + POST `/api/preference/save`(scopeKey + visibleKeys + columnOrder 可空 List);GET `/api/preference/view/list?scopeKey=` + POST `/api/preference/view/save`(scopeKey + JSON SavedView) + POST `/api/preference/view/delete?scopeKey=&viewId=` + POST `/api/preference/view/set-default?scopeKey=&viewId=`;GET `/api/preference/view-form/get?scopeKey=` + POST `/api/preference/view-form/save`(scopeKey + viewForm)(返工票 05)
### 2.2 关键值域/事实(源码锚点)
- **错误码 67001-67017** 全表见 `crm-customer/.../constant/CustomerConstants.java` L13-29(67017=CODE_AGREEMENT_INVALID 票 12 新增)。
- **oplog action 值域 17 种**(同文件 L85-101):CREATE/UPDATE/FOLLOW/ARCHIVE/RESTORE/TRANSFER/CLAIM/POOL/STAGE_CHANGE/MEMBER_ADD/MEMBER_REMOVE + CONTACT_ADD/CONTACT_EDIT/CONTACT_DELETE(F10) + AGREEMENT_ADD/AGREEMENT_EDIT/AGREEMENT_DELETE(票 12)。⚠ `OplogPageParam`@Schema 描述仍写 11 种(滞后,未改——票 11 不动代码,文档按 17 种真值写)。
- **F7 终态**(票 09 Answer 澄清):ownerOnly 等布尔字段**未做**,viewType 单参数承载五内置视图 `CustomerWorkspaceViewType`:ASSIGNED/COLLABORATING/FOLLOW_UP_DUE/FOCUSED/RECENT;workspace 适配 mine 全五、overview 无 FOLLOW_UP_DUE、pool 仅 RECENT/FOCUSED;越界视图 SQL 自然空集不硬拒;savedViewId 叠加自定义视图(RECENT 不接)。customer_view_log 生产侧零写入 → detailHead upsert touchViewLog 收口(票 04 建壳挂账)。
- **F8 duplicateStrategy**(票 09 Answer):upload 可选参数,SKIP 缺省/OVERWRITE,非法值 67013 拦;D24 冻结语义=作用面仅文件内重复组、overwrite=组内最后一条通过校验的行 UPDATE 生效、不触名称相似 SUSPECT;响应回显 duplicateStrategy。
- **F6 batch-update**:`POST /api/customer/company-lookup/batch-update?ids=`(ids 表单绑定、上限 100、§C2 只补空缺、五种状态软处置、port stub 恒 NO_HIT、真实企查查接入自动激活)。
- **groupColumn 白名单**(CustomerWorkspaceServiceImpl L185-191):`stage`→customer_stage / `star`→customer_star_level / 其他→relation_star_level;board/cards 必传 groupValue 否则 67001;**pool 看板 67001 拒绝**(「公海无卡片视图」,票 03)。
- **scopeKey 清单**(CustomerConstants L53-81):saved-view/列/形态共用 `customer.overview` / `customer.mine` / `customer.pool`;列/形态另有 `contact.list`;view-form 公海仅 list/split(board 被拒);商机侧 `opportunity.mine`(冒烟实测)等。
- **company-lookup 参数 = `companyName`**(旧 API-SUMMARY 写 `keyword=` 是错的,以代码为准)。
- **AgreementDTO 字段**:customerId(新增必填,编辑以库内归属为准)/agreementLevel(1~3 必填)/amount(文本可空)/remark/fileFileId + customerName(仅出参);删协议联动清主表 strategic_agreement_level 快照。
- **67017 语义**:等级缺失或越界/协议不存在或无权访问。
- **旧文档 §0/§1/§5 骨架保留**:通用约定(Bearer/Result/PageResult/雪花串化/fail-open/脱敏现状/时间格式)+ 错误码表扩到 67017 + §5 销账(1 confirmSimilar、2 查重设置端点保持删除线;3 改为「页签 5 关联项目仍 --、页签 6 战略协议已上线→§2.x」;4/5 保持划出项;**新增 D-07 assignable 超时已知缺陷条目**)。
- **计划段落结构**(重写时沿用旧骨架编号顺延):§2.1 三 workspace(含 F7 表格式 Param 说明)→ 2.2 公海与归属动作 → 2.3 新增/编辑/查重/工商(含 F6) → 2.4 商机快建+商机侧依赖 → 2.5 详情公共头部+8 页签(页签 6 引战略协议节)→ 2.6 战略协议五端点 → 2.7 联系人独立菜单 → 2.8 团队成员 → 2.9 关注/重点 → 2.10 归档/恢复 → 2.11 交割 → 2.12 导入(含 F8) → 2.13 规则设置 → 2.14 偏好三件套+scopeKey+JSON 例外 → 2.15 ping;§3 值域附录、§4 联调速查(冒烟范式改 `tmp/smoke-t10.py`)、§5 缺口。目标 <1000 旧文档 `.scratch/customer-module/API-SUMMARY.md`194 供对照
### 2.3 票 11 后续步骤细节
- **① map 增补段**:`.scratch/customer-module/map.md` 末尾(L140-142 已有「返工增补指针」一行段)扩为完整段:R1-R11 决策指针(→ rework map)+ 每张返工票(01-10、12)一行增量决策。素材=rework map「Decisions so far」11 行索引 + 各票 Answer。
- **③ HANDOFF 新篇**:`.scratch/customer-module/HANDOFF-2026-09-05.md`(正式收工篇,区别于 customer-rework 下的交接篇);链接 RETEST-REPORT;旧 HANDOFF 不改写。
- **④ CONTEXT.md**:`crm-customer/CONTEXT.md` 若有接口清单段,同步新 URL 契约 + 偏好能力(列/形态/saved-view)+ 战略协议。
- **⑤ URL 对账**:写脚本从新 API-SUMMARY.md 提取全部 URL 与 `@*Mapping` 对账(可复用 `tmp/dump_api.py` 输出做基准);零差异后票 11 Answer/resolved + rework map 加 11 索引行 + 检查 map「Not yet specified」清空 → **整图收官**
## 3. 工具坑(本机实证,勿重蹈)
- **PowerShell**:`-Command` 双引号串里 `$_`/`$env:` 被吞 → 一律写 .ps1 文件用 `-File`;语句分隔用 `;` 不用 `&&`
- **写文件范围**:Write/SearchReplace 只限 workspace(`d:\code\crm-backend-matt`)内;docs 仓 `D:\code\crm-api-docs` 等外部路径 → 先写 workspace 内 staging 再 python 复制(先例 `.scratch/customer-rework/deploy_bru.py`)。
- **py**:统一 `py -3 -X utf8`;`-c` 内嵌脚本引号易炸 → 落 .py 文件再跑。
- **服务起停**(如需复跑冒烟):先例 `.scratch/customer-e2e/map.md` L29(verify profile;MINIO 凭据见该处,不在本文档重复);重打包 `tmp/pack-crmapp.ps1`(fat jar 会落后源码,核对时间戳);`tmp/run-crmapp.ps1` / `tmp/stop-crmapp.ps1`。**当前服务已停**。
- **冒烟实测参数**(写文档校对用):`tmp/smoke-t10.py`——edit 必带 `version`+`isBizNegotiated`+`isChild`;contact/create 必填 `name/jobTitleName/phone`;preference save 用 visibleKeys/columnOrder 逗号串。
## 4. Suggested skills
- `wayfinder`:Work through the map 模式续作,地图 `.scratch/customer-rework/`,从票 11 接续。
- `bruno-sync`:仅当对账再需要时(当前 321=321 已闭环,无需重跑)。
## 5. 验收线(票 11)
文档-代码对账零差异;旧 HANDOFF 不改写(历史只增);五项清单全勾 + Answer/resolved + rework map 11 行 → 整张 map 收官。

72
.scratch/customer-rework/RETEST-REPORT.md

@ -0,0 +1,72 @@
# 客户模块返工 · 回归重测报告(票 10)
- **日期**: 2026-09-05
- **范围**: 返工票 01–09、12 全部改动后的回归验收(票 11 文档同步不在本报告内)
- **结论**: **五项验收全过,收工。**
## 验收线逐项结果
### 1. `mvn test` 三模块全绿 ✅
全 reactor `clean test -pl crm-opportunity -am`(含 rule/customer/opportunity 传递依赖):
| 模块 | 用例数 | 结果 |
|---|---|---|
| crm-rule | 152 | 全绿 |
| crm-customer | 158 | 全绿(基线 122,返工净增 36:风格改造 + 看板拒绝 + 查重设置 + 形态偏好 + viewTouch + 批量工商 + 战略协议 6 例 + 联系人 oplog) |
| crm-opportunity | 242 | 全绿(偏好 scopeKey 常量接入 + tags 双归属,零逻辑变更) |
基线 521(146/122/242)→ 现 552,数量随用例变化、绿为准(票面口径)。
### 2. 冒烟全链 code=0 ✅
脚本 `tmp/smoke-t10.py`(复用 smoke-t12m.py 范式,URL 全部现行端点),服务 = 重打包后 fat jar(新代码入包,jar 时间戳 2026-09-05 11:18,启动日志可见 `customerAgreementMapper` 注册)。
**43 步 0 fail(SMOKE DONE)**,覆盖:
- 登录(debug/token,verify profile)
- 客户三 workspace 列表(mine/overview/pool)+ 看板 summary/cards(mine/overview)+ **pool 看板断言 67001 拒绝**(返工票 03 口径)
- 建改查客户(quick-create → edit[CAS version] → detail → detail-head)
- 联系人 CRUD(create/edit/detail/page/delete)
- 抛公海 → 领取 → 分配(BUDDY) → 回收(ADMIN)
- 交割只读(page + assignable,见下方 D-07 说明;initiate=名下全量口径不重做,e2e-heavy 已覆盖)
- 导入全链(openpyxl 造文件 → upload[**含 duplicateStrategy=SKIP 且响应回显断言**,返工票 09-F8] → confirm → result 终态)
- 偏好存取:列偏好 save/get(visibleKeys/columnOrder 回读断言)+ 形态偏好 view-form save/get(返工票 05)
- 商机偏好存取(opportunity.mine,返工票 07)
- **战略协议五端点 + DB 快照断言**(返工票 12:create 后快照=2 → edit 后=3 → delete 后=NULL)
- **批量工商更新**(返工票 09-F6:ids 单查回填,stub 恒 NO_HIT)
冒烟过程登记的脚本级修正(非服务缺陷):edit 需带 CAS `version` + `isBizNegotiated`;contact/edit 必填 jobTitleName/phone。
**已知缺陷引用(非本次回归)**:
- **D-07(P1)** `GET /api/customer/transfer/assignable` 稳定超时——票 05 已登记(e2e-heavy 30s×2 + probe 60s 双复现),本次历史单上 15s 超时复现,冒烟按「缺陷验证模式」记录不阻断(根因:根部门全树递归 + 逐用户 getDeptNames N+1)。
- **D-04(P2)** 导入失败明细 `fail_reason` 列超长写不进(`Data too long for column 'fail_reason'`,服务日志实证);**D-05(P1)** is_biz_negotiated NOT NULL 无默认值——均为票 09 audit 已知项,维持原状。
### 3. bruno 产出验收 ✅
- 全量对账(`.scratch/customer-rework/bruno_scan_d.py`,本机 D:\ 路径版):**源码 321 钥匙 = 文档仓 321 generated 文件,new=0 / delete=0 / 钥匙冲突=0 / 无法识别=0 / 私产=0**。
- 本次同步落盘 8 文件(票 09/12 增量):
- 新建 `A4 客户管理/战略协议/`(5 份:新增/编辑/删除/详情/页签列表,票 12 tag=`A4 客户管理/战略协议`——票 12 拍板「三页物理在客户详情下、单菜单归属」,对齐联系人先例)
- 新建 `A4 客户管理/我的客户/批量更新工商信息.bru`(F6)
- patch `上传导入文件并预校验.bru` ×2(客户公海 + 我的客户/客户导入 双 tag 副本:duplicateStrategy 参数行 + 请求示例行 + 响应骨架,F8)
- A4 目录树与 v29 对齐终验:A4 七节点(公海/总览/我的客户/客户详情/联系人/查重/交割)+ 战略协议按票 12 拍板挂 A4 根;knife4j 分组同构维持(票 07 终态无漂移)。
### 4. BOM 扫描 ✅
全仓 `*.java``EF BB BF` 前缀(AGENTS.md 编码纪律),零命中。
### 5. 风格 grep 终验 ✅
`grep "@PathVariable|@PutMapping|@DeleteMapping|@RequestBody" crm-customer/` 零命中。
## 用例计数明细
- crm-rule 152:含提醒规则 save 表单化(票 02)+ 查重设置单例栈(票 04)
- crm-customer 158:含 H2 集成 CustomerAgreementIntegrationTest 6 例(快照联动/归属不可换/软删/可见性继承/67017 校验)
- crm-opportunity 242:CustomerOpportunityQueryPortImplTest 等全量(含跨模块 port)
## 遗留(进票 11 / 后续 effort)
- 票 11 文档同步:customer-module 三件套 + crm-customer/CONTEXT.md + URL 对账(本报告同日完成)
- D-07 assignable 超时修复(P1,独立 effort,返工范围外)
- 导入 D-04/D-05(已知缺陷,票 09 audit 记档)

16
.scratch/customer-rework/audit-findings.md

@ -66,3 +66,19 @@
- 机械修:S1(64022 改码)、F3(tag 补挂,确认后)、F10(oplog 参数或记账) - 机械修:S1(64022 改码)、F3(tag 补挂,确认后)、F10(oplog 参数或记账)
- 需用户拍板(grill):F5(战略协议补 or 划出,唯一有完整原型三页的缺口)、F6(批量工商更新)、F7(内置视图过滤参数)、F8(导入重复策略) - 需用户拍板(grill):F5(战略协议补 or 划出,唯一有完整原型三页的缺口)、F6(批量工商更新)、F7(内置视图过滤参数)、F8(导入重复策略)
- 划出候选确认:F4(关联项目→A5 依赖)、F9(日志导出→导出家族) - 划出候选确认:F4(关联项目→A5 依赖)、F9(日志导出→导出家族)
## 四、处置状态(票 09 收尾回填,2026-09-05)
| 条目 | 处置 | 说明 |
|---|---|---|
| S1 | 已修 | 实改方向与上节建议**相反**(64022 已被 `CODE_SCHEME_TPL_FIELD_INVALID` 占用):`CODE_SCHEME_TPL_INVALID` 保 64018(64018-64022 归方案卡模板段),`CODE_STAGE_TPL_SEED_PROTECTED` 64018→64024 顺延补位;段位 javadoc + 引用处 + 测试同步 |
| F3 | 已修 | 六个 import 端点双 tag 增挂「客户公海」(a4-1-1 工具条实证导入入口,ADR-0024 多值) |
| F4 | 划出 | 随 A5 项目域:本工程无项目管理模块,端点与页签随项目模块建立时一并落(用户 2026-09-05 拍板) |
| F5 | 已单开票 12 | 战略协议单表 CRUD(原型三页完整);全栈实现+测试+bruno 移交 12 号票 |
| F6 | 已修 | `POST /api/customer/company-lookup/batch-update`(ids 查询参数表单绑定;逐个单查→guardCreditCodeForWrite→§C2 只补空缺不覆盖→SKIPPED/UPDATED/NO_HIT/CONFLICT/NOT_FOUND 软处置;单批上限 100);port 为 stub 恒 NO_HIT,真实企查查 Bean 接入自动激活;+4 集成用例 |
| F7 | 部分误报 + 已修 | 审计口径偏差:viewType 单参数已承载五内置视图(ASSIGNED/COLLABORATING/FOLLOW_UP_DUE/FOCUSED/RECENT,SQL 与测试俱全),无需新增过滤参数;真缺口=customer_view_log 生产侧零写入(RECENT 死视图,票 04 建壳挂账未兑现)→ detailHead 聚合时自动 upsert touchViewLog(实体 javadoc 明文设计意图),零新端点;+1 upsert 集成用例 |
| F8 | 已修 | import/upload 加 `duplicateStrategy` 参数(SKIP 缺省 / OVERWRITE;非法值 67013;D24 冻结语义:作用面仅文件内重复组,overwrite=组内自上而下最后一条通过校验的行 UPDATE 生效;不触名称相似 SUSPECT——D16 独立规则);task 实体加列 duplicate_strategy、analyzer 穿参、preview DTO、executor、+3 集成用例 |
| F9 | 划出 | 随 D2 导出家族:导出端点族整体未建,先行项不存在(用户 2026-09-05 拍板) |
| F10 | 已修(轻方案) | 客户 oplog 扩 CONTACT_ADD/EDIT/DELETE 三操作类型;联系人 create/update/delete 三处 oplogWriter.write(detail=「新增/修改/删除联系人【名】」);独立联系人日志表方案否决(用户拍板轻方案);+1 集成用例断言三行日志 |
bruno 侧 F6/F8 新参数与 F7 行为的示例同步未在本票内做,随票 10 bruno 对账步统一核补(文档仓本机 `D:\code\crm-api-docs`)。

65
.scratch/customer-rework/bru-staging/客户公海/上传导入文件并预校验.bru

@ -0,0 +1,65 @@
meta {
name: 上传导入文件并预校验
type: http
seq: 1
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/import/upload
body: multipartFormData
auth: inherit
}
body:multipartFormData {
file: @客户导入模板.xlsx
importMode: APPEND_ONLY
duplicateStrategy: SKIP
}
docs {
# 上传导入文件并预校验
`POST /api/customer/import/upload`(本行兼作对账钥匙,勿手改)
文件守门(.xls/.xlsx、≤20MB、模板版本、单 sheet ≤5 万行)→ 行级判定(与执行同口径)→ 任务落待确认态(DRAFT),返回预计数 + 明细(上限 100 条)。已知缺陷 D-05(P1):is_biz_negotiated NOT NULL 无默认值且 createCustomer 未赋值——含 INSERT 行的导入必 FAILED(行不落库);D-04(P2):行异常文本超长致失败明细写不进 fail 表(0 行)。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| file | file | 是 | Excel 文件(.xlsx,multipart) |
| importMode | string | 是 | 导入方式:APPEND_ONLY 仅新增 / UPDATE_ONLY 仅更新 / UPSERT 有则更新 |
| duplicateStrategy | string | 否 | 重复策略:SKIP 不导入(默认)/ OVERWRITE 覆盖导入(返工票 09-F8) |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: multipart/form-data
POST /api/customer/import/upload
file=@客户导入模板.xlsx
importMode=APPEND_ONLY
duplicateStrategy=SKIP
## 响应 data 结构
ImportPreviewDTO(taskId + duplicateStrategy + totalCount/insertCount/updateCount/failCount/suspectCount + 行级明细)
## 错误码
- 67013 导入预校验失败(文件/表头/行数/模板版本)
## 响应示例
// 示例数据(E2E 实测真值),字段结构以上表为准
```json
{
"code": 67013,
"success": false,
"message": "导入方式非法(仅新增/仅更新/新增或更新)",
"data": null
}
```
}

65
.scratch/customer-rework/bru-staging/客户导入/上传导入文件并预校验.bru

@ -0,0 +1,65 @@
meta {
name: 上传导入文件并预校验
type: http
seq: 1
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/import/upload
body: multipartFormData
auth: inherit
}
body:multipartFormData {
file: @客户导入模板.xlsx
importMode: APPEND_ONLY
duplicateStrategy: SKIP
}
docs {
# 上传导入文件并预校验
`POST /api/customer/import/upload`(本行兼作对账钥匙,勿手改)
文件守门(.xls/.xlsx、≤20MB、模板版本、单 sheet ≤5 万行)→ 行级判定(与执行同口径)→ 任务落待确认态(DRAFT),返回预计数 + 明细(上限 100 条)。已知缺陷 D-05(P1):is_biz_negotiated NOT NULL 无默认值且 createCustomer 未赋值——含 INSERT 行的导入必 FAILED(行不落库);D-04(P2):行异常文本超长致失败明细写不进 fail 表(0 行)。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| file | file | 是 | Excel 文件(.xlsx,multipart) |
| importMode | string | 是 | 导入方式:APPEND_ONLY 仅新增 / UPDATE_ONLY 仅更新 / UPSERT 有则更新 |
| duplicateStrategy | string | 否 | 重复策略:SKIP 不导入(默认)/ OVERWRITE 覆盖导入(返工票 09-F8) |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: multipart/form-data
POST /api/customer/import/upload
file=@客户导入模板.xlsx
importMode=APPEND_ONLY
duplicateStrategy=SKIP
## 响应 data 结构
ImportPreviewDTO(taskId + duplicateStrategy + totalCount/insertCount/updateCount/failCount/suspectCount + 行级明细)
## 错误码
- 67013 导入预校验失败(文件/表头/行数/模板版本)
## 响应示例
// 示例数据(E2E 实测真值),字段结构以上表为准
```json
{
"code": 67013,
"success": false,
"message": "导入方式非法(仅新增/仅更新/新增或更新)",
"data": null
}
```
}

71
.scratch/customer-rework/bru-staging/我的客户/批量更新工商信息.bru

@ -0,0 +1,71 @@
meta {
name: 批量更新工商信息
type: http
seq: 94
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/company-lookup/batch-update
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
ids: 1,2
}
docs {
# 批量更新工商信息
`POST /api/customer/company-lookup/batch-update`(本行兼作对账钥匙,勿手改)
a4-3-1 多选确认弹窗(你选择了N个客户,确认要批量查询并更新工商信息吗):逐客户按名称查工商源并回填空缺字段,行级部分成功返回每客户结果;单次上限 100;一期 stub 恒 NO_HIT。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| ids | string | 是 | 客户 id 列表(逗号分隔或多值同名参数,单次上限 100) |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: application/x-www-form-urlencoded
POST /api/customer/company-lookup/batch-update
// 示例数据,非真实返回
ids=1,2
## 响应 data 结构
| 字段 | 类型 | 示例值 |
| --- | --- | --- |
| [] | array | 单客户结果条目,结构如下 |
| [].customerId | string | 1 |
| [].customerName | string | 示例客户 |
| [].status | string | NO_HIT(UPDATED 已回填 / SKIPPED 无空缺 / NO_HIT 未命中 / CONFLICT 信用代码冲突 / NOT_FOUND 客户不存在) |
| [].filledFields | number | 0 |
| [].message | string | 工商源未查询到匹配企业 |
## 响应示例
// 示例数据,字段结构以上表为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": [
{
"customerId": "1",
"customerName": "示例客户",
"status": "NO_HIT",
"filledFields": 0,
"message": "工商源未查询到匹配企业"
}
]
}
```
}

61
.scratch/customer-rework/bru-staging/战略协议/删除战略协议.bru

@ -0,0 +1,61 @@
meta {
name: 删除战略协议
type: http
seq: 3
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/agreement/delete
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
id: 1
}
docs {
# 删除战略协议
`POST /api/customer/agreement/delete`(本行兼作对账钥匙,勿手改)
软删;删空后客户战略协议等级快照回 NULL=未签。权限继承所属客户(无独立 ACL),无权 67017/67002。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| id | string | 是 | 协议 id |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: application/x-www-form-urlencoded
POST /api/customer/agreement/delete
// 示例数据,非真实返回
id=1
## 响应 data 结构
null
## 错误码
- 67017 协议不存在
- 67002 客户不存在或不可见
## 响应示例
// 示例数据,字段结构以上文为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": null
}
```
}

78
.scratch/customer-rework/bru-staging/战略协议/战略协议详情.bru

@ -0,0 +1,78 @@
meta {
name: 战略协议详情
type: http
seq: 4
tags: [
generated
]
}
get {
url: {{baseUrl}}/api/customer/agreement/detail
body: none
auth: inherit
}
params:query {
id: 1
}
docs {
# 战略协议详情
`GET /api/customer/agreement/detail`(本行兼作对账钥匙,勿手改)
编辑回显(a4-4-10)。权限继承所属客户(无独立 ACL),无权 67017/67002。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| id | string | 是 | 协议 id |
## 请求示例
请求头:Authorization: Bearer {{token}}
GET /api/customer/agreement/detail?id=1
## 响应 data 结构
| 字段 | 类型 | 示例值 |
| --- | --- | --- |
| id | string | 1 |
| createTime | string | 2024-01-01 12:00:00 |
| updateTime | string | 2024-01-01 12:00:00 |
| customerId | string | 750844477165273088 |
| agreementLevel | number | 2 |
| amount | string | 500万 |
| remark | string | 年度战略合作 |
| fileFileId | string | null |
| customerName | string | 示例客户 |
## 错误码
- 67017 协议不存在
- 67002 客户不存在或不可见
## 响应示例
// 示例数据,字段结构以上表为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": {
"id": "1",
"createTime": "2024-01-01 12:00:00",
"updateTime": "2024-01-01 12:00:00",
"customerId": "750844477165273088",
"agreementLevel": 2,
"amount": "500万",
"remark": "年度战略合作",
"fileFileId": null,
"customerName": "示例客户"
}
}
```
}

80
.scratch/customer-rework/bru-staging/战略协议/战略协议页签列表.bru

@ -0,0 +1,80 @@
meta {
name: 战略协议页签列表
type: http
seq: 5
tags: [
generated
]
}
get {
url: {{baseUrl}}/api/customer/agreement/list
body: none
auth: inherit
}
params:query {
customerId: 750844477165273088
}
docs {
# 战略协议页签列表
`GET /api/customer/agreement/list`(本行兼作对账钥匙,勿手改)
客户详情页签卡片(a4-4-6),时间倒序非分页;带出客户名。权限继承所属客户(无独立 ACL),无权 67002。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| customerId | string | 是 | 所属客户 id |
## 请求示例
请求头:Authorization: Bearer {{token}}
GET /api/customer/agreement/list?customerId=750844477165273088
## 响应 data 结构
| 字段 | 类型 | 示例值 |
| --- | --- | --- |
| [] | array | 协议条目,结构如下 |
| [].id | string | 1 |
| [].createTime | string | 2024-01-01 12:00:00 |
| [].updateTime | string | 2024-01-01 12:00:00 |
| [].customerId | string | 750844477165273088 |
| [].agreementLevel | number | 2 |
| [].amount | string | 500万 |
| [].remark | string | 年度战略合作 |
| [].fileFileId | string | null |
| [].customerName | string | 示例客户 |
## 错误码
- 67002 客户不存在或不可见
## 响应示例
// 示例数据,字段结构以上表为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": [
{
"id": "1",
"createTime": "2024-01-01 12:00:00",
"updateTime": "2024-01-01 12:00:00",
"customerId": "750844477165273088",
"agreementLevel": 2,
"amount": "500万",
"remark": "年度战略合作",
"fileFileId": null,
"customerName": "示例客户"
}
]
}
```
}

69
.scratch/customer-rework/bru-staging/战略协议/新增战略协议.bru

@ -0,0 +1,69 @@
meta {
name: 新增战略协议
type: http
seq: 1
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/agreement/create
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
customerId: 750844477165273088
agreementLevel: 2
amount: 500万
remark:
fileFileId:
}
docs {
# 新增战略协议
`POST /api/customer/agreement/create`(本行兼作对账钥匙,勿手改)
所属客户必填且对当前用户可见;等级 1~3 必填;写后刷新客户战略协议等级快照。权限继承所属客户(无独立 ACL),无权 67017/67002。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| customerId | string | 是 | 所属客户 id |
| agreementLevel | number | 是 | 战略协议等级 1~3:一级/二级/三级 |
| amount | string | 否 | 签订金额(文本:数字/区间/含单位) |
| remark | string | 否 | 备注 |
| fileFileId | string | 否 | 附件 fileId(crm-file 引用,单文件);下载由前端走 crm-file 通用端点 |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: application/x-www-form-urlencoded
POST /api/customer/agreement/create
// 示例数据,非真实返回
customerId=750844477165273088&agreementLevel=2&amount=500万
## 响应 data 结构
新协议 id(string)
## 错误码
- 67017 战略协议入参非法(等级须 1~3)/协议不存在
- 67002 客户不存在或不可见
## 响应示例
// 示例数据,字段结构以上文为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": "1"
}
```
}

71
.scratch/customer-rework/bru-staging/战略协议/编辑战略协议.bru

@ -0,0 +1,71 @@
meta {
name: 编辑战略协议
type: http
seq: 2
tags: [
generated
]
}
post {
url: {{baseUrl}}/api/customer/agreement/edit
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
id: 1
customerId:
agreementLevel: 3
amount: 600万
remark:
fileFileId:
}
docs {
# 编辑战略协议
`POST /api/customer/agreement/edit`(本行兼作对账钥匙,勿手改)
归属客户不可换(customerId 以库内归属为准,传入忽略);等级 1~3;写后刷新客户快照。权限继承所属客户(无独立 ACL),无权 67017/67002。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| id | string | 是 | 协议 id |
| customerId | string | 否 | 归属客户不可换:以库内归属为准,传入忽略 |
| agreementLevel | number | 是 | 战略协议等级 1~3:一级/二级/三级 |
| amount | string | 否 | 签订金额(文本:数字/区间/含单位) |
| remark | string | 否 | 备注 |
| fileFileId | string | 否 | 附件 fileId(crm-file 引用,单文件) |
## 请求示例
请求头:Authorization: Bearer {{token}}
Content-Type: application/x-www-form-urlencoded
POST /api/customer/agreement/edit
// 示例数据,非真实返回
id=1&agreementLevel=3&amount=600万
## 响应 data 结构
null
## 错误码
- 67017 战略协议入参非法(等级须 1~3)/协议不存在
- 67002 客户不存在或不可见
## 响应示例
// 示例数据,字段结构以上文为准,非真实返回
```json
{
"code": 0,
"success": true,
"message": "success",
"data": null
}
```
}

4326
.scratch/customer-rework/bruno-recon-d.json

File diff suppressed because it is too large

270
.scratch/customer-rework/bruno_scan_d.py

@ -0,0 +1,270 @@
"""bruno-sync Step 2/3 (本机 D:\ 路径版, 由 bruno_scan.py 改路径而来): scan Spring controllers -> reconcile with docs repo.
Outputs JSON: {apis: [...], report: {...}}
"""
import json
import os
import re
import sys
if sys.stdout.encoding.lower() != "utf-8":
sys.stdout.reconfigure(encoding="utf-8")
ROOTS = [
"crm-auth/src/main/java", "crm-file/src/main/java", "crm-lead/src/main/java",
"crm-opportunity/src/main/java", "crm-rule/src/main/java",
"crm-preference/src/main/java", "crm-dict/src/main/java",
"crm-customer/src/main/java",
]
PROJECT = r"d:\code\crm-backend-matt"
DOCS = r"D:\code\crm-api-docs"
def strip_comments(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
src = re.sub(r"//[^\n]*", "", src)
return src
def extract_balanced(text, open_idx):
"""text[open_idx] == '('. return inner content, end idx after ')'."""
depth = 0
i = open_idx
in_str = None
while i < len(text):
c = text[i]
if in_str:
if c == "\\":
i += 2
continue
if c == in_str:
in_str = None
else:
if c in "\"'":
in_str = c
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return text[open_idx + 1:i], i + 1
i += 1
return text[open_idx + 1:], len(text)
def parse_str_array(body):
"""parse {'a','b'} or "a" -> list of strings; also allow bare string."""
body = body.strip()
if body.startswith("{"):
return re.findall(r'"([^"]*)"', body)
m = re.match(r'^"([^"]*)"$', body)
return [m.group(1)] if m else []
def find_annotations(src, name):
"""find all @Name(...) blocks, return list of inner contents."""
out = []
for m in re.finditer(r"@" + name + r"\s*\(", src):
inner, _ = extract_balanced(src, m.end() - 1)
out.append(inner)
return out
def scan_source():
apis = []
conflicts = []
for root in ROOTS:
base = os.path.join(PROJECT, root)
for dirpath, dirnames, filenames in os.walk(base):
for fn in filenames:
if not fn.endswith("Controller.java"):
continue
path = os.path.join(dirpath, fn)
src = strip_comments(open(path, encoding="utf-8").read())
if "@RestController" not in src:
continue
cls_m = re.search(r"class\s+(\w+)", src)
cls = cls_m.group(1) if cls_m else fn[:-5]
prefixes = []
for rm in re.finditer(r"@RequestMapping\s*\(", src):
inner, end = extract_balanced(src, rm.end() - 1)
if end <= len(src) and "class" in src[end:end + 200]:
vals = parse_str_array(inner) or re.findall(r'"([^"]*)"', inner)
pv = re.search(r'value\s*=\s*', inner)
prefixes = parse_str_array(inner[pv.end():]) if pv else vals
break
prefix = prefixes[0] if prefixes else ""
# class-level tags from @Tag(name=...)
cls_tags = []
head = src[:cls_m.start()]
for tag_inner in find_annotations(head, "Tag"):
nm = re.search(r'name\s*=\s*"([^"]*)"', tag_inner)
if nm:
cls_tags.append(nm.group(1))
# collect all @Operation blocks first (project style: @Operation BEFORE @Mapping)
ops = []
for om in re.finditer(r"@Operation\s*\(", src):
op_inner, op_end = extract_balanced(src, om.end() - 1)
ops.append({"start": om.start(), "end": op_end, "inner": op_inner})
# methods: find mapping annotations; assign the NEAREST @Operation BEFORE them
for mm in re.finditer(r"@(Get|Post|Put|Delete|Request)Mapping\s*(\(|$)", src, re.M):
anno_start = mm.start()
if mm.group(1) == "Request":
inner, end = extract_balanced(src, mm.end() - 1) if mm.group(2) == "(" else ("", mm.end())
meth_m = re.search(r"method\s*=\s*RequestMethod\.(\w+)", inner)
if not meth_m:
continue # class-level @RequestMapping (already consumed) or no method
http = meth_m.group(1)
pv = re.search(r'(?:value\s*=\s*)?"([^"]*)"', inner)
sub = pv.group(1) if pv else ""
else:
http = mm.group(1).upper()
if mm.group(2) == "(":
inner, end = extract_balanced(src, mm.end() - 1)
pv = re.search(r'(?:value\s*=\s*)?"([^"]*)"', inner)
sub = pv.group(1) if pv else ""
else:
end = mm.end()
sub = ""
url = (prefix + sub) if sub.startswith("/") else (prefix + ("/" + sub if sub else ""))
# find owning @Operation: project has BOTH styles.
# forward style: @Operation ... @PreAuthorize? ... @Mapping (customer/lead/opp)
# trailing style: @Mapping ... @PreAuthorize ... @Operation (crm-auth role/resource)
op = None
for cand in reversed(ops):
if cand["end"] <= anno_start:
between = src[cand["end"]:anno_start]
if "{" in between:
continue # a method body separates them -> belongs to an earlier method
op = cand
break
if op is None:
body_brace = src.find("{", end)
if body_brace == -1:
body_brace = len(src)
for cand in ops:
if cand["start"] >= end and cand["start"] < body_brace:
op = cand
break
op_tags = None
summary = ""
if op is not None:
sm = re.search(r'summary\s*=\s*"([^"]*)"', op["inner"])
if sm:
summary = sm.group(1)
tm = re.search(r"tags\s*=\s*(\{[^}]*\}|\"[^\"]*\")", op["inner"], re.S)
if tm:
op_tags = parse_str_array(tm.group(1))
method_tags = []
if op_tags is None:
# method-level @Tag between op end and mapping
back = src[anno_start - 800:anno_start]
t_m = None
for ti in find_annotations(back, "Tag"):
t_m = ti
if t_m:
nm = re.search(r'name\s*=\s*"([^"]*)"', t_m)
if nm:
method_tags = [nm.group(1)]
tags = op_tags if op_tags is not None else (method_tags if method_tags else cls_tags)
if not tags:
tags = []
apis.append({
"file": os.path.relpath(path, PROJECT).replace("\\", "/"),
"className": cls, "http": http, "url": url,
"tags": tags, "summary": summary,
})
# key conflicts (method+url, before tag expansion)
seen = {}
for a in apis:
k = a["http"] + " " + a["url"]
seen.setdefault(k, []).append(a)
for k, v in seen.items():
if len(v) > 1:
conflicts.append({"key": k, "files": [x["file"] for x in v]})
return apis, conflicts
def scan_docs():
"""return [{path, key, generated, folder}] for all .bru files."""
out = []
skip = {"node_modules", ".git", "environments"}
for dirpath, dirnames, filenames in os.walk(DOCS):
dirnames[:] = [d for d in dirnames if d not in skip]
for fn in filenames:
if not fn.endswith(".bru") or fn == "collection.bru" or fn == "folder.bru":
continue
path = os.path.join(dirpath, fn)
content = open(path, encoding="utf-8").read()
gen = bool(re.search(r"tags\s*:\s*\[[^\]]*generated", content, re.S))
key = None
km = re.search(r"docs\s*\{.*?`([A-Z]+)\s+(\S+)`", content, re.S)
if km:
key = km.group(1) + " " + km.group(2)
folder = os.path.basename(dirpath)
out.append({"path": os.path.relpath(path, DOCS).replace("\\", "/"),
"key": key, "generated": gen, "folder": folder})
return out
def main():
apis, conflicts = scan_source()
docs = scan_docs()
# expand source keys: one per (method+url, tag)
src_keys = {}
for a in apis:
tags = a["tags"]
if tags:
tl = list(tags)
else:
# no tag -> urlPattern fallback dir: module[/resource]
seg = [s for s in a["url"].split("/") if s]
if seg and seg[0] == "api":
seg = seg[1:]
tl = ["/".join(seg[:2])]
for t in tl:
k = (a["http"] + " " + a["url"], t)
if k in src_keys:
conflicts.append({"key": k[0] + " @" + str(t), "files": [src_keys[k]["file"], a["file"]]})
else:
src_keys[k] = a
# file keys
file_keys = {}
for d in docs:
if not d["generated"]:
continue
if d["key"] is None:
d["unrecognized"] = True
continue
rel = d["path"]
parts = rel.split("/")
# tag = full dir path between repo root and file
tag = "/".join(parts[:-1])
k = (d["key"], tag)
file_keys[k] = d
new_items = []
del_items = []
keep = 0
for k in src_keys:
if k not in file_keys:
a = src_keys[k]
new_items.append({"key": k[0], "tag": k[1], "summary": a["summary"],
"file": a["file"], "className": a["className"]})
for k, d in file_keys.items():
if k not in src_keys:
del_items.append({"key": k[0], "tag": k[1], "path": d["path"]})
for k in src_keys:
if k in file_keys:
keep += 1
report = {
"source_api_count": len(apis),
"source_key_count": len(src_keys),
"generated_file_count": len([d for d in docs if d["generated"]]),
"private_file_count": len([d for d in docs if not d["generated"]]),
"unrecognized_count": len([d for d in docs if d.get("unrecognized")]),
"key_conflicts": conflicts,
"keep_count": keep,
"new_count": len(new_items),
"delete_file_count": len(set(x["path"] for x in del_items)),
"delete_distinct_url_count": len(set(x["key"] for x in del_items)),
}
out = {"report": report, "new": new_items, "delete": del_items, "docs": docs, "apis": apis}
with open(os.path.join(PROJECT, r".scratch\customer-rework\bruno-recon-d.json"), "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=1)
print(json.dumps(report, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()

37
.scratch/customer-rework/deploy_bru.py

@ -0,0 +1,37 @@
"""copy staged .bru files into the docs repo (workspace-external -> python only)."""
import io
import os
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
STAGE = r"d:\code\crm-backend-matt\.scratch\customer-rework\bru-staging"
DOCS = r"D:\code\crm-api-docs"
MAPPING = [
("战略协议", "A4 客户管理/战略协议"), # whole dir, 5 files
("我的客户/批量更新工商信息.bru", "A4 客户管理/我的客户/批量更新工商信息.bru"),
("客户公海/上传导入文件并预校验.bru", "A4 客户管理/客户公海/上传导入文件并预校验.bru"),
("客户导入/上传导入文件并预校验.bru", "A4 客户管理/我的客户/客户导入/上传导入文件并预校验.bru"),
]
def copy_clean(src, dst):
data = open(src, "rb").read()
if data[:3] == b"\xef\xbb\xbf":
data = data[3:]
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, "wb").write(data)
print("OK", dst.replace(DOCS, ""), len(data), "bytes")
for src_rel, dst_rel in MAPPING:
s = os.path.join(STAGE, *src_rel.split("/"))
d = os.path.join(DOCS, *dst_rel.split("/"))
if os.path.isdir(s):
for root, _, files in os.walk(s):
for fn in files:
rel = os.path.relpath(os.path.join(root, fn), s)
copy_clean(os.path.join(root, fn), os.path.join(d, *rel.split(os.sep)))
else:
copy_clean(s, d)

20
.scratch/customer-rework/issues/09-audit-fixes.md

@ -1,6 +1,6 @@
# 09 · 复核差异清单修复 # 09 · 复核差异清单修复
- **Status:** claimed - **Status:** resolved
- **Type:** task - **Type:** task
- **Blocked by:** 08-audit-prototype - **Blocked by:** 08-audit-prototype
@ -29,3 +29,21 @@
## 验收 ## 验收
- 差异清单全部条目状态为"已修 / 已单开票 / 明确不修(记理由)",无悬空项。 - 差异清单全部条目状态为"已修 / 已单开票 / 明确不修(记理由)",无悬空项。
## Answer(2026-09-05 收尾)
**处置总览**(明细见 `audit-findings.md` §四处置状态表):
- **已修六项**
- S1 错误码撞号——实改与票面建议相反:64022 已被 `CODE_SCHEME_TPL_FIELD_INVALID` 占用,故 `CODE_SCHEME_TPL_INVALID` 保 64018(64018-64022 归方案卡模板段),`CODE_STAGE_TPL_SEED_PROTECTED` 64018→**64024** 顺延补位;
- F3 import 六端点双 tag 增挂「客户公海」;
- F6 批量工商更新 `POST /api/customer/company-lookup/batch-update`(ids 表单绑定、§C2 只补空缺、五种状态软处置、上限 100;port stub 恒 NO_HIT,真实企查查接入自动激活);
- F7 审计口径偏差澄清——viewType 单参数已承载五内置视图,真缺口为 customer_view_log 生产侧零写入(票 04 建壳挂账),按实体 javadoc 设计意图在 detailHead 聚合时 upsert touchViewLog,零新端点;
- F8 导入重复策略 `duplicateStrategy`(SKIP 缺省 / OVERWRITE 67013 拦非法;D24 冻结语义:作用面仅文件内重复组,overwrite=组内最后一条通过校验的行 UPDATE 生效;不触名称相似 SUSPECT);
- F10 联系人日志轻方案——客户 oplog 扩 CONTACT_ADD/EDIT/DELETE,联系人增删改三处写日志。
- **已单开票**:F5 战略协议 → 票 12(原型三页完整,全栈实现+测试+bruno 移交)。
- **明确不修(记理由)**:F4 关联项目随 A5 项目域划出;F9 导出日志随 D2 导出家族划出(均经用户 2026-09-05 逐项拍板)。
**验证**:`mvn clean test -pl crm-customer -am` BUILD SUCCESS——crm-customer 152 用例全绿(含 F6 +4、F7 +1、F8 +3、F10 +1 新用例),依赖六模块(base 38 / file 84 / auth 210 / dict 51 / rule 152 / preference 23)全绿;全模块 .java BOM 扫描 NO BOM。
**随票移交**:bruno 侧 F6/F8 新参数与 F7 行为示例未在本票内同步,随票 10 bruno 对账步统一核补(文档仓本机 `D:\code\crm-api-docs`)。

24
.scratch/customer-rework/issues/10-regression-retest.md

@ -1,16 +1,28 @@
# 10 · 回归重测(验收线) # 10 · 回归重测(验收线)
- **Status:** open - **Status:** resolved
- **Type:** task - **Type:** task
- **Blocked by:** 09-audit-fixes - **Blocked by:** 09-audit-fixes
## Answer(2026-09-05)
五项验收全过(详见 [RETEST-REPORT.md](../RETEST-REPORT.md)):
1. **mvn test 三模块全绿**:rule 152 / customer 158 / opp 242(基线 521 → 552,绿为准)。
2. **冒烟全链 code=0**:`tmp/smoke-t10.py` 新写(URL 全现行端点),**43 步 0 fail**——三 workspace 列表+看板(pool 看板断言 67001 拒绝)、建改查(CAS version)、联系人 CRUD、抛公海/领取/分配、交割只读、导入全链(**duplicateStrategy=SKIP 回显断言**,F8)、列+形态偏好、商机偏好、**战略协议五端点 + DB 快照 2→3→NULL 断言**(票 12)、批量工商 NO_HIT(F6)。重打包 fat jar(新代码入包验证:启动日志 customerAgreementMapper 注册)。
3. **bruno 对账终态**:321 源码钥匙 = 321 generated 文件,new/delete/冲突/无法识别全 0;本次落 8 文件(战略协议 5 + 批量工商 1 + upload patch ×2);A4 树对齐 v29(战略协议按票 12 拍板挂 A4 根)。
4. **BOM 全仓零命中**
5. **风格 grep 零命中**
已知缺陷引用(非回归,维持原状):D-07(P1) assignable 超时(票 05 登记,冒烟缺陷验证模式)、导入 D-04/D-05(票 09 audit 记档)。
## 验收线(R10,逐项过) ## 验收线(R10,逐项过)
- [ ] `mvn test`:crm-rule + crm-customer + crm-opportunity 三模块全绿(基线 521:146/122/242,风格改造后数量随用例变化,绿为准); - [x] `mvn test`:crm-rule + crm-customer + crm-opportunity 三模块全绿(基线 521:146/122/242,风格改造后数量随用例变化,绿为准);
- [ ] 冒烟脚本重跑全链 code=0:复用 `tools/smoke-t12m.py` 范式,URL 换 02 号票改造后的新端点,覆盖:登录 → 客户三 workspace 列表/看板(公海看板断言拒绝——用户复核原型确认公海无卡片视图)→ 建改查客户 → 联系人 CRUD → 认领/分配/抛公海 → 交割 → 导入全链 → 偏好存取(列 + 形态)→ 商机偏好存取; - [x] 冒烟脚本重跑全链 code=0:复用 `tools/smoke-t12m.py` 范式,URL 换 02 号票改造后的新端点,覆盖:登录 → 客户三 workspace 列表/看板(公海看板断言拒绝——用户复核原型确认公海无卡片视图)→ 建改查客户 → 联系人 CRUD → 认领/分配/抛公海 → 交割 → 导入全链 → 偏好存取(列 + 形态)→ 商机偏好存取;
- [ ] bruno 产出验收:`E:\code\crm-api-docs` A4 目录树对齐 v29 树,knife4j 分组同构(07 号票已建,此处终验); - [x] bruno 产出验收:`E:\code\crm-api-docs` A4 目录树对齐 v29 树,knife4j 分组同构(07 号票已建,此处终验)——本机实际路径 `D:\code\crm-api-docs`(换机),对账 321=321
- [ ] BOM 扫描:全仓 `*.java``EF BB BF` 前缀(AGENTS.md 编码纪律); - [x] BOM 扫描:全仓 `*.java``EF BB BF` 前缀(AGENTS.md 编码纪律);
- [ ] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/` 零命中终验。 - [x] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/` 零命中终验。
## 产出 ## 产出

40
.scratch/customer-rework/issues/12-agreement.md

@ -0,0 +1,40 @@
# 12 · 战略协议单表 CRUD(F5)
- **Status:** resolved
- **Type:** task
- **Blocked by:** 09-audit-fixes(F5 拍板产出)
## Question
原型 a4-4-6(页签)/ a4-4-9(新增)/ a4-4-10(编辑)三页有完整形态但零端点(audit-findings F5,票 09 拍板本期补、单开本票)。战略协议作为客户详情页签的单表 CRUD 全栈如何落?
## 设计定稿(2026-09-05)
- **表** `customer_agreement`(BaseEntity 范式,对齐 CustomerContact):
- `customer_id` bigint not null(所属客户,无独立 ACL 权限继承客户)
- `agreement_level` tinyint not null(**1~3** 一级/二级/三级——值域钉在 Customer.strategicAgreementLevel 注释「快照 1~3」)
- `amount` varchar(64)(签订金额,对齐 registeredCapital varchar 先例——允许区间/文字形态)
- `remark` varchar(500)、`file_file_id` varchar(64)(附件单文件,crm-file fileId 引用,对齐 CustomerImportTask.fileFileId 先例;下载走 crm-file 通用端点零新下载口)
- 命名策略:无孤立单大写字母,无需 @Column(name=...) 钉名
- **URL**(ADR-0017 扁平动作动词,对齐 contact 家族):`POST /api/customer/agreement/create`、`POST /edit?id=`、`POST /delete?id=`、`GET /detail?id=`、`GET /list?customerId=`(页签卡片列表,时间倒序非分页)
- **错误码** `CODE_AGREEMENT_INVALID = 67017`(入参非法/等级越界/协议不存在或无权——防探测同码)
- **oplog**(对齐 F10 CONTACT_* 先例):ACTION_AGREEMENT_ADD/EDIT/DELETE,detail「新增/修改/删除战略协议【三级】」
- **客户快照刷新**(兑现 Customer.strategicAgreementLevel javadoc「战略协议子域写入,本图只读」承诺):create/edit/delete 后重算该客户最新一条协议(create_time,id 倒序 limit 1)的等级回写 `customer.strategic_agreement_level`(无协议 → NULL=未签);LambdaUpdateWrapper 只更新该列不 bump version(对齐跟进锚点刷新先例)
- **tag** `A4 客户管理/战略协议`(三页均在 客户>我的客户>客户详情 下,单菜单归属)
- **编辑语义**:customerId 不可换(dto 值忽略,对齐 contact);等级必填且 ∈ 1~3
## 验收
- mvn test crm-customer 全绿(新增 H2 集成测试:CRUD 正常路径 + 校验路径 + 快照刷新 + 权限继承客户 + oplog 三动作)
- 端点 grep 无 @PathVariable/@PutMapping/@DeleteMapping/@RequestBody
- bruno 侧新端点示例随票 10 bruno 对账步统一核补(同 F6/F8 处置)
## Answer(2026-09-05)
**按设计定稿全栈落地**(全部与票面定稿一致,两处实现期修正见下):
- **后端栈**:`CustomerAgreement` 实体(customer_agreement 表:customerId / agreementLevel 1~3 / amount varchar(64) / remark / fileFileId)+ `CustomerAgreementMapper`(纯 CrmBaseMapper,无自定义 SQL)+ `AgreementDTO` 双向 DTO + `ICustomerAgreementService` / `CustomerAgreementServiceImpl` + `CustomerAgreementController` 五端点(`POST /api/customer/agreement/create|edit?id=|delete?id=`、`GET /detail?id=|list?customerId=`);错误码 67017;oplog 扩 AGREEMENT_ADD/EDIT/DELETE。
- **快照刷新兑现**:create/edit/delete 后重算最新协议等级回写 `customer.strategic_agreement_level`(删空回 NULL=未签),LambdaUpdateWrapper 不 bump version——`Customer` 实体 javadoc「战略协议子域写入,本图只读」的设计承诺自此闭环。
- **实现期修正两处**:(1) `listByCustomer` 首版漏填出参 customerName(契约承诺带出),对齐 contact 的 listByCustomer 补上;(2) 测试对 @TableLogic 语义的断言修正——已删行 `selectById` 返回 null(非 deleted=true 行),软删位验证改原生 SQL 直读 deleted 列。
- **验证**:`mvn test -pl crm-customer -am` BUILD SUCCESS——crm-customer **158** 用例全绿(+6:create 快照 / edit 不可换客 / delete 快照回退与回 NULL / list+detail / 校验三路 / 防探测与权限继承);规范 grep(@PathVariable|@PutMapping|@DeleteMapping|@RequestBody)零命中;BOM 扫描 NO BOM。
- **bruno**:menuBindings 无需新增(协议端点无 viewType/scopeKey 参数);br 文件生成随票 10 对账步(文档仓 `D:\code\crm-api-docs\A4 客户管理\`)。

14
.scratch/customer-rework/map.md

@ -40,6 +40,12 @@
- [08 · 双镜头全量复核(规范 + 原型)](issues/08-audit-prototype.md) — audit-findings.md:镜头一五项全绿 + S1 错误码 64018 双绑;镜头二四种子收口 + 7 差异项(机械修 S1/F3,需决策 F4-F10);09 票范围已回填。 - [08 · 双镜头全量复核(规范 + 原型)](issues/08-audit-prototype.md) — audit-findings.md:镜头一五项全绿 + S1 错误码 64018 双绑;镜头二四种子收口 + 7 差异项(机械修 S1/F3,需决策 F4-F10);09 票范围已回填。
- [09 · 复核差异清单修复](issues/09-audit-fixes.md) — 六修一开两划:S1(实改反向:SEED_PROTECTED 顺延 64024)、F3 双 tag、F6 批量工商更新端点、F7(口径收窄:viewType 五内置视图已在,补 viewTouch upsert)、F8 duplicateStrategy(D24 语义)、F10 联系人 oplog 轻方案;F5 单开票 12、F4/F9 划出(用户逐项拍板);crm-customer 152 全绿 + BOM 无。
- [12 · 战略协议单表 CRUD(F5)](issues/12-agreement.md) — customer_agreement 表 + 五端点(create/edit/delete/detail/list)+ 错误码 67017 + oplog AGREEMENT_*;客户快照 strategic_agreement_level 随写刷新(javadoc「战略协议子域写入」承诺闭环);crm-customer 158 全绿。
- [10 · 回归重测](issues/10-regression-retest.md) — 五项验收全过:三模块 152/158/242 绿、冒烟 43 步 0 fail(新端点全覆盖 + DB 快照断言 + D-07 缺陷验证模式)、bruno 对账 321=321 终态(落 8 文件)、BOM 零、风格 grep 零;产出 [RETEST-REPORT.md](RETEST-REPORT.md)。
## 范围外(Out of scope) ## 范围外(Out of scope)
- 客户导出(原型有规则,维持 D2 划出,涉及脱敏口径,量级独立) - 客户导出(原型有规则,维持 D2 划出,涉及脱敏口径,量级独立)
@ -54,10 +60,10 @@
``` ```
01 ag-checklist ──┬──> 02 style-unify ──┬──> 03 board-pool-close ──┐ 01 ag-checklist ──┬──> 02 style-unify ──┬──> 03 board-pool-close ──┐
│ └──> 04 gap-fixes ─────────┤ │ └──> 04 gap-fixes ─────────┤
│ ├──> 08 audit ──> 09 audit-fixes ──┬──> 10 regression │ ├──> 08 audit ──> 09 audit-fixes ──┬──> 12 agreement ──┬──> 10 regression
│ 05 view-form-platform ──┬──> 07 tags-bruno ──┤ │ │ 05 view-form-platform ──┬──> 07 tags-bruno ──┤ │
│ 06 column-pref-scopes ──┘ │ │ │ 06 column-pref-scopes ──┘ │ │
└───────────────────────────────────────────────┘ 11 docs-sync <────┘ └───────────────────────────────────────────────┘ 11 docs-sync <────┴───────────────────
``` ```
- 01 是全部票的前置(checklist 是复核依据) - 01 是全部票的前置(checklist 是复核依据)

11
crm-customer/src/main/java/com/crm/customer/constant/CustomerConstants.java

@ -26,6 +26,7 @@ public interface CustomerConstants {
int CODE_IMPORT_STATUS_NOT_ALLOWED = 67014; // 导入任务状态不允许此操作(重复确认/下载未生成明细) int CODE_IMPORT_STATUS_NOT_ALLOWED = 67014; // 导入任务状态不允许此操作(重复确认/下载未生成明细)
int CODE_DEDUP_RULE_MISSING = 67015; // 查重设置单例缺失(crm-rule 未初始化) int CODE_DEDUP_RULE_MISSING = 67015; // 查重设置单例缺失(crm-rule 未初始化)
int CODE_MEMBER_INVALID = 67016; // 成员管理非法:名单空/含负责人/重复加入/用户不存在或停用/成员不存在(原型 11.1) int CODE_MEMBER_INVALID = 67016; // 成员管理非法:名单空/含负责人/重复加入/用户不存在或停用/成员不存在(原型 11.1)
int CODE_AGREEMENT_INVALID = 67017; // 战略协议非法:等级缺失或越界/协议不存在或无权访问(返工票 12-F5)
/*-------- 编号规则--------*/ /*-------- 编号规则--------*/
@ -92,6 +93,12 @@ public interface CustomerConstants {
String ACTION_STAGE_CHANGE = "STAGE_CHANGE"; // 阶段推进(operator=系统或人工) String ACTION_STAGE_CHANGE = "STAGE_CHANGE"; // 阶段推进(operator=系统或人工)
String ACTION_MEMBER_ADD = "MEMBER_ADD"; // 添加协同人(原型 11.1 搜索多选) String ACTION_MEMBER_ADD = "MEMBER_ADD"; // 添加协同人(原型 11.1 搜索多选)
String ACTION_MEMBER_REMOVE = "MEMBER_REMOVE"; // 移除协同人(原型 11.1 二次确认) String ACTION_MEMBER_REMOVE = "MEMBER_REMOVE"; // 移除协同人(原型 11.1 二次确认)
String ACTION_CONTACT_ADD = "CONTACT_ADD"; // 新增联系人(返工票 09-F10,a4-5-6)
String ACTION_CONTACT_EDIT = "CONTACT_EDIT"; // 编辑联系人(返工票 09-F10,a4-5-6)
String ACTION_CONTACT_DELETE = "CONTACT_DELETE"; // 删除联系人(返工票 09-F10,a4-5-6)
String ACTION_AGREEMENT_ADD = "AGREEMENT_ADD"; // 新增战略协议(返工票 12-F5,a4-4-6)
String ACTION_AGREEMENT_EDIT = "AGREEMENT_EDIT"; // 编辑战略协议(返工票 12-F5,a4-4-6)
String ACTION_AGREEMENT_DELETE = "AGREEMENT_DELETE"; // 删除战略协议(返工票 12-F5,a4-4-6)
/*-------- 待发通知(票 09 / issues-10 §D,对称商机 notice 语义)--------*/ /*-------- 待发通知(票 09 / issues-10 §D,对称商机 notice 语义)--------*/
@ -114,6 +121,10 @@ public interface CustomerConstants {
String IMPORT_MODE_APPEND_ONLY = "APPEND_ONLY"; // 仅新增 String IMPORT_MODE_APPEND_ONLY = "APPEND_ONLY"; // 仅新增
String IMPORT_MODE_UPDATE_ONLY = "UPDATE_ONLY"; // 仅更新 String IMPORT_MODE_UPDATE_ONLY = "UPDATE_ONLY"; // 仅更新
String IMPORT_MODE_UPSERT = "UPSERT"; // 新增或更新 String IMPORT_MODE_UPSERT = "UPSERT"; // 新增或更新
/** 重复策略(a4-3-2 复选框 / 旧图 D24:互斥,默认不导入;覆盖=文件内自上而下最后一条通过校验的为准) */
String IMPORT_DUP_SKIP = "SKIP"; // 数据重复时不导入(默认,重复组全失败)
String IMPORT_DUP_OVERWRITE = "OVERWRITE"; // 数据重复时覆盖导入(重复组仅最后一条生效)
int IMPORT_STATUS_DRAFT = 0; // 待确认(预校验完成,等用户 confirm) int IMPORT_STATUS_DRAFT = 0; // 待确认(预校验完成,等用户 confirm)
int IMPORT_STATUS_RUNNING = 1; // 处理中 int IMPORT_STATUS_RUNNING = 1; // 处理中

63
crm-customer/src/main/java/com/crm/customer/controller/CustomerAgreementController.java

@ -0,0 +1,63 @@
package com.crm.customer.controller;
import com.crm.base.domain.result.Result;
import com.crm.customer.domain.dto.AgreementDTO;
import com.crm.customer.service.ICustomerAgreementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 战略协议接口返工票 12-F5 / 原型 a4-4-6 页签 + a4-4-9 新增 + a4-4-10 编辑
*
* <p>写入参 {@link AgreementDTO} 隐式表单绑定ADR-0017权限继承所属客户
* 无独立 ACL服务端按客户可见性校验无权 67017/67002附件存 crm-file
* fileId 引用下载由前端走 crm-file 通用端点</p>
*/
@Tag(name = "A4 客户管理/战略协议", description = "战略协议页签 CRUD(客户详情)")
@RestController
@RequestMapping("/api/customer/agreement")
@RequiredArgsConstructor
public class CustomerAgreementController {
private final ICustomerAgreementService agreementService;
@Operation(summary = "新增战略协议", description = "所属客户必填且对当前用户可见;等级 1~3 必填;写后刷新客户战略协议等级快照")
@PostMapping("/create")
public Result<Long> create(AgreementDTO dto) {
return Result.success(agreementService.create(dto));
}
@Operation(summary = "编辑战略协议", description = "归属客户不可换;等级 1~3;写后刷新客户快照")
@PostMapping("/edit")
public Result<Void> update(@RequestParam("id") Long id, AgreementDTO dto) {
agreementService.update(id, dto);
return Result.success();
}
@Operation(summary = "删除战略协议", description = "软删;删空后客户快照回 NULL=未签")
@PostMapping("/delete")
public Result<Void> delete(@RequestParam("id") Long id) {
agreementService.delete(id);
return Result.success();
}
@Operation(summary = "战略协议详情", description = "编辑回显(a4-4-10)")
@GetMapping("/detail")
public Result<AgreementDTO> detail(@RequestParam("id") Long id) {
return Result.success(agreementService.detail(id));
}
@Operation(summary = "战略协议页签列表", description = "客户详情页签卡片(a4-4-6),时间倒序非分页;带出客户名")
@GetMapping("/list")
public Result<List<AgreementDTO>> list(@RequestParam("customerId") Long customerId) {
return Result.success(agreementService.listByCustomer(customerId));
}
}

9
crm-customer/src/main/java/com/crm/customer/controller/CustomerController.java

@ -11,6 +11,7 @@ import com.crm.customer.domain.dto.CustomerSaveResultDTO;
import com.crm.customer.domain.dto.CustomerSearchItemDTO; import com.crm.customer.domain.dto.CustomerSearchItemDTO;
import com.crm.customer.domain.dto.CustomerSimilarHitDTO; import com.crm.customer.domain.dto.CustomerSimilarHitDTO;
import com.crm.customer.domain.dto.CustomerUpdateDTO; import com.crm.customer.domain.dto.CustomerUpdateDTO;
import com.crm.customer.domain.dto.CompanyBatchUpdateDTO;
import com.crm.customer.domain.param.CustomerPageParam; import com.crm.customer.domain.param.CustomerPageParam;
import com.crm.customer.domain.param.CustomerSearchParam; import com.crm.customer.domain.param.CustomerSearchParam;
import com.crm.customer.port.CompanyLookupPort; import com.crm.customer.port.CompanyLookupPort;
@ -122,4 +123,12 @@ public class CustomerController {
@RequestParam("companyName") String companyName) { @RequestParam("companyName") String companyName) {
return Result.success(companyLookupPort.lookup(companyName)); return Result.success(companyLookupPort.lookup(companyName));
} }
@Operation(summary = "批量更新工商信息", tags = {"A4 客户管理/我的客户"},
description = "a4-3-1 多选确认弹窗(你选择了N个客户,确认要批量查询并更新工商信息吗):逐客户按名称查工商源并回填空缺字段,"
+ "行级部分成功返回每客户结果(UPDATED/SKIPPED/NO_HIT/CONFLICT/NOT_FOUND);单次上限 100;一期 stub 恒 NO_HIT")
@PostMapping("/company-lookup/batch-update")
public Result<List<CompanyBatchUpdateDTO>> batchCompanyUpdate(@RequestParam("ids") List<Long> ids) {
return Result.success(customerService.batchCompanyUpdate(ids));
}
} }

7
crm-customer/src/main/java/com/crm/customer/controller/CustomerImportController.java

@ -44,12 +44,13 @@ public class CustomerImportController {
private final ICustomerImportService importService; private final ICustomerImportService importService;
@Operation(summary = "上传导入文件并预校验", @Operation(summary = "上传导入文件并预校验",
description = "文件守门(.xls/.xlsx、≤20MB、模板版本、单 sheet ≤5 万行)→ 行级判定(与执行同口径)→ 任务落待确认态,返回预计数 + 明细(上限 100 条)", description = "文件守门(.xls/.xlsx、≤20MB、模板版本、单 sheet ≤5 万行)→ 行级判定(与执行同口径)→ 任务落待确认态,返回预计数 + 明细(上限 100 条);duplicateStrategy=数据重复时覆盖导入(OVERWRITE)/不导入(SKIP,默认)",
tags = {"A4 客户管理/我的客户/客户导入", "A4 客户管理/客户公海"}) tags = {"A4 客户管理/我的客户/客户导入", "A4 客户管理/客户公海"})
@PostMapping("/upload") @PostMapping("/upload")
public Result<ImportPreviewDTO> upload(@RequestParam("file") MultipartFile file, public Result<ImportPreviewDTO> upload(@RequestParam("file") MultipartFile file,
@RequestParam("importMode") String importMode) { @RequestParam("importMode") String importMode,
return Result.success(importService.upload(file, importMode)); @RequestParam(value = "duplicateStrategy", required = false) String duplicateStrategy) {
return Result.success(importService.upload(file, importMode, duplicateStrategy));
} }
@Operation(summary = "确认执行导入", description = "待确认 → 处理中 + 触发异步执行;重复确认 67014 阻断", @Operation(summary = "确认执行导入", description = "待确认 → 处理中 + 触发异步执行;重复确认 67014 阻断",

52
crm-customer/src/main/java/com/crm/customer/domain/dto/AgreementDTO.java

@ -0,0 +1,52 @@
package com.crm.customer.domain.dto;
import com.crm.base.domain.dto.BaseDTO;
import com.crm.customer.domain.entity.CustomerAgreement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.beans.BeanUtils;
/**
* 战略协议双向 DTOADR-0017 Route A写入参 + 出参同一类对齐 {@link ContactDTO}
*
* <p>写入customerId/agreementLevel 必填出参带 customerName页签卡片带出客户名
* 与列表场景的面包屑上下文一致附件 fileFileId 双向透传前端凭此走 crm-file 下载</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "战略协议(写入参/出参双向)")
public class AgreementDTO extends BaseDTO {
@Schema(description = "所属客户 id(新增必填;编辑时以库内归属为准不可换客户)")
private Long customerId;
@Schema(description = "战略协议等级 1~3(一级/二级/三级)", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer agreementLevel;
@Schema(description = "签订金额(文本:数字/区间/含单位,可空)")
private String amount;
@Schema(description = "备注(可空)")
private String remark;
@Schema(description = "附件 fileId(crm-file 引用,单文件,可空)")
private String fileFileId;
// -------- 出参(带出客户名) --------
@Schema(description = "所属客户名称(仅出参)")
private String customerName;
public static CustomerAgreement toEntity(AgreementDTO dto) {
CustomerAgreement entity = new CustomerAgreement();
BeanUtils.copyProperties(dto, entity, "id", "createTime", "updateTime", "customerName");
return entity;
}
public static AgreementDTO fromEntity(CustomerAgreement entity) {
AgreementDTO dto = new AgreementDTO();
BeanUtils.copyProperties(entity, dto);
return dto;
}
}

54
crm-customer/src/main/java/com/crm/customer/domain/dto/CompanyBatchUpdateDTO.java

@ -0,0 +1,54 @@
package com.crm.customer.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 批量更新工商信息单客户结果返工票 09-F6 / a4-3-1 多选确认弹窗
*
* <p>行级部分成功单客户失败未命中/冲突/无空缺不影响其余客户
* status 语义见常量一期企查查 stub 恒空 全部 NO_HIT契约先行
* 真实 port Bean 接入后行为自动激活</p>
*/
@Data
@Schema(description = "批量更新工商信息单客户结果")
public class CompanyBatchUpdateDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public static final String UPDATED = "UPDATED"; // 已回填
public static final String SKIPPED = "SKIPPED"; // 无空缺字段可回填
public static final String NO_HIT = "NO_HIT"; // 工商源未查询到匹配企业
public static final String CONFLICT = "CONFLICT"; // 命中信用代码已被其他客户占用
public static final String NOT_FOUND = "NOT_FOUND"; // 客户不存在(并发删除等)
@Schema(description = "客户 ID")
private Long customerId;
@Schema(description = "客户名称(NOT_FOUND 时为 null)")
private String customerName;
@Schema(description = "结果:UPDATED 已回填 / SKIPPED 无空缺 / NO_HIT 未命中 / CONFLICT 信用代码冲突 / NOT_FOUND 客户不存在")
private String status;
@Schema(description = "本次回填字段数(UPDATED 时 > 0)")
private Integer filledFields;
@Schema(description = "说明(SKIPPED/NO_HIT/CONFLICT/NOT_FOUND 时的原因)")
private String message;
public static CompanyBatchUpdateDTO of(Long customerId, String customerName,
String status, int filledFields, String message) {
CompanyBatchUpdateDTO dto = new CompanyBatchUpdateDTO();
dto.setCustomerId(customerId);
dto.setCustomerName(customerName);
dto.setStatus(status);
dto.setFilledFields(filledFields);
dto.setMessage(message);
return dto;
}
}

3
crm-customer/src/main/java/com/crm/customer/domain/dto/ImportPreviewDTO.java

@ -27,6 +27,9 @@ public class ImportPreviewDTO implements Serializable {
@Schema(description = "导入方式:APPEND_ONLY/UPDATE_ONLY/UPSERT") @Schema(description = "导入方式:APPEND_ONLY/UPDATE_ONLY/UPSERT")
private String importMode; private String importMode;
@Schema(description = "重复策略:SKIP 不导入(默认)/ OVERWRITE 覆盖导入(返工票 09-F8)")
private String duplicateStrategy;
@Schema(description = "数据行总数(客户 sheet)") @Schema(description = "数据行总数(客户 sheet)")
private Integer totalCount; private Integer totalCount;

55
crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerAgreement.java

@ -0,0 +1,55 @@
package com.crm.customer.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.crm.base.domain.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Comment;
/**
* 战略协议子表返工票 12-F5 / 原型 a4-4-6/9/10
*
* <p>无独立 ACL权限继承所属客户<b> {@code @DataScope} 注解</b>服务层按所属
* 客户校验 {@link CustomerContact} 口径写操作后同步刷新客户快照
* {@code customer.strategic_agreement_level}javadoc 明文战略协议子域写入本图只读
* 取最新一条协议等级无协议 NULL=未签</p>
*
* <p>附件单文件走 crm-file fileId 引用不落文件名对齐 {@link CustomerImportTask#fileFileId}
* 先例下载由前端走 crm-file 通用端点本模块零下载端点</p>
*
* <p>命名策略无孤立单大写字母agreementLevel/fileFileId/customerId 均为完整词拆解
* 无需 {@code @Column(name=...)} 钉列名</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("customer_agreement")
@Entity
@Table(name = "customer_agreement", indexes = {
@Index(name = "idx_agreement_customer", columnList = "customer_id, deleted")
})
public class CustomerAgreement extends BaseEntity {
@Comment("所属客户 id(NOT NULL)")
@Column(columnDefinition = "bigint not null comment '所属客户ID'")
private Long customerId;
@Comment("战略协议等级 1~3(一级/二级/三级,值域对齐客户快照列)")
@Column(columnDefinition = "tinyint not null comment '战略协议等级1~3'")
private Integer agreementLevel;
@Comment("签订金额(文本形态:数字/区间/含单位,对齐注册资本 varchar 先例)")
@Column(columnDefinition = "varchar(64) comment '签订金额'")
private String amount;
@Comment("备注")
@Column(columnDefinition = "varchar(500) comment '备注'")
private String remark;
@Comment("附件引用(crm-file fileId,单文件;下载走 crm-file 通用端点)")
@Column(columnDefinition = "varchar(64) comment '附件fileId'")
private String fileFileId;
}

5
crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerImportTask.java

@ -2,6 +2,7 @@ package com.crm.customer.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.crm.base.domain.entity.BaseEntity; import com.crm.base.domain.entity.BaseEntity;
import com.crm.customer.constant.CustomerConstants;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Index; import jakarta.persistence.Index;
@ -36,6 +37,10 @@ public class CustomerImportTask extends BaseEntity {
@Column(columnDefinition = "varchar(20) not null comment '导入方式'") @Column(columnDefinition = "varchar(20) not null comment '导入方式'")
private String importMode; private String importMode;
@Comment("重复策略:SKIP 不导入(默认)/ OVERWRITE 覆盖导入(返工票 09-F8,a4-3-2 复选框)")
@Column(columnDefinition = "varchar(20) not null default 'SKIP' comment '重复策略:SKIP不导入/OVERWRITE覆盖导入'")
private String duplicateStrategy = CustomerConstants.IMPORT_DUP_SKIP;
@Comment("模板版本(预校验一致性锚点)") @Comment("模板版本(预校验一致性锚点)")
@Column(columnDefinition = "varchar(10) not null comment '模板版本'") @Column(columnDefinition = "varchar(10) not null comment '模板版本'")
private String templateVersion; private String templateVersion;

11
crm-customer/src/main/java/com/crm/customer/mapper/CustomerAgreementMapper.java

@ -0,0 +1,11 @@
package com.crm.customer.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.customer.domain.entity.CustomerAgreement;
/**
* 战略协议 Mapper返工票 12-F5 CrmBaseMapper无自定义 SQL
* 页签列表用 LambdaQueryWrapper 覆盖跨客户场景不存在协议只随客户页签出现
*/
public interface CustomerAgreementMapper extends CrmBaseMapper<CustomerAgreement> {
}

29
crm-customer/src/main/java/com/crm/customer/service/ICustomerAgreementService.java

@ -0,0 +1,29 @@
package com.crm.customer.service;
import com.crm.customer.domain.dto.AgreementDTO;
import java.util.List;
/**
* 战略协议服务返工票 12-F5 / 原型 a4-4-6 页签
*
* <p>权限继承所属客户无独立 ACL所有读写入口先定位所属客户判定可见性
* 不存在/无权统一码防探测写操作后同步刷新客户战略协议等级快照</p>
*/
public interface ICustomerAgreementService {
/** 新增协议(所属客户必填且可见);返回新 id;刷新客户快照。 */
Long create(AgreementDTO dto);
/** 编辑协议(归属客户不可换);刷新客户快照。 */
void update(Long id, AgreementDTO dto);
/** 删除协议(软删);刷新客户快照(删空 → 快照回 NULL=未签)。 */
void delete(Long id);
/** 协议详情(编辑回显)。 */
AgreementDTO detail(Long id);
/** 客户页签列表:时间倒序(create_time, id),非分页(原型卡片瀑布)。 */
List<AgreementDTO> listByCustomer(Long customerId);
}

7
crm-customer/src/main/java/com/crm/customer/service/ICustomerImportService.java

@ -20,8 +20,11 @@ public interface ICustomerImportService extends IBaseService<CustomerImportTask>
/** 导入模板文件名(版本号锚定 CustomerConstants.IMPORT_TEMPLATE_VERSION,票 14 评审自 Impl 上移)。 */ /** 导入模板文件名(版本号锚定 CustomerConstants.IMPORT_TEMPLATE_VERSION,票 14 评审自 Impl 上移)。 */
String TEMPLATE_NAME = "客户导入模板-" + CustomerConstants.IMPORT_TEMPLATE_VERSION + ".xlsx"; String TEMPLATE_NAME = "客户导入模板-" + CustomerConstants.IMPORT_TEMPLATE_VERSION + ".xlsx";
/** 上传 + 预校验:文件守门(类型/大小/模板版本/行数)→ 行级判定 → 任务落 DRAFT,返回预计数 + 明细。 */ /**
ImportPreviewDTO upload(MultipartFile file, String importMode); * 上传 + 预校验文件守门类型/大小/模板版本/行数 行级判定 任务落 DRAFT返回预计数 + 明细
* duplicateStrategySKIP 不导入默认/ OVERWRITE 覆盖导入返工票 09-F8D24 语义
*/
ImportPreviewDTO upload(MultipartFile file, String importMode, String duplicateStrategy);
/** 确认创建正式任务(DRAFT → RUNNING + 触发异步执行);重复确认 67014。 */ /** 确认创建正式任务(DRAFT → RUNNING + 触发异步执行);重复确认 67014。 */
Long confirm(Long taskId); Long confirm(Long taskId);

10
crm-customer/src/main/java/com/crm/customer/service/ICustomerService.java

@ -8,10 +8,13 @@ import com.crm.customer.domain.dto.CustomerQuickCreateDTO;
import com.crm.customer.domain.dto.CustomerSaveResultDTO; import com.crm.customer.domain.dto.CustomerSaveResultDTO;
import com.crm.customer.domain.dto.CustomerSearchItemDTO; import com.crm.customer.domain.dto.CustomerSearchItemDTO;
import com.crm.customer.domain.dto.CustomerUpdateDTO; import com.crm.customer.domain.dto.CustomerUpdateDTO;
import com.crm.customer.domain.dto.CompanyBatchUpdateDTO;
import com.crm.customer.domain.entity.Customer; import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.param.CustomerPageParam; import com.crm.customer.domain.param.CustomerPageParam;
import com.crm.customer.domain.param.CustomerSearchParam; import com.crm.customer.domain.param.CustomerSearchParam;
import java.util.List;
/** /**
* 客户主表服务 02新增/编辑带三层查重 workspace 视图查询在票 04 增强 * 客户主表服务 02新增/编辑带三层查重 workspace 视图查询在票 04 增强
*/ */
@ -45,4 +48,11 @@ public interface ICustomerService extends IBaseService<Customer> {
* DataScope 自动注入可见范围 * DataScope 自动注入可见范围
*/ */
PageResult<CustomerSearchItemDTO> searchForOpportunity(CustomerSearchParam param); PageResult<CustomerSearchItemDTO> searchForOpportunity(CustomerSearchParam param);
/**
* 批量更新工商信息返工票 09-F6 / a4-3-1 多选确认弹窗逐客户按名称查工商源
* 只回填空缺字段对称 issues-04 §C2 回填口径命中信用代码与他人冲突跳过不写
* 行级部分成功返回每客户结果一期 stub NO_HIT
*/
List<CompanyBatchUpdateDTO> batchCompanyUpdate(List<Long> ids);
} }

173
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerAgreementServiceImpl.java

@ -0,0 +1,173 @@
package com.crm.customer.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.service.impl.BaseServiceImpl;
import com.crm.customer.constant.CustomerConstants;
import com.crm.customer.domain.dto.AgreementDTO;
import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerAgreement;
import com.crm.customer.mapper.CustomerAgreementMapper;
import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.CustomerScopeEvaluator;
import com.crm.customer.service.ICustomerAgreementService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 战略协议服务实现返工票 12-F5
*
* <p>对齐 {@link CustomerContactServiceImpl} 口径无独立 ACL读写先定位所属客户
* {@link CustomerScopeEvaluator#visibleToCurrent} 判定不存在/无权统一码防探测
* 协议维度 67017客户维度 67002可见即可写编辑归属客户不可换dto.customerId 忽略</p>
*
* <p>客户快照刷新create/edit/delete 取该客户最新一条协议create_time,id 倒序
* limit 1的等级回写 {@code customer.strategic_agreement_level}无协议 NULL=未签
* LambdaUpdateWrapper 只更新该列不 bump version对齐跟进锚点刷新先例避免干扰用户 CAS 编辑</p>
*
* <p>操作日志对齐 F10 CONTACT_* 先例增删改写客户 oplogdetail 记等级文案</p>
*/
@Service
@RequiredArgsConstructor
public class CustomerAgreementServiceImpl
extends BaseServiceImpl<CustomerAgreementMapper, CustomerAgreement>
implements ICustomerAgreementService {
/** 等级值域上界(1~3,对齐客户快照列 javadoc;原型 a4-4-6 示例「三级」)。 */
private static final int LEVEL_MAX = 3;
private final CustomerMapper customerMapper;
private final CustomerOplogWriter oplogWriter;
@Override
public Long create(AgreementDTO dto) {
if (dto.getCustomerId() == null) {
throw new BusinessErrorException(CustomerConstants.CODE_CUST_INVALID, "所属客户必填");
}
Customer customer = requireCustomerVisible(dto.getCustomerId());
validate(dto);
CustomerAgreement entity = AgreementDTO.toEntity(dto);
entity.setCustomerId(customer.getId());
this.save(entity);
refreshSnapshot(customer.getId());
oplogWriter.write(customer.getId(), CustomerConstants.ACTION_AGREEMENT_ADD,
"新增战略协议【" + levelLabel(entity.getAgreementLevel()) + "】");
return entity.getId();
}
@Override
public void update(Long id, AgreementDTO dto) {
CustomerAgreement exist = requireAgreementVisible(id);
validate(dto);
// 归属客户不可换(dto.customerId 忽略);系统字段与软删位不动
BeanUtils.copyProperties(dto, exist, "id", "customerId", "createTime", "updateTime",
"creatorId", "updaterId", "deleted");
this.updateById(exist);
refreshSnapshot(exist.getCustomerId());
oplogWriter.write(exist.getCustomerId(), CustomerConstants.ACTION_AGREEMENT_EDIT,
"修改战略协议【" + levelLabel(exist.getAgreementLevel()) + "】");
}
@Override
public void delete(Long id) {
CustomerAgreement exist = requireAgreementVisible(id);
this.removeById(id);
refreshSnapshot(exist.getCustomerId());
oplogWriter.write(exist.getCustomerId(), CustomerConstants.ACTION_AGREEMENT_DELETE,
"删除战略协议【" + levelLabel(exist.getAgreementLevel()) + "】");
}
@Override
public AgreementDTO detail(Long id) {
CustomerAgreement exist = requireAgreementVisible(id);
AgreementDTO dto = AgreementDTO.fromEntity(exist);
Customer customer = customerMapper.selectById(exist.getCustomerId());
if (customer != null) {
dto.setCustomerName(customer.getCustomerName());
}
return dto;
}
@Override
public List<AgreementDTO> listByCustomer(Long customerId) {
requireCustomerVisible(customerId);
Customer customer = customerMapper.selectById(customerId);
return this.list(new LambdaQueryWrapper<CustomerAgreement>()
.eq(CustomerAgreement::getCustomerId, customerId)
.orderByDesc(CustomerAgreement::getCreateTime)
.orderByDesc(CustomerAgreement::getId))
.stream().map(a -> {
AgreementDTO dto = AgreementDTO.fromEntity(a);
if (customer != null) {
dto.setCustomerName(customer.getCustomerName());
}
return dto;
}).toList();
}
// ==================== 私有:校验与权限 ====================
private void validate(AgreementDTO dto) {
if (dto.getAgreementLevel() == null || dto.getAgreementLevel() < 1
|| dto.getAgreementLevel() > LEVEL_MAX) {
throw new BusinessErrorException(CustomerConstants.CODE_AGREEMENT_INVALID,
"战略协议等级必填且为 1~3");
}
if (dto.getAmount() != null && dto.getAmount().length() > 64) {
throw new BusinessErrorException(CustomerConstants.CODE_AGREEMENT_INVALID, "签订金额超长");
}
}
/** 协议必须存在且所属客户对当前用户可见(防探测:不存在/无权同码 67017 协议维度)。 */
private CustomerAgreement requireAgreementVisible(Long id) {
if (id == null) {
throw new BusinessErrorException(CustomerConstants.CODE_AGREEMENT_INVALID, "协议 id 必填");
}
CustomerAgreement exist = this.getById(id);
if (exist == null) {
throw new BusinessErrorException(CustomerConstants.CODE_AGREEMENT_INVALID, "协议不存在或无权访问");
}
requireCustomerVisible(exist.getCustomerId());
return exist;
}
/** 客户必须存在且对当前用户可见(防探测:不存在/无权同码 67002);返回该客户。 */
private Customer requireCustomerVisible(Long customerId) {
Customer customer = customerId == null ? null : customerMapper.selectById(customerId);
if (customer == null || !CustomerScopeEvaluator.visibleToCurrent(
customer.getOwnerUserId(), customer.getOwnerDeptId())) {
throw new BusinessErrorException(CustomerConstants.CODE_CUST_NOT_EXIST, "客户不存在或无权访问");
}
return customer;
}
/**
* 客户快照刷新最新一条协议等级回写无协议 NULL=未签
* 只更新该列不 bump version避免干扰用户编辑的乐观锁对齐跟进锚点刷新先例
*/
private void refreshSnapshot(Long customerId) {
CustomerAgreement latest = this.getOne(new LambdaQueryWrapper<CustomerAgreement>()
.eq(CustomerAgreement::getCustomerId, customerId)
.orderByDesc(CustomerAgreement::getCreateTime)
.orderByDesc(CustomerAgreement::getId)
.last("LIMIT 1"));
customerMapper.update(null, new LambdaUpdateWrapper<Customer>()
.eq(Customer::getId, customerId)
.set(Customer::getStrategicAgreementLevel, latest == null ? null : latest.getAgreementLevel()));
}
/** 等级文案(oplog detail 用;原型 a4-4-6 示例「三级」)。 */
private static String levelLabel(Integer level) {
return switch (level == null ? 0 : level) {
case 1 -> "一级";
case 2 -> "二级";
case 3 -> "三级";
default -> "未知";
};
}
}

13
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerContactServiceImpl.java

@ -19,6 +19,7 @@ import com.crm.customer.domain.param.ContactPageParam;
import com.crm.customer.mapper.CustomerContactMapper; import com.crm.customer.mapper.CustomerContactMapper;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.service.CustomerDedupSettingProvider; import com.crm.customer.service.CustomerDedupSettingProvider;
import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.CustomerScopeEvaluator; import com.crm.customer.service.CustomerScopeEvaluator;
import com.crm.customer.service.ICustomerContactService; import com.crm.customer.service.ICustomerContactService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -41,6 +42,9 @@ import java.util.regex.Pattern;
* *
* <p>电话查重D26不阻断创建 {@link #checkPhone} 软提示命中行展示权限按 * <p>电话查重D26不阻断创建 {@link #checkPhone} 软提示命中行展示权限按
* 所属客户判定不可见脱敏为存在匹配记录</p> * 所属客户判定不可见脱敏为存在匹配记录</p>
*
* <p>操作日志返工票 09-F10 / a4-5-6增删改写客户 oplogCONTACT_ADD/EDIT/DELETE
* customerId 供客户详情日志页按联系人动作过滤detail 文本对称客户 CRUD 形态</p>
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -52,6 +56,7 @@ public class CustomerContactServiceImpl extends BaseServiceImpl<CustomerContactM
private final CustomerMapper customerMapper; private final CustomerMapper customerMapper;
private final CustomerDedupSettingProvider settings; private final CustomerDedupSettingProvider settings;
private final CustomerOplogWriter oplogWriter;
@Override @Override
public Long create(ContactDTO dto) { public Long create(ContactDTO dto) {
@ -63,6 +68,8 @@ public class CustomerContactServiceImpl extends BaseServiceImpl<CustomerContactM
CustomerContact entity = ContactDTO.toEntity(dto); CustomerContact entity = ContactDTO.toEntity(dto);
entity.setCustomerId(customer.getId()); entity.setCustomerId(customer.getId());
this.save(entity); this.save(entity);
oplogWriter.write(customer.getId(), CustomerConstants.ACTION_CONTACT_ADD,
"新增联系人【" + entity.getName() + "】");
return entity.getId(); return entity.getId();
} }
@ -74,12 +81,16 @@ public class CustomerContactServiceImpl extends BaseServiceImpl<CustomerContactM
BeanUtils.copyProperties(dto, exist, "id", "customerId", "createTime", "updateTime", BeanUtils.copyProperties(dto, exist, "id", "customerId", "createTime", "updateTime",
"creatorId", "updaterId", "deleted"); "creatorId", "updaterId", "deleted");
this.updateById(exist); this.updateById(exist);
oplogWriter.write(exist.getCustomerId(), CustomerConstants.ACTION_CONTACT_EDIT,
"修改联系人【" + exist.getName() + "】");
} }
@Override @Override
public void delete(Long id) { public void delete(Long id) {
requireContactVisible(id); CustomerContact exist = requireContactVisible(id);
this.removeById(id); this.removeById(id);
oplogWriter.write(exist.getCustomerId(), CustomerConstants.ACTION_CONTACT_DELETE,
"删除联系人【" + exist.getName() + "】");
} }
@Override @Override

29
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerDetailServiceImpl.java

@ -1,6 +1,7 @@
package com.crm.customer.service.impl; package com.crm.customer.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.auth.domain.entity.AuthUser; import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.service.IAuthUserService; import com.crm.auth.service.IAuthUserService;
@ -8,6 +9,7 @@ import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException; import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.param.BaseParam; import com.crm.base.domain.param.BaseParam;
import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.PageResult;
import com.crm.base.security.SecurityUtils;
import com.crm.base.utils.PageConverter; import com.crm.base.utils.PageConverter;
import com.crm.customer.constant.CustomerConstants; import com.crm.customer.constant.CustomerConstants;
import com.crm.customer.domain.dto.CustomerDetailHeadDTO; import com.crm.customer.domain.dto.CustomerDetailHeadDTO;
@ -16,12 +18,14 @@ import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerFollow; import com.crm.customer.domain.entity.CustomerFollow;
import com.crm.customer.domain.entity.CustomerOplog; import com.crm.customer.domain.entity.CustomerOplog;
import com.crm.customer.domain.entity.CustomerTeamMember; import com.crm.customer.domain.entity.CustomerTeamMember;
import com.crm.customer.domain.entity.CustomerViewLog;
import com.crm.customer.domain.enums.CustomerStageEnum; import com.crm.customer.domain.enums.CustomerStageEnum;
import com.crm.customer.domain.param.OplogPageParam; import com.crm.customer.domain.param.OplogPageParam;
import com.crm.customer.mapper.CustomerFollowMapper; import com.crm.customer.mapper.CustomerFollowMapper;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.mapper.CustomerOplogMapper; import com.crm.customer.mapper.CustomerOplogMapper;
import com.crm.customer.mapper.CustomerTeamMemberMapper; import com.crm.customer.mapper.CustomerTeamMemberMapper;
import com.crm.customer.mapper.CustomerViewLogMapper;
import com.crm.customer.port.CustomerOpportunityQueryPort; import com.crm.customer.port.CustomerOpportunityQueryPort;
import com.crm.customer.service.CustomerOplogWriter; import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.ICustomerDetailService; import com.crm.customer.service.ICustomerDetailService;
@ -54,6 +58,7 @@ public class CustomerDetailServiceImpl implements ICustomerDetailService {
private final CustomerFollowMapper followMapper; private final CustomerFollowMapper followMapper;
private final CustomerTeamMemberMapper teamMemberMapper; private final CustomerTeamMemberMapper teamMemberMapper;
private final CustomerOplogMapper oplogMapper; private final CustomerOplogMapper oplogMapper;
private final CustomerViewLogMapper viewLogMapper;
private final CustomerOpportunityQueryPort opportunityQueryPort; private final CustomerOpportunityQueryPort opportunityQueryPort;
private final IAuthUserService userService; private final IAuthUserService userService;
private final ISysDeptService deptService; private final ISysDeptService deptService;
@ -62,6 +67,7 @@ public class CustomerDetailServiceImpl implements ICustomerDetailService {
@Override @Override
public CustomerDetailHeadDTO detailHead(Long customerId) { public CustomerDetailHeadDTO detailHead(Long customerId) {
Customer c = require(customerId); Customer c = require(customerId);
touchViewLog(customerId, Long.valueOf(SecurityUtils.getRequiredUserId()));
CustomerDetailHeadDTO head = new CustomerDetailHeadDTO(); CustomerDetailHeadDTO head = new CustomerDetailHeadDTO();
head.setId(c.getId()); head.setId(c.getId());
head.setCustomerNo(c.getCustomerNo()); head.setCustomerNo(c.getCustomerNo());
@ -211,6 +217,29 @@ public class CustomerDetailServiceImpl implements ICustomerDetailService {
return c; return c;
} }
/**
* 详情页触达返工票 09-F7upsert 刷新 customer_view_log内置视图最近访问
* 数据源 04 建壳挂账于此兑现UNIQUE(user_id, customer_id) 天然去重
* 高频行为不落 oplog实体 javadoc 明文设计意图
*/
private void touchViewLog(Long customerId, Long userId) {
CustomerViewLog existing = viewLogMapper.selectOne(new LambdaQueryWrapper<CustomerViewLog>()
.eq(CustomerViewLog::getCustomerId, customerId)
.eq(CustomerViewLog::getUserId, userId)
.last("LIMIT 1"));
if (existing != null) {
viewLogMapper.update(null, new LambdaUpdateWrapper<CustomerViewLog>()
.eq(CustomerViewLog::getId, existing.getId())
.set(CustomerViewLog::getLastViewTime, LocalDateTime.now()));
} else {
CustomerViewLog log = new CustomerViewLog();
log.setCustomerId(customerId);
log.setUserId(userId);
log.setLastViewTime(LocalDateTime.now());
viewLogMapper.insert(log);
}
}
/** 最近跟进摘要 + 待跟进提醒(未来最近一条 next_follow_time,null=无待跟进)。 */ /** 最近跟进摘要 + 待跟进提醒(未来最近一条 next_follow_time,null=无待跟进)。 */
private void fillFollowDigest(CustomerDetailHeadDTO head, Long customerId) { private void fillFollowDigest(CustomerDetailHeadDTO head, Long customerId) {
List<CustomerFollow> lastRows = followMapper.selectList( List<CustomerFollow> lastRows = followMapper.selectList(

29
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportAnalyzer.java

@ -5,6 +5,7 @@ import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerContact; import com.crm.customer.domain.entity.CustomerContact;
import com.crm.customer.domain.excel.ContactImportRow; import com.crm.customer.domain.excel.ContactImportRow;
import com.crm.customer.domain.excel.CustomerImportRow; import com.crm.customer.domain.excel.CustomerImportRow;
import com.crm.customer.constant.CustomerConstants;
import com.crm.customer.mapper.CustomerContactMapper; import com.crm.customer.mapper.CustomerContactMapper;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.port.CustomerNameSimilarityPort; import com.crm.customer.port.CustomerNameSimilarityPort;
@ -25,9 +26,10 @@ import java.util.Set;
* *
* <p>upload 预校验与 confirm 执行共用同一判定预计数与实际结果同口径 * <p>upload 预校验与 confirm 执行共用同一判定预计数与实际结果同口径
* 匹配顺序 = 客户编号完全一致 统一社会信用代码完全一致编号与信用代码 * 匹配顺序 = 客户编号完全一致 统一社会信用代码完全一致编号与信用代码
* 分别命中不同客户 该行冲突禁止导入文件内多行匹配同一客户 重复组全部失败 * 分别命中不同客户 该行冲突禁止导入文件内多行匹配同一客户 duplicateStrategy
* Q14 默认重复时不导入均未匹配 不凭名称自动覆盖UPSERT 下名称相似 * 处置旧图 D24 / a4-3-2 复选框默认 SKIP 重复组全部失败OVERWRITE 覆盖导入 =
* 列疑似重复不写入对称票 02 查重口径</p> * 自上而下最后一条通过校验的行生效其余标失败说明未执行均未匹配 不凭名称
* 自动覆盖UPSERT 下名称相似列疑似重复不写入对称票 02 查重口径策略不影响 SUSPECT</p>
* *
* <p>联系人D26 翻案后电话命中本客户 更新命中他客户 照常导入 + * <p>联系人D26 翻案后电话命中本客户 更新命中他客户 照常导入 +
* 疑似重复软提示跟随查重设置联系电话开关电话空 客户+姓名+职务 * 疑似重复软提示跟随查重设置联系电话开关电话空 客户+姓名+职务
@ -84,7 +86,7 @@ public class CustomerImportAnalyzer {
} }
/** 客户行判定(两遍:逐行静态+匹配 → 文件内重复组互斥)。 */ /** 客户行判定(两遍:逐行静态+匹配 → 文件内重复组互斥)。 */
public List<RowPlan> analyzeCustomers(List<CustomerImportRow> rows, String mode) { public List<RowPlan> analyzeCustomers(List<CustomerImportRow> rows, String mode, String duplicateStrategy) {
List<RowPlan> plans = new ArrayList<>(); List<RowPlan> plans = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
CustomerImportRow r = rows.get(i); CustomerImportRow r = rows.get(i);
@ -139,7 +141,9 @@ public class CustomerImportAnalyzer {
} }
} }
// 文件内重复组:多行匹配同一已有客户 → 重复组全部失败(Q14 默认「重复时不导入」) // 文件内重复组:多行匹配同一已有客户 → 按 duplicateStrategy 处置
//(D24:默认 SKIP 重复组全失败;OVERWRITE 仅自上而下最后一条通过校验的行生效)
boolean overwrite = CustomerConstants.IMPORT_DUP_OVERWRITE.equals(duplicateStrategy);
Map<Long, List<RowPlan>> groups = new HashMap<>(); Map<Long, List<RowPlan>> groups = new HashMap<>();
for (RowPlan p : plans) { for (RowPlan p : plans) {
if (p.getMatched() != null) { if (p.getMatched() != null) {
@ -148,9 +152,22 @@ public class CustomerImportAnalyzer {
} }
for (List<RowPlan> group : groups.values()) { for (List<RowPlan> group : groups.values()) {
if (group.size() > 1) { if (group.size() > 1) {
RowPlan survivor = null;
if (overwrite) {
for (RowPlan p : group) {
if (VERDICT_UPDATE.equals(p.getVerdict())) {
survivor = p;
}
}
}
for (RowPlan p : group) { for (RowPlan p : group) {
if (p == survivor) {
continue;
}
p.setVerdict(VERDICT_FAIL); p.setVerdict(VERDICT_FAIL);
p.setReason("文件内多行匹配同一客户(重复时不导入),请合并为单行"); p.setReason(overwrite
? "文件内多行匹配同一客户(覆盖导入:仅最后一条通过校验的行生效),本行未执行"
: "文件内多行匹配同一客户(重复时不导入),请合并为单行");
} }
} }
} }

16
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportServiceImpl.java

@ -69,14 +69,22 @@ public class CustomerImportServiceImpl extends BaseServiceImpl<CustomerImportTas
private final com.crm.auth.service.IAuthUserService userService; private final com.crm.auth.service.IAuthUserService userService;
@Override @Override
public ImportPreviewDTO upload(MultipartFile file, String importMode) { public ImportPreviewDTO upload(MultipartFile file, String importMode, String duplicateStrategy) {
// 守门:方式 / 文件类型 / 大小(issues-09 §D 预校验) // 守门:方式 / 重复策略 / 文件类型 / 大小(issues-09 §D 预校验)
if (!CustomerConstants.IMPORT_MODE_APPEND_ONLY.equals(importMode) if (!CustomerConstants.IMPORT_MODE_APPEND_ONLY.equals(importMode)
&& !CustomerConstants.IMPORT_MODE_UPDATE_ONLY.equals(importMode) && !CustomerConstants.IMPORT_MODE_UPDATE_ONLY.equals(importMode)
&& !CustomerConstants.IMPORT_MODE_UPSERT.equals(importMode)) { && !CustomerConstants.IMPORT_MODE_UPSERT.equals(importMode)) {
throw new BusinessErrorException(CustomerConstants.CODE_IMPORT_PRECHECK_FAIL, throw new BusinessErrorException(CustomerConstants.CODE_IMPORT_PRECHECK_FAIL,
"导入方式非法(仅新增/仅更新/新增或更新)"); "导入方式非法(仅新增/仅更新/新增或更新)");
} }
// 重复策略缺省 SKIP(D24 默认「重复时不导入」;a4-3-2 复选框,返工票 09-F8)
String dupStrategy = duplicateStrategy == null || duplicateStrategy.isBlank()
? CustomerConstants.IMPORT_DUP_SKIP : duplicateStrategy.trim();
if (!CustomerConstants.IMPORT_DUP_SKIP.equals(dupStrategy)
&& !CustomerConstants.IMPORT_DUP_OVERWRITE.equals(dupStrategy)) {
throw new BusinessErrorException(CustomerConstants.CODE_IMPORT_PRECHECK_FAIL,
"重复策略非法(数据重复时覆盖导入/不导入)");
}
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new BusinessErrorException(CustomerConstants.CODE_IMPORT_PRECHECK_FAIL, "请选择导入文件"); throw new BusinessErrorException(CustomerConstants.CODE_IMPORT_PRECHECK_FAIL, "请选择导入文件");
} }
@ -115,7 +123,7 @@ public class CustomerImportServiceImpl extends BaseServiceImpl<CustomerImportTas
checkTemplateVersion(file); checkTemplateVersion(file);
// 行级判定(与执行同口径)+ 计数 // 行级判定(与执行同口径)+ 计数
List<CustomerImportAnalyzer.RowPlan> plans = analyzer.analyzeCustomers(rows, importMode); List<CustomerImportAnalyzer.RowPlan> plans = analyzer.analyzeCustomers(rows, importMode, dupStrategy);
List<CustomerImportAnalyzer.ContactPlan> contactPlans = contacts.isEmpty() List<CustomerImportAnalyzer.ContactPlan> contactPlans = contacts.isEmpty()
? List.of() : analyzer.analyzeContacts(contacts, plans); ? List.of() : analyzer.analyzeContacts(contacts, plans);
int insert = 0; int insert = 0;
@ -144,6 +152,7 @@ public class CustomerImportServiceImpl extends BaseServiceImpl<CustomerImportTas
CustomerImportTask task = new CustomerImportTask(); CustomerImportTask task = new CustomerImportTask();
task.setImportMode(importMode); task.setImportMode(importMode);
task.setDuplicateStrategy(dupStrategy);
task.setTemplateVersion(CustomerConstants.IMPORT_TEMPLATE_VERSION); task.setTemplateVersion(CustomerConstants.IMPORT_TEMPLATE_VERSION);
task.setFileFileId(stored.getFileId()); task.setFileFileId(stored.getFileId());
task.setFileHash(sha256(bytes)); task.setFileHash(sha256(bytes));
@ -159,6 +168,7 @@ public class CustomerImportServiceImpl extends BaseServiceImpl<CustomerImportTas
ImportPreviewDTO dto = new ImportPreviewDTO(); ImportPreviewDTO dto = new ImportPreviewDTO();
dto.setTaskId(task.getId()); dto.setTaskId(task.getId());
dto.setImportMode(importMode); dto.setImportMode(importMode);
dto.setDuplicateStrategy(dupStrategy);
dto.setTotalCount(rows.size()); dto.setTotalCount(rows.size());
dto.setInsertCount(insert); dto.setInsertCount(insert);
dto.setUpdateCount(update); dto.setUpdateCount(update);

100
crm-customer/src/main/java/com/crm/customer/service/impl/CustomerServiceImpl.java

@ -17,10 +17,12 @@ import com.crm.customer.domain.dto.CustomerQuickCreateDTO;
import com.crm.customer.domain.dto.CustomerSaveResultDTO; import com.crm.customer.domain.dto.CustomerSaveResultDTO;
import com.crm.customer.domain.dto.CustomerSearchItemDTO; import com.crm.customer.domain.dto.CustomerSearchItemDTO;
import com.crm.customer.domain.dto.CustomerUpdateDTO; import com.crm.customer.domain.dto.CustomerUpdateDTO;
import com.crm.customer.domain.dto.CompanyBatchUpdateDTO;
import com.crm.customer.domain.entity.Customer; import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.param.CustomerPageParam; import com.crm.customer.domain.param.CustomerPageParam;
import com.crm.customer.domain.param.CustomerSearchParam; import com.crm.customer.domain.param.CustomerSearchParam;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.port.CompanyLookupPort;
import com.crm.customer.service.CustomerDedupService; import com.crm.customer.service.CustomerDedupService;
import com.crm.customer.service.CustomerOplogWriter; import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.ICustomerService; import com.crm.customer.service.ICustomerService;
@ -39,6 +41,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
/** /**
* 客户主表服务实现 02 * 客户主表服务实现 02
@ -63,17 +66,20 @@ public class CustomerServiceImpl extends BaseServiceImpl<CustomerMapper, Custome
private final IAuthUserService userService; private final IAuthUserService userService;
private final ISysDeptService deptService; private final ISysDeptService deptService;
private final DictQueryService dictQueryService; private final DictQueryService dictQueryService;
private final CompanyLookupPort companyLookupPort;
public CustomerServiceImpl(CustomerOplogWriter oplogWriter, public CustomerServiceImpl(CustomerOplogWriter oplogWriter,
CustomerDedupService dedupService, CustomerDedupService dedupService,
IAuthUserService userService, IAuthUserService userService,
ISysDeptService deptService, ISysDeptService deptService,
DictQueryService dictQueryService) { DictQueryService dictQueryService,
CompanyLookupPort companyLookupPort) {
this.oplogWriter = oplogWriter; this.oplogWriter = oplogWriter;
this.dedupService = dedupService; this.dedupService = dedupService;
this.userService = userService; this.userService = userService;
this.deptService = deptService; this.deptService = deptService;
this.dictQueryService = dictQueryService; this.dictQueryService = dictQueryService;
this.companyLookupPort = companyLookupPort;
} }
// ==================== 新增 ==================== // ==================== 新增 ====================
@ -413,6 +419,98 @@ public class CustomerServiceImpl extends BaseServiceImpl<CustomerMapper, Custome
return out; return out;
} }
// ==================== 批量工商更新(返工票 09-F6 / a4-3-1 多选确认弹窗) ====================
/** 单次批量上限(防御性收口;原型交互为多选确认弹窗,个位数量级)。 */
private static final int MAX_BATCH_COMPANY_UPDATE = 100;
@Override
@Transactional(rollbackFor = Exception.class)
public List<CompanyBatchUpdateDTO> batchCompanyUpdate(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
throw new BusinessErrorException(CustomerConstants.CODE_CUST_INVALID, "请选择要更新工商信息的客户");
}
if (ids.size() > MAX_BATCH_COMPANY_UPDATE) {
throw new BusinessErrorException(CustomerConstants.CODE_CUST_INVALID,
"单次最多批量更新 " + MAX_BATCH_COMPANY_UPDATE + " 个客户");
}
List<CompanyBatchUpdateDTO> results = new ArrayList<>(ids.size());
for (Long id : ids.stream().distinct().toList()) {
results.add(batchCompanyUpdateOne(id));
}
return results;
}
/**
* 单客户工商回填按客户名称查 port 取首个候选只回填空缺字段对称
* issues-04 §C2只回填当前为空的字段命中信用代码与他人占用冲突
* CONFLICT 不写L3 终查同口径软处置不中断批次有回填才落库 + oplog
*/
private CompanyBatchUpdateDTO batchCompanyUpdateOne(Long id) {
Customer c = this.getById(id);
if (c == null) {
return CompanyBatchUpdateDTO.of(id, null, CompanyBatchUpdateDTO.NOT_FOUND, 0, "客户不存在");
}
List<CompanyLookupPort.CompanyCandidate> hits = companyLookupPort.lookup(c.getCustomerName());
if (hits.isEmpty()) {
return CompanyBatchUpdateDTO.of(id, c.getCustomerName(), CompanyBatchUpdateDTO.NO_HIT,
0, "工商源未查询到匹配企业");
}
CompanyLookupPort.CompanyCandidate hit = hits.get(0);
try {
dedupService.guardCreditCodeForWrite(blankToNull(hit.unifiedCreditCode()), id);
} catch (BusinessErrorException e) {
return CompanyBatchUpdateDTO.of(id, c.getCustomerName(), CompanyBatchUpdateDTO.CONFLICT,
0, e.getMessage());
}
int filled = 0;
filled += fillIfBlank(c.getBizNameFromLookup(), hit.bizName(), c::setBizNameFromLookup);
filled += fillIfBlank(c.getUnifiedCreditCode(), hit.unifiedCreditCode(), c::setUnifiedCreditCode);
filled += fillIfBlank(c.getLegalRepresentative(), hit.legalRepresentative(), c::setLegalRepresentative);
filled += fillDateIfBlank(c.getEstablishedDate(), hit.establishedDate(), c::setEstablishedDate);
filled += fillIfBlank(c.getRegisteredCapital(), hit.registeredCapital(), c::setRegisteredCapital);
filled += fillIfBlank(c.getBusinessScope(), hit.businessScope(), c::setBusinessScope);
filled += fillIfBlank(c.getStaffSize(), hit.staffSize(), c::setStaffSize);
filled += fillIfBlank(c.getAnnualRevenue(), hit.annualRevenue(), c::setAnnualRevenue);
filled += fillIfBlank(c.getBusinessAddress(), hit.address(), c::setBusinessAddress);
if (filled == 0) {
return CompanyBatchUpdateDTO.of(id, c.getCustomerName(), CompanyBatchUpdateDTO.SKIPPED,
0, "工商字段均已填写,无空缺可回填");
}
if (!this.updateById(c)) {
return CompanyBatchUpdateDTO.of(id, c.getCustomerName(), CompanyBatchUpdateDTO.CONFLICT,
0, "客户已被他人修改,请刷新后重试");
}
oplogWriter.write(c.getId(), CustomerConstants.ACTION_UPDATE,
"批量更新工商信息:回填 " + filled + " 个字段");
return CompanyBatchUpdateDTO.of(id, c.getCustomerName(), CompanyBatchUpdateDTO.UPDATED,
filled, null);
}
/** 空缺才回填(空白 incoming 跳过、非空 current 不覆盖;返回是否写入)。 */
private static int fillIfBlank(String current, String incoming, Consumer<String> setter) {
if (incoming == null || incoming.isBlank()) {
return 0;
}
if (current != null && !current.isBlank()) {
return 0;
}
setter.accept(incoming.trim());
return 1;
}
private static int fillDateIfBlank(LocalDate current, LocalDate incoming, Consumer<LocalDate> setter) {
if (incoming == null || current != null) {
return 0;
}
setter.accept(incoming);
return 1;
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s.trim();
}
private void appendChange(List<String> out, String label, Object oldV, Object newV) { private void appendChange(List<String> out, String label, Object oldV, Object newV) {
String o = oldV == null ? "" : String.valueOf(oldV); String o = oldV == null ? "" : String.valueOf(oldV);
String n = newV == null ? "" : String.valueOf(newV); String n = newV == null ? "" : String.valueOf(newV);

3
crm-customer/src/main/java/com/crm/customer/task/CustomerImportExecutor.java

@ -96,7 +96,8 @@ public class CustomerImportExecutor {
contacts = List.of(); contacts = List.of();
} }
List<CustomerImportAnalyzer.RowPlan> plans = analyzer.analyzeCustomers(rows, task.getImportMode()); List<CustomerImportAnalyzer.RowPlan> plans = analyzer.analyzeCustomers(rows,
task.getImportMode(), task.getDuplicateStrategy());
List<CustomerImportAnalyzer.ContactPlan> contactPlans = contacts.isEmpty() List<CustomerImportAnalyzer.ContactPlan> contactPlans = contacts.isEmpty()
? List.of() : analyzer.analyzeContacts(contacts, plans); ? List.of() : analyzer.analyzeContacts(contacts, plans);
AuthUser submitter = userService.getById(Long.valueOf(task.getCreatorId())); AuthUser submitter = userService.getById(Long.valueOf(task.getCreatorId()));

439
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerAgreementIntegrationTest.java

@ -0,0 +1,439 @@
package com.crm.customer.service.impl;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.service.IAuthUserService;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.config.MetaObjectFillHandler;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.security.DataScopeLevel;
import com.crm.base.security.DataVisibility;
import com.crm.base.security.DataVisibilityContext;
import com.crm.base.security.SecurityUtils;
import com.crm.customer.constant.CustomerConstants;
import com.crm.customer.domain.dto.AgreementDTO;
import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerAgreement;
import com.crm.customer.domain.entity.CustomerOplog;
import com.crm.customer.mapper.CustomerAgreementMapper;
import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.mapper.CustomerOplogMapper;
import com.crm.customer.service.CustomerOplogWriter;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.LocalCacheScope;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 战略协议子表 H2 集成测试返工票 12-F5 验收 / 原型 a4-4-6/9/10
*
* <p>范式对齐 {@code CustomerContactServiceIntegrationTest}真实 SQL 链路 +
* mock auth 服务覆盖 CRUD 正常与校验路径权限继承客户SELF 范围防探测
* oplog 三动作客户快照刷新最新协议等级回写 / 删空回 NULL</p>
*/
@DisplayName("战略协议:页签 CRUD + 快照刷新 + oplog(H2 集成,票 12-F5)")
class CustomerAgreementIntegrationTest {
private static final Long OPERATOR_ID = 5001L;
private static final Long OWNER_ID = 6001L;
private static final Long OTHER_OWNER_ID = 6002L;
private static final Long DEPT_ID = 1001L;
private static final Long OTHER_DEPT_ID = 2002L;
private static final AtomicBoolean BOOTSTRAPPED = new AtomicBoolean(false);
private static SqlSessionFactory sqlSessionFactory;
private static final JdbcDataSource DS = new JdbcDataSource();
private SqlSession session;
private CustomerMapper customerMapper;
private CustomerAgreementMapper agreementMapper;
private CustomerOplogMapper oplogMapper;
private CustomerAgreementServiceImpl service;
private MockedStatic<SecurityUtils> securityMock;
private int noSeq = 1;
private final IAuthUserService userService = mock(IAuthUserService.class);
private final ISysDeptService deptService = mock(ISysDeptService.class);
@BeforeAll
static void bootstrap() throws Exception {
if (!BOOTSTRAPPED.compareAndSet(false, true)) {
return;
}
DS.setURL("jdbc:h2:mem:custagr;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;"
+ "DB_CLOSE_DELAY=-1");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("custagr-test", new JdbcTransactionFactory(), DS));
configuration.setMapUnderscoreToCamelCase(true);
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor();
mpInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
mpInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
configuration.addInterceptor(mpInterceptor);
GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler());
GlobalConfigUtils.getGlobalConfig(configuration).setIdentifierGenerator(DefaultIdentifierGenerator.getInstance());
configuration.addMapper(CustomerMapper.class);
configuration.addMapper(CustomerAgreementMapper.class);
configuration.addMapper(CustomerOplogMapper.class);
sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
try (SqlSession s = sqlSessionFactory.openSession(true)) {
try (Statement st = s.getConnection().createStatement()) {
for (String sql : SCHEMA) {
st.execute(sql);
}
}
}
}
@BeforeEach
void setUp() throws Exception {
session = sqlSessionFactory.openSession(true);
try (Statement st = session.getConnection().createStatement()) {
st.execute("DELETE FROM customer");
st.execute("DELETE FROM customer_agreement");
st.execute("DELETE FROM customer_oplog");
}
customerMapper = session.getMapper(CustomerMapper.class);
agreementMapper = session.getMapper(CustomerAgreementMapper.class);
oplogMapper = session.getMapper(CustomerOplogMapper.class);
service = new CustomerAgreementServiceImpl(customerMapper,
new CustomerOplogWriter(oplogMapper, userService, deptService));
ReflectionTestUtils.setField(service, "baseMapper", agreementMapper);
ReflectionTestUtils.setField(service, "entityClass", CustomerAgreement.class);
securityMock = Mockito.mockStatic(SecurityUtils.class);
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(OPERATOR_ID));
when(userService.getById(anyLong())).thenAnswer(inv -> {
AuthUser u = new AuthUser();
u.setId(inv.getArgument(0));
u.setUsername("用户" + inv.getArgument(0));
u.setDeptId(DEPT_ID);
return u;
});
when(deptService.getDeptNames(anySet())).thenReturn(Map.of(DEPT_ID, "销售一部"));
}
@AfterEach
void tearDown() {
DataVisibilityContext.clear();
securityMock.close();
session.close();
}
@AfterAll
static void shutdown() {
// DB_CLOSE_DELAY=-1:JVM 退出即释放
}
// ==================== CRUD 正常路径 ====================
@Test
@DisplayName("新增成功:落库 + oplog AGREEMENT_ADD + 客户快照刷新为该等级")
void create_success_and_snapshot() {
Customer c = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
AgreementDTO dto = agreementDTO(c.getId(), 2, "120万元", "年度框架");
Long id = service.create(dto);
CustomerAgreement saved = agreementMapper.selectById(id);
assertThat(saved).isNotNull();
assertThat(saved.getCustomerId()).isEqualTo(c.getId());
assertThat(saved.getAgreementLevel()).isEqualTo(2);
assertThat(saved.getAmount()).isEqualTo("120万元");
assertThat(saved.getRemark()).isEqualTo("年度框架");
// 客户快照同步(原 NULL → 2)
assertThat(customerMapper.selectById(c.getId()).getStrategicAgreementLevel()).isEqualTo(2);
// oplog
List<CustomerOplog> logs = oplogMapper.selectList(null);
assertThat(logs).hasSize(1);
assertThat(logs.get(0).getAction()).isEqualTo(CustomerConstants.ACTION_AGREEMENT_ADD);
assertThat(logs.get(0).getDetail()).contains("二级");
}
@Test
@DisplayName("编辑成功:等级/金额更新 + 归属客户不可换 + 快照同步 + oplog AGREEMENT_EDIT")
void edit_success_keepCustomer() {
Customer c = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
Customer other = insertRaw("别家公司", OPERATOR_ID, DEPT_ID);
Long id = service.create(agreementDTO(c.getId(), 2, null, null));
AgreementDTO edit = agreementDTO(other.getId(), 3, "300万元", null); // customerId 携带别家 → 忽略
service.update(id, edit);
CustomerAgreement db = agreementMapper.selectById(id);
assertThat(db.getCustomerId()).isEqualTo(c.getId()); // 归属不可换
assertThat(db.getAgreementLevel()).isEqualTo(3);
assertThat(db.getAmount()).isEqualTo("300万元");
assertThat(customerMapper.selectById(c.getId()).getStrategicAgreementLevel()).isEqualTo(3);
List<CustomerOplog> logs = oplogMapper.selectList(null);
assertThat(logs).extracting(CustomerOplog::getAction)
.containsExactly(CustomerConstants.ACTION_AGREEMENT_ADD, CustomerConstants.ACTION_AGREEMENT_EDIT); // 主键自然序=发生序
assertThat(logs.get(1).getDetail()).contains("三级");
}
@Test
@DisplayName("删除:软删 + 快照回退(删最新 → 次新等级;删空 → NULL=未签)+ oplog AGREEMENT_DELETE")
void delete_snapshotFallback() throws Exception {
Customer c = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
Long olderId = service.create(agreementDTO(c.getId(), 2, null, null));
Long newerId = service.create(agreementDTO(c.getId(), 3, null, null));
// 显式错开 create_time,钉死「最新」语义(对齐 pageFollows 测试做法)
exec("UPDATE customer_agreement SET create_time = '2026-01-01 10:00:00' WHERE id = " + olderId);
exec("UPDATE customer_agreement SET create_time = '2026-02-01 10:00:00' WHERE id = " + newerId);
service.delete(newerId);
assertThat(agreementMapper.selectById(newerId)).isNull(); // @TableLogic 已删行自动过滤
assertThat(deletedFlag(newerId)).isEqualTo(1); // 软删位落库
assertThat(customerMapper.selectById(c.getId()).getStrategicAgreementLevel()).isEqualTo(2);
service.delete(olderId);
assertThat(customerMapper.selectById(c.getId()).getStrategicAgreementLevel()).isNull(); // 未签
List<CustomerOplog> logs = oplogMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CustomerOplog>()
.eq(CustomerOplog::getAction, CustomerConstants.ACTION_AGREEMENT_DELETE)
.orderByDesc(CustomerOplog::getId));
assertThat(logs).hasSize(2);
assertThat(logs).extracting(CustomerOplog::getDetail)
.containsExactly("删除战略协议【二级】", "删除战略协议【三级】"); // id 倒序=删除发生倒序(最后删的是二级)
}
@Test
@DisplayName("列表与详情:时间倒序 + 带出客户名")
void list_and_detail() {
Customer c = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
Long olderId = service.create(agreementDTO(c.getId(), 1, null, "旧协议"));
service.create(agreementDTO(c.getId(), 3, null, "新协议"));
exec("UPDATE customer_agreement SET create_time = '2026-01-01 10:00:00' WHERE id = " + olderId);
List<AgreementDTO> list = service.listByCustomer(c.getId());
assertThat(list).hasSize(2);
assertThat(list.get(0).getRemark()).isEqualTo("新协议"); // 时间倒序
assertThat(list.get(0).getCustomerName()).isEqualTo("云启科技有限公司");
AgreementDTO detail = service.detail(list.get(0).getId());
assertThat(detail.getAgreementLevel()).isEqualTo(3);
assertThat(detail.getCustomerName()).isEqualTo("云启科技有限公司");
}
// ==================== 校验 / 权限路径 ====================
@Test
@DisplayName("校验:所属客户缺失 67001 / 等级缺失或越界 67017 / 客户不存在 67002")
void create_validation() {
assertThatThrownBy(() -> service.create(agreementDTO(null, 2, null, null)))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_CUST_INVALID));
Customer c = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
assertThatThrownBy(() -> service.create(agreementDTO(c.getId(), null, null, null)))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_AGREEMENT_INVALID));
assertThatThrownBy(() -> service.create(agreementDTO(c.getId(), 4, null, null)))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_AGREEMENT_INVALID));
assertThatThrownBy(() -> service.create(agreementDTO(999999L, 2, null, null)))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_CUST_NOT_EXIST));
}
@Test
@DisplayName("协议不存在 67017 防探测;权限继承客户(SELF 对他人客户:页签 67002、其协议详情 67002)")
void notExist_and_visibilityInheritance() {
assertThatThrownBy(() -> service.detail(999999L))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_AGREEMENT_INVALID));
Customer other = insertRaw("他人客户有限公司", OTHER_OWNER_ID, OTHER_DEPT_ID);
CustomerAgreement hidden = new CustomerAgreement(); // mapper 直插他人客户名下协议
hidden.setCustomerId(other.getId());
hidden.setAgreementLevel(2);
agreementMapper.insert(hidden);
loadSelfScope();
try {
assertThatThrownBy(() -> service.listByCustomer(other.getId()))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_CUST_NOT_EXIST));
assertThatThrownBy(() -> service.detail(hidden.getId()))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_CUST_NOT_EXIST));
assertThatThrownBy(() -> service.delete(hidden.getId()))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(CustomerConstants.CODE_CUST_NOT_EXIST));
} finally {
DataVisibilityContext.clear();
}
}
// ==================== helpers ====================
private AgreementDTO agreementDTO(Long customerId, Integer level, String amount, String remark) {
AgreementDTO dto = new AgreementDTO();
dto.setCustomerId(customerId);
dto.setAgreementLevel(level);
dto.setAmount(amount);
dto.setRemark(remark);
return dto;
}
private Customer insertRaw(String name, Long ownerUserId, Long ownerDeptId) {
Customer c = new Customer();
c.setCustomerNo("KHA" + String.format("%04d", noSeq++));
c.setCustomerName(name);
c.setCustomerType("TYPE_A");
c.setCustomerStage(1);
c.setArchiveStatus(1);
c.setOwnerUserId(ownerUserId);
c.setOwnerDeptId(ownerDeptId);
c.setVersion(0);
customerMapper.insert(c);
return c;
}
private void loadSelfScope() {
DataVisibilityContext.load(new DataVisibility(OPERATOR_ID, DEPT_ID,
List.of(DEPT_ID), Map.of("customer", DataScopeLevel.SELF), null));
}
private void exec(String sql) {
try (Statement st = session.getConnection().createStatement()) {
st.execute(sql);
} catch (Exception e) {
throw new IllegalStateException("测试 SQL 执行失败:" + sql, e);
}
}
/** 原生读取软删位(绕过 @TableLogic 自动过滤,验证 deleted 落库值)。 */
private Integer deletedFlag(Long id) {
try (Statement st = session.getConnection().createStatement();
var rs = st.executeQuery("SELECT deleted FROM customer_agreement WHERE id = " + id)) {
assertThat(rs.next()).as("行应存在").isTrue();
return rs.getInt(1);
} catch (Exception e) {
throw new IllegalStateException("查询软删位失败:id=" + id, e);
}
}
private static final List<String> SCHEMA = List.of("""
CREATE TABLE IF NOT EXISTS customer (
id bigint primary key,
customer_no varchar(32),
customer_name varchar(100),
customer_type varchar(50),
province_code varchar(20),
city_code varchar(20),
district_code varchar(20),
industry_code varchar(50),
industry_child_code varchar(50),
customer_star_level tinyint,
relation_star_level tinyint,
network_units text,
owner_user_id bigint,
owner_user_name_snapshot varchar(64),
owner_dept_id bigint,
owner_dept_name_snapshot varchar(64),
enter_pool_time datetime,
customer_stage tinyint default 1,
archive_status tinyint default 1,
last_valid_follow_time datetime,
strategic_agreement_level tinyint,
vip_customer_level tinyint,
company_decision_maker varchar(100),
is_biz_negotiated tinyint,
director_visit_time datetime,
is_child tinyint default 0,
parent_customer_id bigint,
parent_customer_name_snapshot varchar(100),
cooperation_system varchar(200),
non_coop_reason varchar(200),
joined_president_class tinyint,
president_class_person varchar(100),
president_class_phone varchar(20),
joined_product_class tinyint,
product_class_person varchar(100),
product_class_phone varchar(20),
unified_credit_code varchar(18),
legal_representative varchar(100),
established_date date,
registered_capital varchar(100),
business_scope text,
staff_size varchar(50),
annual_revenue varchar(50),
business_address varchar(500),
biz_name_from_lookup varchar(200),
remark varchar(500),
version int default 0,
creator_id varchar(64),
create_time datetime,
updater_id varchar(64),
update_time datetime,
deleted tinyint default 0
)
""", """
CREATE TABLE IF NOT EXISTS customer_agreement (
id bigint primary key,
customer_id bigint not null,
agreement_level tinyint not null,
amount varchar(64),
remark varchar(500),
file_file_id varchar(64),
creator_id varchar(64),
create_time datetime,
updater_id varchar(64),
update_time datetime,
deleted tinyint default 0
)
""", """
CREATE TABLE IF NOT EXISTS customer_oplog (
id bigint primary key,
customer_id bigint,
action varchar(50),
detail varchar(1000),
operator_id bigint,
operator_name varchar(50),
operator_dept_name varchar(100),
creator_id varchar(64),
create_time datetime,
updater_id varchar(64),
update_time datetime,
deleted tinyint default 0
)
""");
}

32
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerContactServiceIntegrationTest.java

@ -24,6 +24,7 @@ import com.crm.customer.domain.dto.CustomerSaveResultDTO;
import com.crm.customer.domain.dto.CustomerSearchItemDTO; import com.crm.customer.domain.dto.CustomerSearchItemDTO;
import com.crm.customer.domain.entity.Customer; import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerContact; import com.crm.customer.domain.entity.CustomerContact;
import com.crm.customer.domain.entity.CustomerOplog;
import com.crm.customer.domain.param.ContactPageParam; import com.crm.customer.domain.param.ContactPageParam;
import com.crm.customer.domain.param.CustomerSearchParam; import com.crm.customer.domain.param.CustomerSearchParam;
import com.crm.customer.mapper.CustomerContactMapper; import com.crm.customer.mapper.CustomerContactMapper;
@ -152,7 +153,8 @@ class CustomerContactServiceIntegrationTest {
oplogMapper = session.getMapper(CustomerOplogMapper.class); oplogMapper = session.getMapper(CustomerOplogMapper.class);
CustomerDedupSettingProvider settings = new DefaultCustomerDedupSettingProvider(); CustomerDedupSettingProvider settings = new DefaultCustomerDedupSettingProvider();
CustomerContactServiceImpl impl = new CustomerContactServiceImpl(customerMapper, settings); CustomerContactServiceImpl impl = new CustomerContactServiceImpl(customerMapper, settings,
new CustomerOplogWriter(oplogMapper, userService, deptService));
ReflectionTestUtils.setField(impl, "baseMapper", contactMapper); ReflectionTestUtils.setField(impl, "baseMapper", contactMapper);
ReflectionTestUtils.setField(impl, "entityClass", CustomerContact.class); ReflectionTestUtils.setField(impl, "entityClass", CustomerContact.class);
contactService = impl; contactService = impl;
@ -162,7 +164,7 @@ class CustomerContactServiceIntegrationTest {
CustomerDedupService dedup = new CustomerDedupService(customerMapper, port, settings, userService); CustomerDedupService dedup = new CustomerDedupService(customerMapper, port, settings, userService);
CustomerServiceImpl custImpl = new CustomerServiceImpl( CustomerServiceImpl custImpl = new CustomerServiceImpl(
new CustomerOplogWriter(oplogMapper, userService, deptService), new CustomerOplogWriter(oplogMapper, userService, deptService),
dedup, userService, deptService, dictQueryService); dedup, userService, deptService, dictQueryService, name -> List.of());
ReflectionTestUtils.setField(custImpl, "baseMapper", customerMapper); ReflectionTestUtils.setField(custImpl, "baseMapper", customerMapper);
ReflectionTestUtils.setField(custImpl, "entityClass", Customer.class); ReflectionTestUtils.setField(custImpl, "entityClass", Customer.class);
customerService = custImpl; customerService = custImpl;
@ -436,12 +438,36 @@ class CustomerContactServiceIntegrationTest {
@Override public boolean creditCodeEnabled() { return true; } @Override public boolean creditCodeEnabled() { return true; }
@Override public boolean phoneSoftCheckEnabled() { return false; } @Override public boolean phoneSoftCheckEnabled() { return false; }
}; };
CustomerContactServiceImpl svc = new CustomerContactServiceImpl(customerMapper, off); CustomerContactServiceImpl svc = new CustomerContactServiceImpl(customerMapper, off,
new CustomerOplogWriter(oplogMapper, userService, deptService));
ReflectionTestUtils.setField(svc, "baseMapper", contactMapper); ReflectionTestUtils.setField(svc, "baseMapper", contactMapper);
ReflectionTestUtils.setField(svc, "entityClass", CustomerContact.class); ReflectionTestUtils.setField(svc, "entityClass", CustomerContact.class);
assertThat(svc.checkPhone("13800001111", null)).isEmpty(); assertThat(svc.checkPhone("13800001111", null)).isEmpty();
} }
// ==================== 操作日志(返工票 09-F10 / a4-5-6) ====================
@Test
@DisplayName("增删改写客户 oplog:CONTACT_ADD/EDIT/DELETE 记 customerId + 联系人名 detail")
void oplog_contactActions() {
Customer cust = insertRaw("云启科技有限公司", OPERATOR_ID, DEPT_ID);
Long id = contactService.create(contactDTO(cust.getId(), "王经理", "采购总监", "13800001111"));
ContactDTO edit = contactDTO(cust.getId(), "王经理", "采购总监", "13800001111");
edit.setName("王建国");
contactService.update(id, edit);
contactService.delete(id);
List<CustomerOplog> logs = oplogMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CustomerOplog>()
.eq(CustomerOplog::getCustomerId, cust.getId())
.orderByAsc(CustomerOplog::getId));
assertThat(logs).extracting(CustomerOplog::getAction)
.containsExactly("CONTACT_ADD", "CONTACT_EDIT", "CONTACT_DELETE");
assertThat(logs.get(0).getDetail()).contains("王经理").contains("新增联系人");
assertThat(logs.get(1).getDetail()).contains("王建国").contains("修改联系人");
assertThat(logs.get(2).getDetail()).contains("王建国").contains("删除联系人");
}
// ==================== 商机侧硬依赖(CustomerService,票 03 契约) ==================== // ==================== 商机侧硬依赖(CustomerService,票 03 契约) ====================
@Test @Test

15
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerDetailMembersIntegrationTest.java

@ -24,6 +24,7 @@ import com.crm.customer.mapper.CustomerFollowMapper;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.mapper.CustomerOplogMapper; import com.crm.customer.mapper.CustomerOplogMapper;
import com.crm.customer.mapper.CustomerTeamMemberMapper; import com.crm.customer.mapper.CustomerTeamMemberMapper;
import com.crm.customer.mapper.CustomerViewLogMapper;
import com.crm.customer.port.CustomerOpportunityQueryPort; import com.crm.customer.port.CustomerOpportunityQueryPort;
import com.crm.customer.service.CustomerOplogWriter; import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.ICustomerDetailService; import com.crm.customer.service.ICustomerDetailService;
@ -80,6 +81,7 @@ class CustomerDetailMembersIntegrationTest {
private CustomerFollowMapper followMapper; private CustomerFollowMapper followMapper;
private CustomerTeamMemberMapper teamMemberMapper; private CustomerTeamMemberMapper teamMemberMapper;
private CustomerOplogMapper oplogMapper; private CustomerOplogMapper oplogMapper;
private CustomerViewLogMapper viewLogMapper;
private ICustomerDetailService detailService; private ICustomerDetailService detailService;
private MockedStatic<SecurityUtils> securityMock; private MockedStatic<SecurityUtils> securityMock;
private int noSeq = 1; private int noSeq = 1;
@ -112,6 +114,7 @@ class CustomerDetailMembersIntegrationTest {
configuration.addMapper(CustomerFollowMapper.class); configuration.addMapper(CustomerFollowMapper.class);
configuration.addMapper(CustomerTeamMemberMapper.class); configuration.addMapper(CustomerTeamMemberMapper.class);
configuration.addMapper(CustomerOplogMapper.class); configuration.addMapper(CustomerOplogMapper.class);
configuration.addMapper(CustomerViewLogMapper.class);
sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
try (SqlSession s = sqlSessionFactory.openSession(true)) { try (SqlSession s = sqlSessionFactory.openSession(true)) {
@ -130,15 +133,17 @@ class CustomerDetailMembersIntegrationTest {
st.execute("DELETE FROM customer"); st.execute("DELETE FROM customer");
st.execute("DELETE FROM customer_team_member"); st.execute("DELETE FROM customer_team_member");
st.execute("DELETE FROM customer_oplog"); st.execute("DELETE FROM customer_oplog");
st.execute("DELETE FROM customer_view_log");
} }
customerMapper = session.getMapper(CustomerMapper.class); customerMapper = session.getMapper(CustomerMapper.class);
followMapper = session.getMapper(CustomerFollowMapper.class); followMapper = session.getMapper(CustomerFollowMapper.class);
teamMemberMapper = session.getMapper(CustomerTeamMemberMapper.class); teamMemberMapper = session.getMapper(CustomerTeamMemberMapper.class);
oplogMapper = session.getMapper(CustomerOplogMapper.class); oplogMapper = session.getMapper(CustomerOplogMapper.class);
viewLogMapper = session.getMapper(CustomerViewLogMapper.class);
CustomerOplogWriter writer = new CustomerOplogWriter(oplogMapper, userService, deptService); CustomerOplogWriter writer = new CustomerOplogWriter(oplogMapper, userService, deptService);
detailService = new CustomerDetailServiceImpl(customerMapper, followMapper, detailService = new CustomerDetailServiceImpl(customerMapper, followMapper,
teamMemberMapper, oplogMapper, new NoopPort(), userService, deptService, writer); teamMemberMapper, oplogMapper, viewLogMapper, new NoopPort(), userService, deptService, writer);
securityMock = Mockito.mockStatic(SecurityUtils.class); securityMock = Mockito.mockStatic(SecurityUtils.class);
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(OPERATOR_ID)); securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(OPERATOR_ID));
@ -433,5 +438,13 @@ class CustomerDetailMembersIntegrationTest {
update_time datetime, update_time datetime,
deleted tinyint default 0 deleted tinyint default 0
) )
""", """
CREATE TABLE IF NOT EXISTS customer_view_log (
id bigint primary key,
user_id bigint not null,
customer_id bigint not null,
last_view_time datetime not null,
UNIQUE (user_id, customer_id)
)
"""); """);
} }

38
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerFollowDetailIntegrationTest.java

@ -23,10 +23,12 @@ import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerFollow; import com.crm.customer.domain.entity.CustomerFollow;
import com.crm.customer.domain.entity.CustomerOplog; import com.crm.customer.domain.entity.CustomerOplog;
import com.crm.customer.domain.entity.CustomerTeamMember; import com.crm.customer.domain.entity.CustomerTeamMember;
import com.crm.customer.domain.entity.CustomerViewLog;
import com.crm.customer.mapper.CustomerFollowMapper; import com.crm.customer.mapper.CustomerFollowMapper;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.mapper.CustomerOplogMapper; import com.crm.customer.mapper.CustomerOplogMapper;
import com.crm.customer.mapper.CustomerTeamMemberMapper; import com.crm.customer.mapper.CustomerTeamMemberMapper;
import com.crm.customer.mapper.CustomerViewLogMapper;
import com.crm.customer.port.CustomerOpportunityQueryPort; import com.crm.customer.port.CustomerOpportunityQueryPort;
import com.crm.customer.service.CustomerOplogWriter; import com.crm.customer.service.CustomerOplogWriter;
import com.crm.customer.service.ICustomerDetailService; import com.crm.customer.service.ICustomerDetailService;
@ -85,6 +87,7 @@ class CustomerFollowDetailIntegrationTest {
private CustomerFollowMapper followMapper; private CustomerFollowMapper followMapper;
private CustomerTeamMemberMapper teamMemberMapper; private CustomerTeamMemberMapper teamMemberMapper;
private CustomerOplogMapper oplogMapper; private CustomerOplogMapper oplogMapper;
private CustomerViewLogMapper viewLogMapper;
private ICustomerFollowService followService; private ICustomerFollowService followService;
private ICustomerDetailService detailService; private ICustomerDetailService detailService;
private CustomerOplogWriter writer; private CustomerOplogWriter writer;
@ -119,6 +122,7 @@ class CustomerFollowDetailIntegrationTest {
configuration.addMapper(CustomerFollowMapper.class); configuration.addMapper(CustomerFollowMapper.class);
configuration.addMapper(CustomerTeamMemberMapper.class); configuration.addMapper(CustomerTeamMemberMapper.class);
configuration.addMapper(CustomerOplogMapper.class); configuration.addMapper(CustomerOplogMapper.class);
configuration.addMapper(CustomerViewLogMapper.class);
sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
try (SqlSession s = sqlSessionFactory.openSession(true)) { try (SqlSession s = sqlSessionFactory.openSession(true)) {
@ -138,11 +142,13 @@ class CustomerFollowDetailIntegrationTest {
st.execute("DELETE FROM customer_follow"); st.execute("DELETE FROM customer_follow");
st.execute("DELETE FROM customer_team_member"); st.execute("DELETE FROM customer_team_member");
st.execute("DELETE FROM customer_oplog"); st.execute("DELETE FROM customer_oplog");
st.execute("DELETE FROM customer_view_log");
} }
customerMapper = session.getMapper(CustomerMapper.class); customerMapper = session.getMapper(CustomerMapper.class);
followMapper = session.getMapper(CustomerFollowMapper.class); followMapper = session.getMapper(CustomerFollowMapper.class);
teamMemberMapper = session.getMapper(CustomerTeamMemberMapper.class); teamMemberMapper = session.getMapper(CustomerTeamMemberMapper.class);
oplogMapper = session.getMapper(CustomerOplogMapper.class); oplogMapper = session.getMapper(CustomerOplogMapper.class);
viewLogMapper = session.getMapper(CustomerViewLogMapper.class);
writer = new CustomerOplogWriter(oplogMapper, userService, deptService); writer = new CustomerOplogWriter(oplogMapper, userService, deptService);
followService = new CustomerFollowServiceImpl(customerMapper, userService, deptService, writer); followService = new CustomerFollowServiceImpl(customerMapper, userService, deptService, writer);
@ -150,7 +156,7 @@ class CustomerFollowDetailIntegrationTest {
ReflectionTestUtils.setField(followService, "entityClass", CustomerFollow.class); ReflectionTestUtils.setField(followService, "entityClass", CustomerFollow.class);
detailService = new CustomerDetailServiceImpl(customerMapper, followMapper, teamMemberMapper, detailService = new CustomerDetailServiceImpl(customerMapper, followMapper, teamMemberMapper,
oplogMapper, new NoopPort(), userService, deptService, writer); oplogMapper, viewLogMapper, new NoopPort(), userService, deptService, writer);
securityMock = Mockito.mockStatic(SecurityUtils.class); securityMock = Mockito.mockStatic(SecurityUtils.class);
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(OPERATOR_ID)); securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(OPERATOR_ID));
@ -296,7 +302,7 @@ class CustomerFollowDetailIntegrationTest {
// port 异常 → 计数降级 null(前端显 "--"),头部不失败 // port 异常 → 计数降级 null(前端显 "--"),头部不失败
ICustomerDetailService degraded = new CustomerDetailServiceImpl(customerMapper, followMapper, ICustomerDetailService degraded = new CustomerDetailServiceImpl(customerMapper, followMapper,
teamMemberMapper, oplogMapper, new CustomerOpportunityQueryPort() { teamMemberMapper, oplogMapper, viewLogMapper, new CustomerOpportunityQueryPort() {
@Override @Override
public PageResult<CustomerOpportunityQueryPort.OpportunityItem> pageByCustomerId( public PageResult<CustomerOpportunityQueryPort.OpportunityItem> pageByCustomerId(
Long customerId, BaseParam param) { Long customerId, BaseParam param) {
@ -381,6 +387,26 @@ class CustomerFollowDetailIntegrationTest {
assertThat(page.getTotal()).isZero(); assertThat(page.getTotal()).isZero();
} }
@Test
@DisplayName("详情触达(F7):detailHead upsert customer_view_log——已有行刷新锚点、新客户首访插入")
void detailHead_touchViewLog() {
Customer c = newCust("客户甲");
LocalDateTime first = LocalDateTime.now().minusMinutes(1);
exec("INSERT INTO customer_view_log (id, user_id, customer_id, last_view_time) "
+ "VALUES (770001, " + OPERATOR_ID + ", " + c.getId() + ", '" + first + "')");
detailService.detailHead(c.getId());
List<CustomerViewLog> rows = viewLogMapper.selectList(null);
assertThat(rows).hasSize(1); // UNIQUE(user_id, customer_id) 去重,upsert 不新增行
assertThat(rows.get(0).getUserId()).isEqualTo(OPERATOR_ID);
assertThat(rows.get(0).getLastViewTime()).isAfter(first);
Customer other = newCust("客户乙");
detailService.detailHead(other.getId()); // 首访 → insert 第二行
assertThat(viewLogMapper.selectList(null)).hasSize(2);
}
// ==================== helpers ==================== // ==================== helpers ====================
private FollowCreateDTO followOf(String content) { private FollowCreateDTO followOf(String content) {
@ -535,5 +561,13 @@ class CustomerFollowDetailIntegrationTest {
update_time datetime, update_time datetime,
deleted tinyint default 0 deleted tinyint default 0
) )
""", """
CREATE TABLE IF NOT EXISTS customer_view_log (
id bigint primary key,
user_id bigint not null,
customer_id bigint not null,
last_view_time datetime not null,
UNIQUE (user_id, customer_id)
)
"""); """);
} }

54
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerImportIntegrationTest.java

@ -322,8 +322,12 @@ class CustomerImportIntegrationTest {
} }
private ImportPreviewDTO upload(byte[] bytes, String importMode) { private ImportPreviewDTO upload(byte[] bytes, String importMode) {
return upload(bytes, importMode, null);
}
private ImportPreviewDTO upload(byte[] bytes, String importMode, String duplicateStrategy) {
MockMultipartFile file = new MockMultipartFile("file", "import.xlsx", XLSX_TYPE, bytes); MockMultipartFile file = new MockMultipartFile("file", "import.xlsx", XLSX_TYPE, bytes);
return importService.upload(file, importMode); return importService.upload(file, importMode, duplicateStrategy);
} }
private ImportPreviewDTO uploadStandard(List<List<Object>> customerRows, List<List<Object>> contactRows, private ImportPreviewDTO uploadStandard(List<List<Object>> customerRows, List<List<Object>> contactRows,
@ -332,6 +336,12 @@ class CustomerImportIntegrationTest {
importMode); importMode);
} }
private ImportPreviewDTO uploadStandard(List<List<Object>> customerRows, List<List<Object>> contactRows,
String importMode, String duplicateStrategy) {
return upload(excel(headOf(CustomerImportServiceImpl.CUSTOMER_HEADERS), customerRows, contactRows),
importMode, duplicateStrategy);
}
private Long singleTaskId() { private Long singleTaskId() {
return taskMapper.selectList(new LambdaQueryWrapper<>()).get(0).getId(); return taskMapper.selectList(new LambdaQueryWrapper<>()).get(0).getId();
} }
@ -450,6 +460,47 @@ class CustomerImportIntegrationTest {
.containsOnly("文件内多行匹配同一客户(重复时不导入),请合并为单行"); .containsOnly("文件内多行匹配同一客户(重复时不导入),请合并为单行");
} }
@Test
@DisplayName("upload OVERWRITE:组内仅最后一条通过校验的行生效,confirm 后按此执行(返工票 09-F8)")
void upload_duplicateGroupOverwriteLastWins() {
newCust("KH0001", "客户甲", null, "旧备注", 3);
ImportPreviewDTO preview = uploadStandard(
rowsOf(
row("KH0001", "客户甲一", null, null, null, null, null, null, "改一"),
row("KH0001", "客户甲二", null, null, null, null, null, null, "改二")),
List.of(), CustomerConstants.IMPORT_MODE_UPSERT,
CustomerConstants.IMPORT_DUP_OVERWRITE);
assertThat(preview.getDuplicateStrategy()).isEqualTo(CustomerConstants.IMPORT_DUP_OVERWRITE);
assertThat(preview.getUpdateCount()).isEqualTo(1);
assertThat(preview.getFailCount()).isEqualTo(1);
assertThat(preview.getRows()).extracting(ImportPreviewDTO.Row::getReason)
.containsOnlyOnce("文件内多行匹配同一客户(覆盖导入:仅最后一条通过校验的行生效),本行未执行");
Long taskId = singleTaskId();
importService.confirm(taskId);
CustomerImportTask task = taskMapper.selectById(taskId);
assertThat(task.getDuplicateStrategy()).isEqualTo(CustomerConstants.IMPORT_DUP_OVERWRITE);
assertThat(task.getStatus()).isEqualTo(CustomerConstants.IMPORT_STATUS_DONE);
assertThat(task.getUpdateCount()).isEqualTo(1);
assertThat(task.getFailCount()).isEqualTo(1);
// 最后一条生效:备注=改二
assertThat(customerMapper.selectOne(new LambdaQueryWrapper<Customer>()
.eq(Customer::getCustomerNo, "KH0001")).getRemark()).isEqualTo("改二");
assertThat(failMapper.selectCount(new LambdaQueryWrapper<CustomerImportFail>()
.eq(CustomerImportFail::getTaskId, taskId))).isEqualTo(1);
}
@Test
@DisplayName("upload 重复策略非法 → 67013")
void upload_illegalDuplicateStrategy() {
assertCode(() -> uploadStandard(List.of(
row(null, "新客户甲", null, null, null, null, null, "3", null)),
List.of(), CustomerConstants.IMPORT_MODE_UPSERT, "MERGE"), 67013);
}
// ==================== 更新语义(issues-09 §C) ==================== // ==================== 更新语义(issues-09 §C) ====================
@Test @Test
@ -806,6 +857,7 @@ class CustomerImportIntegrationTest {
CREATE TABLE IF NOT EXISTS customer_import_task ( CREATE TABLE IF NOT EXISTS customer_import_task (
id bigint primary key, id bigint primary key,
import_mode varchar(20) not null, import_mode varchar(20) not null,
duplicate_strategy varchar(20) not null default 'SKIP',
template_version varchar(10) not null, template_version varchar(10) not null,
file_file_id varchar(64), file_file_id varchar(64),
file_hash varchar(64), file_hash varchar(64),

78
crm-customer/src/test/java/com/crm/customer/service/impl/CustomerServiceImplIntegrationTest.java

@ -19,10 +19,12 @@ import com.crm.base.security.SecurityUtils;
import com.crm.customer.domain.dto.CustomerCreateDTO; import com.crm.customer.domain.dto.CustomerCreateDTO;
import com.crm.customer.domain.dto.CustomerSaveResultDTO; import com.crm.customer.domain.dto.CustomerSaveResultDTO;
import com.crm.customer.domain.dto.CustomerUpdateDTO; import com.crm.customer.domain.dto.CustomerUpdateDTO;
import com.crm.customer.domain.dto.CompanyBatchUpdateDTO;
import com.crm.customer.domain.entity.Customer; import com.crm.customer.domain.entity.Customer;
import com.crm.customer.domain.entity.CustomerOplog; import com.crm.customer.domain.entity.CustomerOplog;
import com.crm.customer.mapper.CustomerMapper; import com.crm.customer.mapper.CustomerMapper;
import com.crm.customer.mapper.CustomerOplogMapper; import com.crm.customer.mapper.CustomerOplogMapper;
import com.crm.customer.port.CompanyLookupPort;
import com.crm.customer.port.impl.NgramNameSimilarityPortImpl; import com.crm.customer.port.impl.NgramNameSimilarityPortImpl;
import com.crm.customer.service.CustomerDedupService; import com.crm.customer.service.CustomerDedupService;
import com.crm.customer.service.CustomerDedupSettingProvider; import com.crm.customer.service.CustomerDedupSettingProvider;
@ -30,6 +32,7 @@ import com.crm.customer.service.CustomerNameMatchMode;
import com.crm.customer.service.CustomerOplogWriter; import com.crm.customer.service.CustomerOplogWriter;
import com.crm.dict.service.DictQueryService; import com.crm.dict.service.DictQueryService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.LocalCacheScope; import org.apache.ibatis.session.LocalCacheScope;
import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSession;
@ -89,6 +92,8 @@ class CustomerServiceImplIntegrationTest {
private final IAuthUserService userService = mock(IAuthUserService.class); private final IAuthUserService userService = mock(IAuthUserService.class);
private final ISysDeptService deptService = mock(ISysDeptService.class); private final ISysDeptService deptService = mock(ISysDeptService.class);
private final DictQueryService dictQueryService = mock(DictQueryService.class); private final DictQueryService dictQueryService = mock(DictQueryService.class);
private final com.crm.customer.port.CompanyLookupPort companyLookupPort =
mock(com.crm.customer.port.CompanyLookupPort.class);
@BeforeAll @BeforeAll
static void bootstrap() throws Exception { static void bootstrap() throws Exception {
@ -139,7 +144,7 @@ class CustomerServiceImplIntegrationTest {
NgramNameSimilarityPortImpl port = new NgramNameSimilarityPortImpl(customerMapper, settings, DS); NgramNameSimilarityPortImpl port = new NgramNameSimilarityPortImpl(customerMapper, settings, DS);
CustomerDedupService dedup = new CustomerDedupService(customerMapper, port, settings, userService); CustomerDedupService dedup = new CustomerDedupService(customerMapper, port, settings, userService);
service = new CustomerServiceImpl(new CustomerOplogWriter(oplogMapper, userService, deptService), service = new CustomerServiceImpl(new CustomerOplogWriter(oplogMapper, userService, deptService),
dedup, userService, deptService, dictQueryService); dedup, userService, deptService, dictQueryService, companyLookupPort);
ReflectionTestUtils.setField(service, "baseMapper", customerMapper); ReflectionTestUtils.setField(service, "baseMapper", customerMapper);
ReflectionTestUtils.setField(service, "entityClass", Customer.class); ReflectionTestUtils.setField(service, "entityClass", Customer.class);
@ -281,6 +286,77 @@ class CustomerServiceImplIntegrationTest {
e -> assertThat(e.getCode()).isEqualTo(67001)); e -> assertThat(e.getCode()).isEqualTo(67001));
} }
// ==================== 批量工商更新(返工票 09-F6 / a4-3-1) ====================
@Test
@DisplayName("批量工商更新:命中候选只回填空缺字段 + oplog,行级 UPDATED")
void batchCompanyUpdate_fillsBlankFields() {
Customer c = insertRaw("云启科技有限公司", null);
when(companyLookupPort.lookup("云启科技有限公司")).thenReturn(List.of(
new CompanyLookupPort.CompanyCandidate("云启科技有限公司(沪)", "91310000MA1FL0000X",
"张三", LocalDate.of(2015, 3, 1), "1000万人民币", "软件开发", "50-99人",
"5000万", "上海市浦东新区")));
List<CompanyBatchUpdateDTO> results = service.batchCompanyUpdate(List.of(c.getId()));
assertThat(results).hasSize(1);
CompanyBatchUpdateDTO r = results.get(0);
assertThat(r.getStatus()).isEqualTo(CompanyBatchUpdateDTO.UPDATED);
assertThat(r.getFilledFields()).isEqualTo(9);
Customer after = customerMapper.selectById(c.getId());
assertThat(after.getBizNameFromLookup()).isEqualTo("云启科技有限公司(沪)");
assertThat(after.getLegalRepresentative()).isEqualTo("张三");
assertThat(after.getBusinessAddress()).isEqualTo("上海市浦东新区");
List<CustomerOplog> logs = oplogMapper.selectList(new LambdaQueryWrapper<CustomerOplog>()
.eq(CustomerOplog::getCustomerId, c.getId())
.eq(CustomerOplog::getAction, "UPDATE"));
assertThat(logs).hasSize(1);
assertThat(logs.get(0).getDetail()).contains("批量更新工商信息").contains("9");
}
@Test
@DisplayName("批量工商更新:已填字段不覆盖(§C2 只补空缺)→ SKIPPED 无 oplog")
void batchCompanyUpdate_skipsWhenFilled() {
Customer c = insertRaw("云启科技有限公司", null);
customerMapper.update(c, new LambdaUpdateWrapper<Customer>()
.eq(Customer::getId, c.getId())
.set(Customer::getLegalRepresentative, "已有法人"));
when(companyLookupPort.lookup("云启科技有限公司")).thenReturn(List.of(
new CompanyLookupPort.CompanyCandidate(null, null,
"张三", null, null, null, null, null, null)));
CompanyBatchUpdateDTO r = service.batchCompanyUpdate(List.of(c.getId())).get(0);
assertThat(r.getStatus()).isEqualTo(CompanyBatchUpdateDTO.SKIPPED);
assertThat(customerMapper.selectById(c.getId()).getLegalRepresentative()).isEqualTo("已有法人");
assertThat(customerMapper.selectById(c.getId()).getBizNameFromLookup()).isNull();
}
@Test
@DisplayName("批量工商更新:工商源未命中 → NO_HIT;命中信用代码被他人占用 → CONFLICT 不写")
void batchCompanyUpdate_noHitAndConflict() {
Customer c = insertRaw("云启科技有限公司", null);
Customer other = insertRaw("已有公司", "91310000MA1FL0000X");
when(companyLookupPort.lookup("云启科技有限公司")).thenReturn(List.of());
assertThat(service.batchCompanyUpdate(List.of(c.getId())).get(0).getStatus())
.isEqualTo(CompanyBatchUpdateDTO.NO_HIT);
when(companyLookupPort.lookup("云启科技有限公司")).thenReturn(List.of(
new CompanyLookupPort.CompanyCandidate("云启科技有限公司(沪)", "91310000MA1FL0000X",
null, null, null, null, null, null, null)));
CompanyBatchUpdateDTO conflict = service.batchCompanyUpdate(List.of(c.getId())).get(0);
assertThat(conflict.getStatus()).isEqualTo(CompanyBatchUpdateDTO.CONFLICT);
assertThat(customerMapper.selectById(c.getId()).getUnifiedCreditCode()).isNull();
}
@Test
@DisplayName("批量工商更新:ids 空 → 67001")
void batchCompanyUpdate_emptyIds() {
assertThatThrownBy(() -> service.batchCompanyUpdate(List.of()))
.isInstanceOfSatisfying(BusinessErrorException.class,
e -> assertThat(e.getCode()).isEqualTo(67001));
}
// ==================== 编辑 ==================== // ==================== 编辑 ====================
@Test @Test

3758
logs/crm-app.log

File diff suppressed because it is too large

BIN
logs/crm-app.log.2026-09-04.0.gz

Binary file not shown.
Loading…
Cancel
Save