46 changed files with 7154 additions and 3399 deletions
@ -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 收官。 |
||||
@ -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 记档) |
||||
@ -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 |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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": "工商源未查询到匹配企业" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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": "示例客户" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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": "示例客户" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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" |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
``` |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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() |
||||
@ -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) |
||||
@ -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 客户管理\`)。 |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
|
||||
|
/** |
||||
|
* 战略协议双向 DTO(ADR-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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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_* 先例):增删改写客户 oplog,detail 记等级文案。</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 -> "未知"; |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
) |
||||
|
"""); |
||||
|
} |
||||
File diff suppressed because it is too large
Binary file not shown.
Loading…
Reference in new issue