Browse Source

commit

master
luoweijian 5 days ago
parent
commit
2329f8cf14
  1. 29
      .scratch/customer-module/issues/01-domain-context.md
  2. 42
      .scratch/customer-module/issues/02-customer-main-table.md
  3. 41
      .scratch/customer-module/issues/03-ownership-views.md
  4. 39
      .scratch/customer-module/issues/04-create-dedup-and-company-lookup.md
  5. 36
      .scratch/customer-module/issues/05-customer-stage.md
  6. 57
      .scratch/customer-module/issues/06-contacts-crud.md
  7. 53
      .scratch/customer-module/issues/07-detail-follow-oplog.md
  8. 47
      .scratch/customer-module/issues/08-transfer.md
  9. 36
      .scratch/customer-module/issues/09-import.md
  10. 42
      .scratch/customer-module/issues/10-overdue-reminder-rule.md
  11. 37
      .scratch/customer-module/issues/11-saved-view-integration.md
  12. 78
      .scratch/customer-module/map.md
  13. 50
      .scratch/opp-customer-dependency/demo-addcustomer.js
  14. BIN
      .scratch/opp-customer-dependency/demo04-1-customer-subtab.png
  15. BIN
      .scratch/opp-customer-dependency/demo04-2-dialog-open.png
  16. BIN
      .scratch/opp-customer-dependency/demo04-3-search-results.png
  17. BIN
      .scratch/opp-customer-dependency/demo04-4-selected-cascade.png
  18. BIN
      .scratch/opp-customer-dependency/demo04-5-after-add.png
  19. 84
      .scratch/opp-customer-dependency/drive-demo04.py
  20. 16
      .scratch/opp-customer-dependency/issues/01-customer-type-dict-seed.md
  21. 21
      .scratch/opp-customer-dependency/issues/02-customer-contacts-stub.md
  22. 29
      .scratch/opp-customer-dependency/issues/03-frontend-integration-regression.md
  23. 35
      .scratch/opp-customer-dependency/issues/04-demo-frontend-acceptance.md
  24. 60
      .scratch/opp-customer-dependency/verify-03-result.json
  25. 50
      .scratch/opp-customer-dependency/verify-03.py
  26. 95
      .scratch/opp-customer-dependency/wayfinder/商机前端联调工作量评估.md
  27. BIN
      .scratch/opportunity-acceptance/boot-run.log
  28. 13
      .scratch/opportunity-board-display-name/issues/01-stage-template-display-name-unique.md
  29. 12
      .scratch/opportunity-board-display-name/issues/02-stage-display-name-resolution-contract.md
  30. 13
      .scratch/opportunity-board-display-name/issues/03-board-drop-status-filter.md
  31. 15
      .scratch/opportunity-board-display-name/issues/04-board-group-by-display-name.md
  32. 14
      .scratch/opportunity-board-display-name/issues/05-board-integration-tests.md
  33. 12
      .scratch/opportunity-board-display-name/issues/06-board-api-docs.md
  34. 12
      .scratch/opportunity-board-display-name/issues/07-code-review.md
  35. 157
      .scratch/opportunity-e2e/demo/index.html
  36. BIN
      .scratch/opportunity-e2e/demo/shot-12-09-lanes.png
  37. BIN
      .scratch/opportunity-e2e/demo/shot-12-10-lanes-more.png
  38. BIN
      .scratch/opportunity-e2e/demo/shot-12-11-create-dialog.png
  39. BIN
      .scratch/opportunity-e2e/demo/shot-12-12-cust-search.png
  40. BIN
      .scratch/opportunity-e2e/demo/shot-12-13-create-result.png
  41. BIN
      .scratch/opportunity-e2e/demo/shot-12-14-detail-after-create.png
  42. BIN
      .scratch/opportunity-e2e/demo/shot-12-15-testenv-mode.png
  43. 53
      .scratch/opportunity-e2e/issues/12-demo-sync-opp-create-fields.md
  44. 2
      .scratch/opportunity-e2e/map.md
  45. 142
      .scratch/opportunity-e2e/selftest-12.py
  46. 3
      .scratch/patch_board_stagenode.md
  47. 3
      .scratch/patch_board_viewform.md
  48. 5
      crm-base/src/main/java/com/crm/base/config/MybatisPlusConfig.java
  49. 10
      crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
  50. 22
      crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java
  51. 6
      crm-opportunity/CONTEXT.md
  52. 7
      crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java
  53. 31
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CustomerContactDTO.java
  54. 14
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java
  55. 4
      crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java
  56. 9
      crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java
  57. 17
      docs/adr/0032-board-groups-by-stage-display-name.md
  58. 189
      logs/crm-app-boot.log
  59. 1224
      logs/crm-app.log
  60. BIN
      logs/crm-app.log.2026-08-31.0.gz

29
.scratch/customer-module/issues/01-domain-context.md

@ -0,0 +1,29 @@
# 01 — 客户域领域词汇 + crm-customer/CONTEXT.md 骨架
Type: task
Status: open
Blocked by:
## 目标
新建 `crm-customer` 模块,落 `crm-customer/CONTEXT.md`(ubiquitous language,纯 glossary,无实现细节),并在根 `CONTEXT-MAP.md` 增一行。对称 `crm-lead/CONTEXT.md` / `crm-opportunity/CONTEXT.md` 的写法。
## 要收录的核心术语(随各票 resolve 回填/校准)
- **客户 (Customer)** — crm-customer 核心聚合根;归属由 `owner_user_id`(销售负责人)+`owner_dept_id`(销售部门快照) 表达,owner 空=在公海。_Avoid_: 线索(转化前)、商机(下游)、联系人。
- **销售负责人 / 销售部门** — 客户的 owner + 部门快照;抛公海/交割时变更。_Avoid_: 领取人(那是线索术语)、创建人。
- **协同人** — 客户团队非负责人成员;抛公海清空、交割不自动恢复。
- **客户阶段 (customer_stage)** — 潜在→重潜→已成交,只前进不回退,外部成功结果驱动,不可手动改。_Avoid_: 客户状态、归属状态。
- **档案状态 (archive_status)** — 有效/已归档/已被合并,正交于归属。
- **客户公海** — owner 空的有效客户逻辑视图;无独立池实体(对称商机公海)。
- **联系人 (customer_contact)** — 归属某客户的独立实体,无独立 ACL,权限继承客户。
- **人脉关系单位** — 客户下的关系单位名标签集(非关联 CRM 客户)。
- **客户交割** — 销售离职/调岗时把名下客户批量交给直属总监,再分配;同步变更关联商机/项目负责人。
## 依赖方向
单向:`crm-opportunity → crm-customer`(商机读客户/建关联,已有 customer_id 契约占位,见商机图「customer_id 契约占位」决策);`crm-customer → crm-rule → (crm-auth / crm-dict / crm-file)`。客户读「关联商机」走查 crm-opportunity(下游反查上游,注意 ADR-0029 循环依赖技术债,同库单体阶段可接受)。
## Answer
_(待 resolve)_

42
.scratch/customer-module/issues/02-customer-main-table.md

@ -0,0 +1,42 @@
# 02 — 客户主表字段清单
Type: research
Status: open
Blocked by:
## 问题
定客户主表 `customer` 的完整字段清单 + 索引 + DataScope 注解。这是扇出最广的票,其他票(三视图/查重/交割/阶段)回填它。
## 一手证据(蓝湖缓存 txt)
- `a4-3-3-1_新增客户信息.txt` — 新增页字段(基本信息/工商信息/跟进人员/关联信息/补充资料五区)
- `a4-4-1_客户详情_客户信息_.txt` — 详情页字段分区(基本信息/关联信息/补充资料/工商信息/系统信息)
- `a4-2-1_客户总览列表_列表视图_.txt` — 列表列 + 字段设置项
## 已知字段线索(待核实归类)
- **系统生成**:客户编号 `customer_no`(KH+日期+序,只读)、创建人/时间、修改人/时间。
- **基本信息**:客户名称*、客户类型*(字典)、所属地区*(省市区)、行业*(两级树,复用 crm-dict `industry`)、客户星级*(一~五星)、关系星级*(一~五星)、人脉关系单位(标签集)、公司决策人。
- **归属(轴1)**:`owner_user_id`(销售负责人*)+`owner_dept_id`(销售部门*快照)+协同人(团队子表)。
- **阶段(轴2)**:`customer_stage`(潜在/重潜/已成交)。
- **档案状态**:`archive_status`(有效/已归档/已被合并)。
- **战略协议**(划出):`战略协议等级` 列在列表出现——但战略协议子域划出,主表是否冗余一个等级快照列供列表展示?→ 本票决定(建议:留 `strategic_agreement_level` 快照列供列表,实体本身划出)。
- **已成交VIP客户等级**:列表有列 → 主表字段还是 A5 驱动?待定(疑似阶段=已成交后的细分,可能留 seam)。
- **工商信息**(企查查回填):统一社会信用代码(唯一)、法定代表人、成立时间、注册资本、经营范围、人员规模、年产值、公司地址、工商名称。
- **关联信息**:是否商机勾兑*、总监近期拜访时间、是否子客户*(是→父客户关联必填)、父客户关联(禁自身及下级/防环)、合作系统、不合作原因、是否参加总裁班/产品经理班(+参加人/电话,条件必填)。
- **补充资料**:备注(≤500)、附件(crm-file)。
## 待拍板点(research 中逐一给推荐 + grill)
1. 战略协议等级 / 已成交VIP等级 是否主表冗余快照列(战略协议实体划出)?
2. 父子客户:自关联 `parent_customer_id` + 防环校验口径。
3. 客户类型字典分组(`customer_type`)取值:甲方(企业/政府/普教/中高职/高校)… 需列全。
4. 客户星级/关系星级:字典 or 定长枚举(一~五星)?
5. 冗余快照列(列表免 join):最近跟进时间、关联业务计数(商机/项目)、owner 名快照。
6. DataScope:`@DataScope(module="customer", ownerColumn="owner_user_id", deptColumn="owner_dept_id")`。
7. 索引:UNIQUE 统一社会信用代码 / owner+stage / owner_dept+archive_status(公海视图)。
## Answer
_(待 resolve)_

41
.scratch/customer-module/issues/03-ownership-views.md

@ -0,0 +1,41 @@
# 03 — 归属流转 + 三 workspace + 内置视图 + 自定义视图查询口径
Type: research
Status: open
Blocked by: 02
## 问题
定客户模块三个 workspace(客户总览 / 我的客户 / 客户公海)的**数据范围**、**内置视图**、**共用列**、**行内操作**、**自定义视图接入**(复用商机 saved-view 手册)。同时定归属的迁移动作(领取/分配/抛公海/交割换主/归档)在读侧怎么反映。
## 一手证据(蓝湖缓存 txt)
- `a4-1-1_客户公海列表.txt` / `a4-1-1_客户公海列表_列表视图_.txt` / `a4-1-2_客户公海列表_分屏视图_.txt` — 客户公海(普通销售视角=批量领取 + 领导视角=批量分配)
- `a4-2-1_客户总览列表_列表视图_.txt` / `a4-2-1_客户总览列表_分屏视图_.txt` / `a4-2-1_客户总览列表_卡片视图_.txt` — 客户总览(三视图:表格/列表详情/看板;含分配/抛公海/归档)
- `a4-3-1_我的客户列表_分屏视图_.txt` / `a4-3-1_我的客户列表_列表视图_.txt` / `a4-3-1_我的客户列表_卡片视图_.txt` — 我的客户
## 已知事实
- **三 workspace 数据范围**(对称商机四视图):
- **我的客户** = 当前用户 owner 或作为协同人的有效客户(去重)。
- **客户总览** = 按 `@DataScope` 覆盖组织范围内的**未删除、未进入公海**客户;含内置子筛「我负责的 / 我协同的 / 我关注的 / 最近访问」;**不混入公海**(公海是独立入口);含**已归档客户 / 已被合并客户**两个专门 tab(原型 A4-2-1 有)。
- **客户公海** = `archive_status=有效 且 owner_user_id IS NULL` 的逻辑视图;无独立池表;`owner_dept_id`+匹配规则的可见性(对称商机公海口径,待与超期规则票 10 呼应)。
- **行内操作差异**
- 我的客户:详情 / 关注 / 跟进 / 编辑;分配/抛公海按 RBAC 出。
- 客户总览:详情 / 关注 / **分配** / **抛公海** / 更多(含 **归档**);顶部批量:批量分配 / 批量抛公海 / 批量归档 / 工商信息更新。
- 客户公海:普通销售=详情+关注+**领取**+批量领取;领导=详情+关注+**分配**+批量分配(不做审批)。
- **三视图模式(仅客户总览有全三种;公海/我的客户只有列表+分屏)**:表格 / 列表详情(左表+右详情) / 看板(客户阶段分列?待原型核实)。切换视图记住用户偏好(复用 crm-preference)。
- **自定义视图 = crm-preference 平台能力**(商机图票 14 已建平台;本模块新接一个 scope,参商机 `OpportunitySavedViewFilter` 手册)——见票 11。
## 待拍板点
1. **公海准入规则**(原型 A4-1-1 §3 明说):客户有「进行中商机/项目」不可移入公海——本票拍板:抛公海写路径的前置校验点(读 crm-opportunity 存在性;A5 未建先跳过项目那半)。
2. **看板视图(卡片视图)分列维度**:客户阶段?客户星级?→ 需 grill 或读原型细节。
3. **共用列 vs 差异列**:列表统一列 = 客户名/客户类型/行业/客户阶段/星级×2/战略等级/VIP等级/进入公海时间(公海)or销售负责人+部门+关联业务+最近跟进(总览/我的)。字段设置=复用 crm-preference 列偏好。
4. **归属迁移动作枚举**:领取(公海→个人)、分配(公海→个人 / 个人→个人)、抛公海(个人→公海)、交割(交割票 08 专管,本票只留 seam)、归档(→archive_status=2)。每边写操作日志(票 07)。
5. **并发**:所有归属变更 CAS 乐观锁 + 版本号;批量操作跳过已被他人处理的(Toast 报成功/跳过数)。
6. **领导视角 DataScope 映射**:客户总览 = ALL(领导) / DEPT_AND_CHILD(总监) / DEPT(普通销售不可见总览菜单)——对称 crm-auth DataScopeEnum。
## Answer
_(待 resolve)_

39
.scratch/customer-module/issues/04-create-dedup-and-company-lookup.md

@ -0,0 +1,39 @@
# 04 — 新增/编辑客户的查重校验 + 企查查工商回填 seam
Type: research
Status: open
Blocked by: 02
## 问题
定新增/编辑客户表单的**查重校验规则**(内建在新增流程、写死不做后台可配)+ **企查查外部服务契约 seam**(调用点/回填范围/异常/精确查重触发)。**A4-6 独立查重页 + 客户合并不做**(chart grill D6)。
## 一手证据(蓝湖缓存 txt)
- `a4-3-3-1_新增客户信息.txt` — 主页面 §3.2 客户名称与自动查重 / §3.3 企业查询与工商信息回填 / §3.4 客户重复校验 / §3.5 联系人明细表 / §3.6 工商信息
- `a4-3-3-2_编辑客户信息.txt` — 编辑页复用新增结构 + 有 seam
## 已知事实(原型明文,节选)
- **自动查重(防抖)**:客户名称停止输入→防抖异步查 CRM 相似客户;仅名称输入框下方展示相似列表;只提示、不阻止;无权限的匹配脱敏为「存在匹配记录」不显 name/编号/负责人。
- **企业查询**:`查询企业`→调外部工商服务→候选列表→单选回填。首次选择只回填**当前为空且属于允许范围**的工商字段(工商名称/信用代码/法代/成立时间/注册资本/经营范围/人员规模/年产值/工商注册地址)。已填字段再次选择必须弹「受影响字段确认」再覆盖;空值不覆盖原值。**不覆盖**:客户类型/行业/所属地区/关系星级/负责人/部门/协同人/人脉关系单位/联系人/备注。
- **精确查重触发**:选择企业并回填后立即用统一社会信用代码执行精确查重;保存时再执行一次终查。
- **保存时校验**(服务端最终查重):
- 统一社会信用代码在**全部未删除客户**中必须唯一 → 完全相同**硬拦禁止创建**(含无权查看时不显 name/编号,只提示阻断)。
- 客户名称相似但信用代码不同 → 弹「客户相似确认」弹窗,用户可"仍要创建";"仍要创建"后**服务端必须再查一次**(防并发/防中间被人创建同信用代码)。
- **联系人明细表内联查重**:同表格内联系电话相同→禁止提交定位重复行;无电话按「客户+姓名+职务」提示疑似重复;全库按电话查重(对已存在联系人)。→ 联系人查重口径归本票,联系人 CRUD 建模归票 06。
- **父子客户防环**:`是否子客户=是`时父客户必填;父客户不得选择自身及其下级 → 服务端必须校验不得形成循环。
- **人脉关系单位**:标签集(单个≤50字,同 name 不重复),非关联 CRM 客户。
## 待拍板点
1. **相似客户判定口径**(阈值/算法)——原型说「使用 CRM 后端统一查重服务,不在前端固化阈值」。本票拍板:走什么算法(简单 Trigram / MySQL 全文索引 / 编辑距离 / N-gram 分词)?先给最简可行方案。
2. **企查查外部服务契约 seam**:不深挖服务实现,只定接口(`CustomerCompanyLookupPort`?)+ 请求(name)/响应(候选列表带9项工商字段)/异常语义(无结果/失败/超时);实现可 mock。
3. **信用代码格式校验**:18 位数字或字母组合(原型明说,正则拍死)。
4. **成立时间不得晚于当前日期** 前置校验。
5. **并发建客户**(同信用代码并发提交):DB 唯一约束兜底 + 乐观返回「重复」错误;先查后写做双保险但不依赖它。
6. **相似客户提示的脱敏**:无权限用户命中他人客户时的返回负载最小化(不带 name/编号/负责人/联系方式),只回「命中数量+提示语」。
## Answer
_(待 resolve)_

36
.scratch/customer-module/issues/05-customer-stage.md

@ -0,0 +1,36 @@
# 05 — 客户阶段(潜在→重潜→已成交,只前进)
Type: research
Status: open
Blocked by: 02
## 问题
`customer_stage` 的三态定义、迁移触发源、约束(只前进不回退)、seam 契约(重潜由方案卡驱动,已成交由 A5 驱动)。**本图只做「潜在→重潜」**,已成交留 seam(chart grill D3)。
## 一手证据(蓝湖缓存 txt)
- `a4-4-1_客户详情_客户信息_.txt` §1「客户阶段」明文:新建=潜在;任一方案卡提交并报备成功→重潜;任一项目首次中标赢单→已成交;只前进不回退;方案卡失败/报备失败/商机输单/项目结束**均不触发回退**;阶段条不可点击修改;每次变化刷新公共头部+写操作日志。
## 已知事实
- 三态:`潜在客户(1) / 重潜客户(2) / 已成交客户(3)`。
- 默认值:新建客户成功后 = 1 潜在。
- **驱动源**
- 1→2:方案卡「提交并报备成功」——方案卡状态机在 crm-opportunity 票 07 已定为 `草稿1/已提交2/已推送3`,本图需在 crm-opportunity 侧添 seam:方案卡状态变 2→3(已推送=报备成功?) 时**回调** customer.promoteStage(customerId, 2)。**需与商机侧核对「报备成功」到底对齐哪个状态**(推送 or 一个新态)。
- 2→3:A5 项目「首次中标赢单」——A5 未建,留 seam 接口 `CustomerStagePort.promoteToClosed(customerId)`
- **只前进**:迁移方法只接受更高阶段;低阶段传入返回 no-op(不报错、不写日志)。
- **不回退**:方案卡撤回/商机关闭/项目结束**不改阶段**(原型明说)。
- **不可手动改**:无 setter API 面向前端;仅内部 port + 方案卡回调触发。
## 待拍板点
1. **重潜的触发点到底是「方案卡提交」还是「方案卡推送/报备成功」**?原型 A4-4-1 说「方案卡提交并报备成功」——「报备」是**商机图 spin-out 的推送链路**(商机图票 07 说推送/报备本期不可达,只有 A5 回调特殊路径可达)。→ 本票拍板:**先按「方案卡状态=已提交(2)」触发重潜**(保守),等推送链路建好再改口径;或等 A5 建好后由「报备成功」驱动。
2. **seam 接口签名**:`CustomerStagePort.promote(customerId, targetStage)` 单方法 or 两方法(`promoteToRepeat` / `promoteToClosed`)?
3. **幂等**:重复触发(同一方案卡多次触发)→ 只前进逻辑天然幂等,无需去重表。
4. **父子客户联动**:子客户阶段改变时,父客户是否受影响?→ 推荐:**不联动**(父子各自独立阶段,原型无相关规则)。
5. **阶段变更写操作日志**(票 07):格式 = 「客户阶段:潜在 → 重潜(由方案卡 XXX 提交触发)」,操作人=「系统」。
## Answer
_(待 resolve)_

57
.scratch/customer-module/issues/06-contacts-crud.md

@ -0,0 +1,57 @@
# 06 — 联系人建模 CRUD + 全局搜索 + 商机侧硬依赖接口
Type: research
Status: open
Blocked by: 02
## 问题
`customer_contact` 实体表、CRUD 接口、全局搜索(商机侧硬依赖)、联系人电话查重、职务字段占位(图谱划出但字段留占位)。归属客户不孤立、权限继承客户团队。
## 一手证据(蓝湖缓存 txt)
- `a4-3-3-1_新增客户信息.txt` — 新增客户内嵌联系人明细表(序号/*联系人/*职务/联系电话/是否内线/礼品备注/来源/操作),OCR/批量导入按钮划出
- `a4-5-1_联系人列表_列表视图_.txt` / `a4-5-1_联系人列表_分屏视图_.txt` — 顶级联系人菜单
- `a4-5-2_新增联系人.txt` / `a4-5-3_编辑联系人.txt` — 独立联系人 CRUD
- `a4-5-4_联系人详情_联系人信息_.txt` / `a4-5-5_联系人详情_联系人信息_.txt` — 联系人详情
- `a4-5-5_联系人详情_操作日志_.txt` / `a4-5-6_联系人详情_操作日志_.txt` — 联系人操作日志
- `a4-3-4_添加联系人.txt` — 客户下快速添加联系人
- `a4-4-2_客户详情_联系人_.txt` — 客户详情联系人页签
- `.scratch/opp-customer-dependency/商机模块前置客户接口依赖分析报告.md` — 商机硬依赖清单
## 已知字段线索
- **联系人**(必填)— 姓名。
- **职务**(必填)— 见 `a4-3-3-1.txt` 底部「职务 - 搜索二级联动下拉框」需求:**图谱划出但存储字段留占位**:`job_title_name`(文本必填) + `job_title_id`(标准职务id可空) + `job_title_category`(类别可空) + `job_title_level`(层级 int 可空)。自定义职务时后三列空。
- **联系电话** — 手机号格式;**全库按电话查重**:新增/编辑时电话完全相同禁止创建;同表格内不同行电话相同禁止提交。
- **是否内线** — 布尔。
- **是否关键联系人** — 布尔(原型有)。
- **礼品备注** — 文本。
- **来源** — 字典 `contact_source`:组会/组局/转介绍/其他。
- **归属客户**`customer_id` NOT NULL(无孤立联系人)。
- **系统字段** — 创建人/时间、修改人/时间。
## 商机侧硬依赖(对齐 opp-customer-dependency P0/P1)
| 优先级 | 接口 | 商机侧用途 |
|---|---|---|
| P0 | `POST /api/customer/search` | 商机新增关联客户下拉数据源 |
| P0 | `GET /api/customer/{id}/contacts` | 商机字段17按客户筛选联系人 |
| P0 | `GET /api/customer/{id}` | 商机编辑回显客户带出 |
| P1 | `GET /api/customer/contact/search` | 商机字段13全局搜联系人+带出所属公司 |
| P1 | `POST /api/customer/quick-create` | 商机弹窗「+快速创建客户」入口 |
**本票产出**:以上契约签名 + 请求/响应 DTO(复用已有的分析报告,把它 promote 成正式契约稿)。
## 待拍板点
1. **联系人权限模型**:明说「继承客户团队权限」——本票落成契约:无独立 `@DataScope` 注解,服务层显式按 customer 校验(对客户有读权限即对全部联系人有读权限)。
2. **联系人删除**:软删还是硬删?→ 推荐软删(联系人可能被商机/项目引用做快照);主表加 `deleted` 位。
3. **联系人电话唯一 vs 允许一号多人**:原型「联系电话完全相同禁止新建」听起来是全库唯一——但同一联系人可能有座机 vs 手机?→ 拍板:**仅手机号做唯一约束**,座机不查重;或直接单一 `phone` 列全库唯一,用户共号自己处理。先按后者简化。
4. **快速创建客户 `POST /api/customer/quick-create`**:字段集是**新增客户表单的必填最小子集**?还是就用完整表单?→ 推荐最小集(客户名称/客户类型/所属地区/行业/星级/关系星级)+ 服务端默认(销售负责人=当前用户/is_child=否)。
5. **联系人的操作日志**:单独一张 `customer_contact_oplog` 还是并入客户 oplog(票 07)用 `biz_type=CONTACT`?→ 推荐后者,参商机 oplog 口径。
6. **人脉关系单位** 与联系人是独立集合,仅是标签(非联系人子表)——归客户主表 JSON/子表(票 02 决定)。
## Answer
_(待 resolve)_

53
.scratch/customer-module/issues/07-detail-follow-oplog.md

@ -0,0 +1,53 @@
# 07 — 客户详情 8 页签 + 跟进记录 + 操作日志
Type: research
Status: open
Blocked by: 02, 05
## 问题
定客户详情页公共头部 + 8 页签数据源、跟进记录 `customer_follow` 子表、操作日志 `customer_oplog` 子表(字段级审计口径,对称商机 oplog 票 09)。
## 一手证据(蓝湖缓存 txt)
- `a4-4-1_客户详情_客户信息_.txt` — 公共头部 + 客户信息页签 + 权限/敏感信息/加载/异常规则(**这是详情页的顶层 spec,务必细读**)
- `a4-4-2_客户详情_联系人_.txt` — 联系人页签(复用票 06 数据源)
- `a4-4-3_客户详情_跟进记录_.txt` — 跟进记录页签
- `a4-4-4_客户详情_关联商机_.txt` — 关联商机页签(读 crm-opportunity ✅ 已建)
- `a4-4-5_客户详情_关联项目_.txt` — 关联项目页签(**读 A5 未建 → 划出本图**,页签保留骨架显"—")
- `a4-4-6_客户详情_团队成员_.txt` / `a4-4-7_客户详情_团队成员_.txt` — 团队成员页签
- `a4-4-6_客户详情_战略协议_.txt` — 战略协议页签(战略协议子域划出 → 本图页签保留骨架显"—")
- `a4-4-1_客户详情_已归档客户_.txt` / `a4-4-1_客户详情_已被合并客户_.txt` — 归档/合并态的详情皮肤
- `a4-4-7_客户详情_操作日志_.txt` / `a4-4-8_客户详情_操作日志_.txt` — 操作日志页签
## 8 页签清单
1. **客户信息** — 主表字段展开(票 02 字段集),支持折叠区块,「编辑客户信息」跳编辑页;「工商信息更新」→ 企查查回填 seam(票 04 复用)。
2. **联系人** — 票 06 数据源,`GET /api/customer/{id}/contacts`。
3. **跟进记录** — 本票新建 `customer_follow` 子表。
4. **关联商机** — 反查 crm-opportunity(`GET /api/opportunity/by-customer/{customerId}` 需商机侧新增或本票新增反查接口)。
5. **关联项目** — 划出(A5 未建),页签骨架保留。
6. **战略协议** — 划出(战略协议子域),页签骨架保留。
7. **团队成员** — 显示销售负责人 + 协同人;领导可维护。
8. **操作日志** — 本票新建 `customer_oplog` 子表。
## 已知事实
- **公共头部**:客户阶段(潜在/重潜/已成交)/客户星级/销售负责人+部门/战略协议等级(快照)/已成交VIP等级/客户档案状态/最近跟进摘要/**汇总卡片**(已中标项目总额=A5读、跟进中方案预估总额=方案卡读、进行中商机总额=crm-opportunity读)/关联业务计数/快捷操作(关注/标记重点客户/跟进/添加联系人/新增商机/新增项目/新增战略协议/工商信息更新)。
- **敏感信息**:完整/脱敏/无权限三档;脱敏对电话/金额;完整值只当前页面会话生效,刷新恢复脱敏;每次完整查看记录敏感信息查询日志。
- **加载/异常**:局部区域独立加载/失败/重试;关联模块接口失败只影响该区域,客户基本信息继续展示。
- **跟进记录**:跟进方式必选(字典 `follow_way` 复用商机)、跟进内容必填(1-500)、下次跟进时间(非必填、不得早于当前);本期**不做**跟进附件;提交后刷新最近跟进+跟进记录页签;商机行也可点跟进复用同一弹窗,自动关联当前客户+该商机(即 `customer_follow.opportunity_id` 可选)。
- **操作日志**(口径纠偏,对称商机票 09):**字段级审计**——实体(customer/contact/follow/team)/字段/旧值/新值/操作人(系统 vs 人工)/操作时间/来源;**非**动作枚举模型。归属变更(领取/分配/抛公海/交割/归档)/阶段变更/工商信息更新/编辑保存/联系人增删改/团队成员变更/敏感信息查询/仍要创建重复客户/相似客户仍创建 均写日志;未保存/取消/校验失败不写。日志脱敏电话/金额;工商查询 raw / OCR 图片不写日志。默认全记 + 短黑名单排除(非白名单)。
## 待拍板点
1. **`customer_follow` 表字段**:id/customer_id/opportunity_id(可选)/follow_way/content/next_follow_time/creator/create_time;无草稿态(对称商机票 09);无附件本期。
2. **`customer_oplog` 表字段**:id/biz_type(customer/contact/follow/team/company_info)/biz_id/entity_field/old_value/new_value/operator_user_id(null=系统)/operator_source/create_time;金额/电话脱敏存原值再回显时脱敏 or 存已脱敏值?→ 推荐存原值+回显时按查看者权限脱敏。
3. **关联商机反查接口**:本模块调 crm-opportunity 新增 `list-by-customer` 还是 crm-opportunity 直接暴露?→ 推荐 crm-opportunity 侧加,本模块调(对称票 09 的模块边界)。
4. **公共头部汇总金额脱敏**:无权限=`***万元`;无数据=`0.00万元`;接口失败=`--`+重试(原型明说)。
5. **关注 / 标记重点客户 = 用户维度个人状态**(对称商机票 14 followed)——本表建 `user_customer_focus`(user_id, customer_id, is_starred)。
6. **归档/合并态详情皮肤**:只读,隐藏编辑/新增入口;仍展示历史数据。合并态划出(合并不做),归档态本图做。
## Answer
_(待 resolve)_

47
.scratch/customer-module/issues/08-transfer.md

@ -0,0 +1,47 @@
# 08 — 客户交割(跨模块同步商机/项目负责人)
Type: research
Status: open
Blocked by: 02, 03
## 问题
定客户交割全流程:发起交接 → 客户分配 → 交接记录 + 详情。核心难点是**跨模块负责人同步变更事务**(客户+关联商机+关联项目一起换主,要么全成要么全不成)。
## 一手证据(蓝湖缓存 txt)
- `a4-7-1_客户交割_发起交接_.txt` — 发起交接页(三 tab 之首)+ 交接原因/预览/确认弹窗 + **公共规则/字段规则/客户及关联业务变更/加载与操作反馈**(关键 spec 段)
- `a4-7-2_客户交割_客户分配_.txt` — 客户分配 tab(接收总监分配到具体销售)
- `a4-7-3_客户交割_交接记录_.txt` — 交接记录 tab
- `a4-7-4_交接记录详情.txt` — 交接记录详情
## 已知事实(关键提炼)
- **三 tab**:发起交接 / 客户分配 / 交接记录(+ 记录详情)。
- **交接原因**:员工离职 / 岗位调动 / 区域调整(单选必选)。
- **发起人**:仅销售**本人**可发起本人名下客户交接(不允许主管/管理员代发起)。
- **接收人**:仅交接人员的**直属销售总监**;员工离职时系统自动定唯一总监,岗位调动/区域调整时手选启用的直属总监。
- **可交接客户范围**:交接人员**当前为唯一负责人**的**未删除、未进入公海**客户;仅协同人身份的不纳入。
- **员工离职**:必须交接**全部可交接客户**(无勾选框);**岗位调动/区域调整**:至少勾选1个,跨分页勾选。
- **发起交接后同步变更**:客户负责人 → 接收总监;**该客户全部关联商机 + 项目负责人同步变更为同一接收人**;协同人保留;商机/项目其他数据不变。
- **原子性**:客户+商机+项目负责人**全部成功**才算本客户处理成功;不允许出现「客户变了但关联商机未变」的中间态;任一失败整批不生效、不生成正式交接记录、当前表单/搜索条件/勾选保留。
- **客户分配 tab**:接收总监按交接批次把客户分配给具体销售(单条/批量);分配对象排除交接来源人本人;同步变更客户+关联商机+项目负责人;成功后原负责人(接收总监)移出团队。
- **未完成交接约束**:同一交接人员有未完成交接记录时不允许再次发起。
- **交接记录**:唯一交接编号;初始分配状态=待分配,进度=0/N;随分配推进;跟操作日志区分(跨模块自动变更的操作人显示"系统"+注明交接编号)。
## 待拍板点
1. **跨模块负责人同步的事务边界**
- **推荐 A(同步事务)**:本地事务包含 customer 表 + 调用 crm-opportunity 的 `batchTransferOwner(oppIds, newOwner)` + 调用 crm-project 的同名接口;任一失败整批回滚。但 crm-project 未建 → 本图只做客户+商机两侧同步,项目侧留 seam(`ProjectOwnerTransferPort`)。
- **推荐 B(异步 saga)**:客户先变,商机/项目通过消息异步跟;失败重试;对账。
- 我倾向 **A(同步事务)**:原型明说「不允许出现中间态」→ saga 语义不匹配。同库单体阶段跨模块事务可行(同一 DataSource)。
2. **`customer_transfer` 表结构**:id/transfer_no/handover_user_id/receiver_dean_id/reason(enum)/remark/status(待分配/进行中/已完成)/total_count/assigned_count/create_time/finish_time。
3. **`customer_transfer_item` 子表**:id/transfer_id/customer_id/original_owner/new_owner(初始=接收总监)/final_assignee(分配后=最终销售)/status(待分配/已分配/失败)/fail_reason。
4. **「直属销售总监」怎么定**:读 crm-auth 的 SysDept 树 + 角色配置?→ 需与 crm-auth 现有事实核对,可能加个 `SysUser.superior_user_id` 字段(多角色时用「主直属」)。**本票关键 seam**。
5. **未完成交接锁**:数据层用 `handover_user_id` 上有一条 status ∈ (待分配/进行中) 的记录做互斥;发起前查一次。
6. **跳过与部分成功**:客户分配 tab 按客户逐条校验,任一失败不影响其他客户;返回 Toast 成功/失败数 + 逐条失败原因(部分失败不整体回滚)——**与发起交接的强原子性不同**(发起交接=全批同一事务,分配=按客户独立事务)。
7. **操作日志**:跨模块自动变更操作人显示「系统」+ 注明交接编号(票 07 oplog 字段 `operator_source` 支持 "system+transferNo=XXX")。
## Answer
_(待 resolve)_

36
.scratch/customer-module/issues/09-import.md

@ -0,0 +1,36 @@
# 09 — 客户导入 + 联系人快速新增(OCR 划出)
Type: research
Status: open
Blocked by: 02
## 问题
定客户 Excel 批量导入流程(模板下载 / 上传解析 / 校验 / 结果反馈)。**OCR 快捷新增划出**(chart D2),本图只做 Excel 导入。
## 一手证据(蓝湖缓存 txt)
- `a4-3-2_客户导入.txt` — 客户导入主页
- `a4-3-2_新增客户信息.txt` — 与新增页同族(同 a4-3-2 前缀,可能是导入后进入新增回显)
- `a4-3-5_客户导入.txt` / `a4-3-6_客户导入.txt` — 导入步骤/结果页
- `a4-3-3-3______.txt` / `a4-3-3-4_ocr____.txt` / `a4-3-5_ocr____.txt` — OCR 相关(划出,不读)
## 已知事实(待原型细读补充)
- 模板下载 → 用户填 → 上传 → 后台异步解析 → 结果表(成功/失败逐行)。
- 复用票 04 查重口径:信用代码唯一硬拦、名称相似只提示不阻断(导入场景可能改为「相似=跳过」或「相似=创建但标记」)。
- 复用票 02 字段清单:必填校验(客户名称/客户类型/所属地区/行业/星级/关系星级)。
- 销售负责人:允许在 Excel 里指定 or 默认导入者。
## 待拍板点
1. **导入策略**:全成才提交(整体事务)vs 逐行独立事务(部分成功)?→ 推荐**逐行独立**(对称一般 CRM 导入的常识 + 商机模块导入若已建可参照)。
2. **异步 vs 同步**:批量大(>500)走异步任务 + 轮询进度 vs 同步阻塞?→ 推荐**同步小批 + 异步大批**双通道,阈值可配。
3. **相似客户在导入场景的处理**:跳过 / 创建但标记 / 报错 → 拍板。
4. **导入历史记录表** `customer_import_batch`:批次号 / 上传人 / 上传时间 / 总数 / 成功数 / 失败数 / 状态 / 失败明细文件 url。
5. **模板字段清单**:Excel 列 = 客户主表可导入字段子集(工商信息、附件、人脉关系单位标签集怎么表达?→ 标签用「;」分隔)。
6. **联系人一起导入**:Excel 是否含联系人 sheet?还是分两批?→ 推荐只导客户主表,联系人另导(联系人导入本图划出,见票 06)。
## Answer
_(待 resolve)_

42
.scratch/customer-module/issues/10-overdue-reminder-rule.md

@ -0,0 +1,42 @@
# 10 — 客户规则族「超期未跟进提醒」(crm-rule 全局单例)
Type: research
Status: open
Blocked by:
## 问题
`crm-rule` 落客户规则族第三个 tab(线索/商机/客户并列),本图**只做「超期未跟进提醒」**一项,**全局单例配置**(不套 V-CONFIG 版本化,chart D4)。「客户查重设置」不做(chart D6)。
## 一手证据(蓝湖缓存 txt)
- `a7-3-3-1_客户管理设置_超期未跟进提醒_.txt` — 全部 spec 都在这里
- `a7-3-3-2_客户管理设置_客户查重设置_.txt`**划出不读**(查重规则写死在新增流程,票 04)
## 已知事实(原型明文)
- **全局单例**:非按阶段/按部门分别配,仅后台管理面板可见。
- **顶部总开关**:控制整个超期未跟进提醒功能启用/禁用。
- **两配置项**
- **首次触发时间**:客户超过 N 天未跟进→触发首次提醒(默认 30 天);含独立启用开关。
- **二次提醒间隔时间**:首次后每隔 M 天再提醒(默认 7 天);含独立启用开关。
- **提醒推送渠道**:钉钉推送 + 应用内通知(对称商机票 05 的通知落点)。
- **交互**:保存/取消(取消恢复上次保存的配置)。
## 待拍板点
1. **数据模型**
- **推荐 A(单行配置表)** `customer_reminder_rule`(id=1 fixed / master_enabled / first_trigger_enabled / first_trigger_days / second_interval_enabled / second_interval_days / updater / update_time)。
- **推荐 B(键值配置)** 复用某个 platform_config 表按 key 存。
- 我倾向 **A**(单行表,与商机 V-CONFIG 表族对称位置摆,只是不做版本,操作更直观)。
2. **触发口径**:「超过 N 天未跟进」=`now - last_valid_follow_time ≥ N`;`last_valid_follow_time` = 客户下最新一条 `customer_follow.create_time`(或字段编辑时间?→ 推荐**仅跟进记录**驱动,字段编辑不算——对齐商机票 05 的 last_valid_follow_time 但商机含字段编辑;这里更保守)。
3. **计时锚点写入**:`customer.last_valid_follow_time` 冗余到主表(新写跟进时刷新);对称商机主表 `last_valid_follow_time`。→ **回填票 02** 加此字段。
4. **调度**:定时任务(Quartz / Spring Schedule)每天跑一次;命中的客户写「待发通知」表让通知模块消费(对称商机票 05 的 remind 待发通知)。
5. **通知去重**:一个客户当天只触发一次(首次或二次,取其一)。
6. **无 owner 的客户**(在公海)**不参与**超期提醒(对谁提醒?→ 无接收人)。
7. **禁用后**:定时任务扫到总开关=禁用 → 跳过整轮;启用后从下一轮开始。
8. **规则生效时机**:chart Q4-补 用户特意强调「规则在客户新增时生效」——我的解读是**新增客户时就把 `last_valid_follow_time` 初始化为 create_time**(客户创建后开始计时,30 天没跟进就首次提醒)。**待和用户确认**这个解读——见 grill 挂号。
## Answer
_(待 resolve)_

37
.scratch/customer-module/issues/11-saved-view-integration.md

@ -0,0 +1,37 @@
# 11 — 自定义视图接入 crm-preference(复用商机 saved-view 平台能力)
Type: task
Status: open
Blocked by: 03
## 目标
客户模块的三 workspace(客户总览 / 我的客户 / 客户公海)复用 crm-preference 已建的 `user_saved_view` 平台能力,按接入手册三步走:约定 scope_key + 建 `CustomerSavedViewFilter` + PageParam 加 savedViewId。**平台能力不重写**,商机图票 14 已建。
## 已知事实
- **平台能力已就绪**(商机图票 14):`user_saved_view` 实体 + `SavedViewService` list/save/delete/setDefault + Controller `/api/preference/view` + 六操作符枚举 `SavedViewOperator`(包含/不包含/属于任一/不属于任一/早于/晚于 等)+ 按 `scope_key` 分组、crm-preference 不校验字段。
- **商机侧接线样本**:`OpportunitySavedViewFilter`(filter_json → wrapper,字段池白名单 = 票 10 可筛选列,未知字段/操作符跳过)+ `OpportunityPageParam.savedViewId` + ViewQueryImpl 叠加。**本票直接参此样本改客户版**(rule of three,商机是样本1,客户是样本2;样本3出现前不抽共享翻译器)。
- **原型明说客户支持自定义视图**(`a4-1-1_客户公海列表.txt` / `a4-2-1_客户总览列表.txt` / `a4-3-1_我的客户列表.txt` 三处均有「常用检索 / 自定义检索 / 编辑检索 / 重命名 / 删除」+ 保存对话框条件列表)。
## 接入手册三步
1. **约定 scope_key**
- 客户总览 = `customer.overview`
- 我的客户 = `customer.mine`
- 客户公海 = `customer.pool`
- (公海双视角=普通销售/领导 → 是否分两 scope 需拍板;推荐**共用 `customer.pool`**,因为字段池相同,视角只影响行内操作而非筛选。)
2. **建 `CustomerSavedViewFilter`**:filter_json 条件翻译成 MyBatis-Plus wrapper;字段池白名单 = 票 03 定的可筛选列(客户名称/客户类型/行业/客户阶段/星级/关系星级/战略等级/地区/最近跟进时间/进入公海时间 等);未知字段/操作符跳过。
3. **PageParam 加 `savedViewId`**:客户三 workspace 的 PageParam 均加此字段;ViewQueryImpl 里叠加 filter。
## 待拍板点
1. **scope_key 数量**:3 个还是把公海/总览合成一个?→ 推荐 3 个(保存的检索按语境分组,用户体验更清晰)。
2. **公海双视角是否分 scope**:见上,推荐共用。
3. **排序字段池**:默认可排序 = 创建时间 / 最近跟进时间 / 进入公海时间;其他字段(如客户名称)排不排?→ 参商机的默认口径。
4. **默认视图**:每个 scope 一个 isDefault 视图,crm-preference 已实现互斥。前端进入 workspace 先调 `/view/list` 取 isDefault 再带 viewId 调 `/page`(对称商机)。
5. **测试**:本票不写代码,但 spec 需列出接入验收清单(scope_key 唯一/filter 白名单/未知字段跳过/默认视图互斥/端到端 saved-view 可用)。
## Answer
_(待 resolve)_

78
.scratch/customer-module/map.md

@ -0,0 +1,78 @@
# Map: 客户模块后端 spec(crm-customer / crm-rule 客户规则)
`wayfinder:map`
## Destination
一份可交接的后端规格说明书(spec),描述"客户(Customer)"域的后端设计,交给实现方(人或 agent)落地——对称商机地图(`.scratch/opportunity-module/`)的产出形态:**spec-only(只出决策 + 契约 + 领域模型,不写实现代码)**,做完后进 `/implement` 阶段。
- **`crm-customer`(新建模块)** — 客户业务域:客户主表 + 归属流转(有主/公海 + 交割换主)+ 客户阶段(潜在→重潜→已成交,只前进)+ 三 workspace(客户总览 / 我的客户 / 客户公海)+ 内置视图 + 自定义视图(复用 crm-preference)+ 新增/编辑(含查重校验 + 企查查工商回填 seam)+ 客户导入 + 客户详情 8 页签 + 跟进记录 + 操作日志 + 客户交割(跨模块同步商机/项目负责人)+ 联系人 CRUD + 全局搜索。
- **`crm-rule`(客户规则族,新增第三族)** — 后台"业务规则"下线索/商机/客户三族并列。客户规则本图**只做「超期未跟进提醒」**(全局单例配置,非版本化)。
### 原型出处(蓝湖缓存,已本地提取)
- 主文档 `bade4454-52aa-44db-8ba2-dec594732ecb`(`【原型】itc信息化业务中台web端 V1.0`)。
- 截图/文本缓存目录:`D:\code\crm-需求梳理\lanhu-mcp\data\axure_extract_bade4454_screenshots\`(每页 4 文件:`.png`/`.txt`/`_annotations.json`/`_styles.json`;`.txt` 为主)。
- A4 客户模块页族:`a4-1 客户公海` / `a4-2 客户总览` / `a4-3 我的客户+新增+导入+OCR` / `a4-4 客户详情(8页签)` / `a4-5 联系人` / `a4-6 客户查重+合并` / `a4-7 客户交割`。规则侧 `a7-3-3 客户管理设置`
- **前置依赖分析已存在**:`.scratch/opp-customer-dependency/商机模块前置客户接口依赖分析报告.md`(商机侧硬依赖的客户接口:`POST /api/customer/search` / `GET /api/customer/{id}/contacts` / `GET /api/customer/{id}` 等)——客户模块的一个已知锚点切片。
## Decisions so far
<!-- chart 期先行拍板(grill 定名阶段);逐题底稿见对应 issue -->
- **[D1] 目的地形态 = spec-only,对称商机图** — 只产出决策/契约/领域模型,不写实现代码;做完进 /implement。(chart grill Q1)
- **[D2] 作用域三档划分(chart grill Q2 + Q4 修正)**:
- **① 本图覆盖**:客户核心(实体+查重校验+公海+总览+我的客户+三视图+新增/编辑+导入+详情8页签(关联商机读 crm-opportunity✅)+跟进+操作日志)、联系人(CRUD+全局搜索)、客户交割、客户规则族「超期未跟进提醒」。
- **② 依赖未建 A5 项目模块 → 留 seam**:客户阶段「已成交客户」(由项目中标驱动)本图只做到「潜在→重潜」;「关联项目」详情页签划出。
- **③ 自成子域/复杂特性 → 划出本图**(Not-yet / Out-of-scope 记一笔):战略协议(A4-4-9/10)、联系人图谱(职务二级联动+人脉连线)、**客户查重列表+客户合并(A4-6,grill Q4 拍板不做)**、企查查/OCR 外部服务(只定契约 seam,不深挖服务内部;OCR 快捷新增划出)。
- **[D3] 客户双轴建模(chart grill Q3)** — 对称商机双轴但两轴都更简单:
- **轴1 归属**:**不做显式 tinyint 状态机**,改用两个正交维度:① `owner_user_id`+`owner_dept_id`(快照) 是否为空 = 有主/在公海(公海=owner 空的逻辑视图,对称商机公海);② `archive_status`(有效1/已归档2/已被合并3) 独立字段,归档/合并是它的迁移。迁移动作 = 领取/分配/抛公海/交割(换主)/归档。
- **轴2 阶段**:主表 `customer_stage`(潜在1/重潜2/已成交3) 单字段,**只增不减、不可配置**(硬编码三态,非商机版本化模板),由外部成功结果驱动。本图**只做潜在→重潜**(重潜由方案卡报备驱动,crm-opportunity 已建);**已成交留 seam** 给 A5。
- **[D4] 客户规则落 crm-rule + 全局单例形态(chart grill Q4)** — 客户规则族作 crm-rule 第三族(线索/商机/客户并列)。本图**只做「超期未跟进提醒」**(全局单例配置:总开关 + 首次触发天数(默认30) + 二次提醒间隔(默认7);定时任务 + 钉钉/应用内通知),**不套 V-CONFIG 版本化**(区别于商机规则)。**「客户查重设置」不做**(A4-6 查重执行侧不做,查重口径写死在新增流程)。
- **[D5] 联系人建模(chart grill Q5)** — **联系人 = `crm-customer` 下独立实体表 `customer_contact`**(不单开 crm-contact 模块);每个联系人 `customer_id` 必属一客户(无孤立),权限继承所属客户(联系人无独立 ACL)。**本图做**:联系人 CRUD(列表/详情/新增/编辑)+ 随客户新增内嵌明细 + 全局搜索接口(商机硬依赖)+ 联系人电话查重。**本图不做**:职务二级联动图谱、人脉关系连线、OCR 快捷新增、联系人导入。职务字段先存「职务名称文本 + 可空 标准职务id/类别/层级」三列占位,不实现联动选择器逻辑。
- **[D6] 新增客户查重口径(chart grill Q4-补)** — 新增/编辑客户的**查重校验做**、内建新增流程:① 统一社会信用代码全局唯一(硬拦),② 客户名称相似→保存时确认弹窗(可"仍要创建")。查重阈值/口径**写死**,不做后台可配。A4-6 独立查重页 + 客户合并**不做**。
## Not yet specified
<!-- in-scope fog,随前沿推进 graduate;每张票 resolve 后回到 Decisions so far -->
| 票 | 主题 | 依赖 | 前沿位置 |
|---|---|---|---|
| 01 | 客户域领域词汇 + CONTEXT.md 骨架 (task) | — | ✅ 可开工(无依赖) |
| 02 | **客户主表字段清单** — 扇出最广,多票回填 | — | ✅ 可开工(无依赖) |
| 03 | 归属流转 + 三 workspace + 内置视图查询口径 | 02 | 02 done 后 |
| 04 | 新增查重校验 + 企查查工商回填 seam | 02 | 02 done 后 |
| 05 | 客户阶段(潜在→重潜→已成交,只前进;本图只做前两态) | 02 | 02 done 后 |
| 06 | 联系人 CRUD + 全局搜索 + 商机侧硬依赖接口 | 02 | 02 done 后 |
| 07 | 客户详情 8 页签 + 跟进记录 + 操作日志 | 02, 05 | 02+05 done 后 |
| 08 | 客户交割(跨模块同步商机/项目负责人事务) | 02, 03 | 02+03 done 后 |
| 09 | 客户导入(Excel,OCR 划出) | 02 | 02 done 后 |
| 10 | 客户规则族「超期未跟进提醒」(crm-rule 全局单例) | — | ✅ 可开工(无依赖) |
| 11 | 自定义视图接入 crm-preference(复用商机 saved-view) | 03 | 03 done 后(task) |
**Frontier(当下可并行开工的票)**:`01`(task,建模块+CONTEXT 骨架)、`02`(主表,扇出最广,优先起 research)、`10`(规则单例,无依赖可并行)。02 resolve 后 03/04/05/06/09 集体解冻,再往下推。
**已知 grill 挂号**(不阻塞开票,各票 resolve 时问用户拍板):
- **票 04**:客户名称相似查重的算法/阈值(Trigram / MySQL 全文 / 编辑距离…)
- **票 05**:重潜的触发点到底是「方案卡提交(状态2)」还是「方案卡推送/报备成功(状态3)」?
- **票 08**:直属销售总监怎么定(是否在 SysUser 加 `superior_user_id`)?
- **票 09**:Excel 导入的相似客户处理策略(跳过/创建带标/报错)
- **票 10**:"规则在客户新增时生效"的确认解读——新增客户时初始化 `last_valid_follow_time = create_time`,30 天没跟进首次提醒(本条已在 grill Q4-补 与用户初步对齐,票 resolve 时最终确认)
## Out of scope
<!-- 越过目的地,划出本 effort;不 graduate 除非目的地重画 -->
- **战略协议**(A4-4-9/10 等级/新增/编辑)— 独立子域,另 effort。
- **联系人图谱**(职务二级联动树 + 人脉关系连线 + 层级自动生成)— 复杂可视化,另 effort;本图职务只留占位列。
- **客户查重列表 + 客户合并**(A4-6-1/2/3)— grill Q4 拍板不做;查重口径写死在新增流程。
- **OCR 快捷新增**(联系人/客户 OCR 识别)— 外部服务,划出;只在新增流程留 seam。
- **企查查等工商数据服务内部**— 只定调用契约 seam(调用点/回填范围/异常/精确查重触发),不深挖服务实现。
- **客户阶段「已成交客户」**(由 A5 项目首次中标驱动)— 留 seam,本图只做潜在→重潜。
- **「关联项目」详情页签**(读 A5 项目模块)— A5 未建,划出。
- **联系人导入**— 划出(客户导入本图做,联系人导入随图谱 effort)。

50
.scratch/opp-customer-dependency/demo-addcustomer.js

@ -0,0 +1,50 @@
/* ---------- 添加关联客户(票04 客户信息区联调:类型筛选/搜索/联系人级联/角色/主要) ---------- */
let _custPicked = null;
async function dlgAddCustomer() {
_custPicked = null;
openDlg('添加关联客户', `
${fld('客户类型(筛选)', `<select name="customerType"><option value="">全部客户类型</option>${dictOpts('customer_type')}</select>`)}
${fld('关联客户 *', `<div style="display:flex;gap:6px"><input name="custKw" placeholder="输入客户名关键字" style="flex:1"><button type="button" onclick="doCustSearch()">搜索</button></div>
<div id="custResult" style="max-height:160px;overflow:auto;margin-top:6px"></div>
<div id="custPicked" class="hint">未选择客户</div>`)}
${fld('客户联系人(选客户后级联)', `<select name="customerContactId" id="custContactSel"><option value="">—先选客户—</option></select>`)}
${fld('客户角色 *', `<select name="customerRole"><option value="">—必选—</option>${dictOpts('customer_role')}</select>`)}
${fld('是否主要客户 *', '<select name="isPrimaryIntended"><option value="0">否</option><option value="1">是</option></select>')}
<div id="addCustErr"></div>
<div class="hint">类型/角色下拉取自 dict enabled-listcustomer_type/customer_role搜索走 POST /api/opportunity/customer/search联系人级联走 GET /api/opportunity/customer/contacts?customerId=A4 前为降级 stub返回空属正常提交走 POST /api/opportunity/customer/add</div>`,
[{ t: '确定关联', cls: 'ok', fn: doAddCustomer }]);
}
async function doCustSearch() {
const box = document.getElementById('custResult');
box.innerHTML = '搜索中…';
const d = await tryApi('customer/search', () => api('POST', '/api/opportunity/customer/search',
{ form: { oppId: S.oppId, keyword: v('custKw') || '', pageNum: 1, pageSize: 10 } }));
const rows = (d && (d.content || d.records)) || [];
box.innerHTML = rows.map(c =>
`<div class="kv" style="cursor:pointer;padding:4px 6px" onclick='pickCust(${JSON.stringify(String(c.customerId))},${JSON.stringify(String(c.customerName || ''))})'>· ${esc(c.customerName || c.customerId)} <span class="hint">id=${esc(c.customerId)}</span></div>`).join('')
|| '<div class="empty">无匹配客户</div>';
}
async function pickCust(cid, cname) {
_custPicked = { customerId: cid, customerName: cname };
document.getElementById('custPicked').innerHTML = `已选:<b>${esc(cname)}</b>(id=${esc(cid)}`;
// 级联刷新客户联系人(切换客户 → 重新拉取 → 清空重选)
const sel = document.getElementById('custContactSel');
sel.innerHTML = '<option value="">加载中…</option>';
const contacts = await tryApi('customer/contacts', () => api('GET', '/api/opportunity/customer/contacts', { params: { customerId: cid } }), true) || [];
sel.innerHTML = '<option value="">(可空)</option>' + contacts.map(ct =>
`<option value="${esc(ct.contactId)}">${esc(ct.name)}${ct.company ? '(' + esc(ct.company) + ')' : ''}</option>`).join('')
+ (contacts.length ? '' : '<!-- stub 空 -->');
if (!contacts.length) sel.insertAdjacentHTML('beforeend', '<option value="" disabled>(A4 前 stub 无联系人,级联已触发)</option>');
}
async function doAddCustomer() {
const err = document.getElementById('addCustErr');
if (!_custPicked) { err.innerHTML = '<div class="errbox">请先搜索并选择关联客户</div>'; return; }
if (!v('customerRole')) { err.innerHTML = '<div class="errbox">客户角色必选</div>'; return; }
const form = { oppId: S.oppId, customerId: _custPicked.customerId,
customerNameSnapshot: _custPicked.customerName, customerRole: v('customerRole'),
isPrimaryIntended: v('isPrimaryIntended') };
const ok = await okApi('customer/add', () => api('POST', '/api/opportunity/customer/add', { form }));
if (ok) { toast('关联客户成功', 'ok'); closeDlg(); loadSub('customer'); }
}
/* ---------- 新建商机(flat create;D-14 后六必填=名称/来源/招标形式/省/市/属地) ---------- */

BIN
.scratch/opp-customer-dependency/demo04-1-customer-subtab.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/opp-customer-dependency/demo04-2-dialog-open.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
.scratch/opp-customer-dependency/demo04-3-search-results.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
.scratch/opp-customer-dependency/demo04-4-selected-cascade.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
.scratch/opp-customer-dependency/demo04-5-after-add.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

84
.scratch/opp-customer-dependency/drive-demo04.py

@ -0,0 +1,84 @@
import sys, time, json
from playwright.sync_api import sync_playwright
DEMO = 'file:///D:/code/crm-backend-matt/.scratch/opportunity-e2e/demo/index.html'
OUT = 'D:/code/crm-backend-matt/.scratch/opp-customer-dependency'
logs = []
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
pg = b.new_page()
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}'))
pg.goto(DEMO)
pg.wait_for_load_state('networkidle')
# 1) login as first user (switchUser triggers login() which loads dicts+list)
pg.evaluate("switchUser(0)")
pg.wait_for_timeout(2500)
# verify dicts loaded incl customer_type/customer_role
dict_counts = pg.evaluate("({customer_type:(S.dict['customer_type']||[]).length, customer_role:(S.dict['customer_role']||[]).length})")
print('DICT COUNTS:', dict_counts)
# 2) pick first opp id from list, open detail
oppId = pg.evaluate("""async () => {
const d = await api('POST','/api/opportunity/page',{form:{viewType:'MINE',current:1,size:5}});
const recs = (d && (d.content||d.records))||[]; return recs.length?String(recs[0].id):null;
}""")
print('OPP ID:', oppId)
pg.evaluate(f"loadDetail('{oppId}')")
pg.wait_for_timeout(1500)
# 3) go to customer subtab
pg.evaluate("S.subTab='customer';renderSubTabs();loadSub('customer')")
pg.wait_for_timeout(1200)
pg.screenshot(path=f'{OUT}/demo04-1-customer-subtab.png', full_page=True)
# 4) open add-customer dialog
pg.evaluate("dlgAddCustomer()")
pg.wait_for_timeout(600)
# check customer_type + customer_role options rendered in dialog
opt_check = pg.evaluate("""() => {
const dlg = document.querySelector('dialog[open]') || document.querySelector('dialog');
const ct = dlg.querySelector('[name=customerType]'); const cr = dlg.querySelector('[name=customerRole]');
return {customerType_opts: ct?ct.options.length:-1, customerRole_opts: cr?cr.options.length:-1};
}""")
print('DIALOG OPTS:', opt_check)
pg.screenshot(path=f'{OUT}/demo04-2-dialog-open.png', full_page=True)
# 5) search customers
pg.evaluate("document.querySelector('dialog[open] [name=custKw]').value=''")
pg.evaluate("doCustSearch()")
pg.wait_for_timeout(1200)
search_rows = pg.evaluate("document.querySelectorAll('#custResult .kv').length")
print('SEARCH ROWS:', search_rows)
pg.screenshot(path=f'{OUT}/demo04-3-search-results.png', full_page=True)
# 6) pick first candidate -> triggers contacts cascade
picked = pg.evaluate("""async () => {
const first = document.querySelector('#custResult .kv');
if(!first) return null;
first.click();
return true;
}""")
pg.wait_for_timeout(1200)
cascade = pg.evaluate("""() => {
const sel = document.getElementById('custContactSel');
const picked = document.getElementById('custPicked').textContent;
return {contact_options: sel?sel.options.length:-1, picked_text: picked};
}""")
print('PICKED:', picked, 'CASCADE:', cascade)
pg.screenshot(path=f'{OUT}/demo04-4-selected-cascade.png', full_page=True)
# 7) choose a role and submit
pg.evaluate("""() => {
const cr = document.querySelector('dialog[open] [name=customerRole]');
if(cr && cr.options.length>1) cr.value = cr.options[1].value;
}""")
pg.evaluate("doAddCustomer()")
pg.wait_for_timeout(1800)
# 8) back on customer subtab, count linked customers
linked = pg.evaluate("""async () => {
const d = await api('GET','/api/opportunity/customer/list',{params:{oppId: S.oppId}});
return (d||[]).length;
}""")
print('LINKED CUSTOMERS AFTER ADD:', linked)
pg.screenshot(path=f'{OUT}/demo04-5-after-add.png', full_page=True)
b.close()
print('--- CONSOLE (errors only) ---')
for l in logs:
if l.startswith('error'): print(l)
print('done')

16
.scratch/opp-customer-dependency/issues/01-customer-type-dict-seed.md

@ -0,0 +1,16 @@
# 01 — 补 customer_type 字典 seed
**What to build:** 前端商机新增/编辑页弹窗的"客户类型"筛选下拉、以及客户信息 Tab 的"客户类型"列,能通过通用字典接口 `GET /api/dict/item/enabled-list?groupCode=customer_type` 拿到选项数据,不再开天窗。选项为原型实样 6 项:总包 / 工程商 / 投资方 / 设计院 / 集成商 / 投标公司。仿已存在的 `customer_role` 分组 seed,纯数据 seed,无表结构变更。
**Blocked by:** None — can start immediately
**Status:** resolved
- [ ] `DictDataInitializer` 中新增 `customer_type` 分组 seed,含 6 个启用项(总包/工程商/投资方/设计院/集成商/投标公司)
- [ ] 编码风格对齐同文件既有 `customer_role` 分组 seed(GroupSeed + ItemSeed 结构、排序、注释口径)
- [ ] 服务启动后 `GET /api/dict/item/enabled-list?groupCode=customer_type` 返回 6 个启用项
- [ ] `customer_role` 原有 seed 不受影响(回归验证仍出 6 项)
## Answer
`DictDataInitializer.SEED_GROUPS` 末尾 `attachment_type` 后新增 `customer_type` 分组(sort=23),6 个启用项 customer_type_01..06 = 总包/工程商/投资方/设计院/集成商/投标公司,结构对齐既有 `customer_role`。`customer_role` seed 未改动。crm-dict 编译通过(BUILD SUCCESS)。前端走通用 `GET /api/dict/item/enabled-list?groupCode=customer_type` 即出 6 项(起服务后 #03 回归验证出数)。

21
.scratch/opp-customer-dependency/issues/02-customer-contacts-stub.md

@ -0,0 +1,21 @@
# 02 — 客户联系人级联接口 stub
**What to build:** 前端在商机新增/编辑页(字段17)及"添加关联客户"弹窗中,选中关联客户后"客户联系人"下拉会按已选客户级联刷新。当前该接口无实现,前端打请求 404、级联联动无契约可对。本票在商机侧补一个降级 stub 端点(对齐现有快照池降级套路,如 `GET /api/opportunity/customer/{customerId}/contacts`),返回空列表或固定 mock,让前端级联逻辑能对上出参契约、不 404。真实联系人数据待 A4 客户模块建成后补真源,本票不建快照子表、不接真数据。
**Blocked by:** None — can start immediately
**Status:** resolved
- [ ] 商机侧新增按客户查联系人的 stub 端点,路径风格对齐 `OpportunitySubController` 现有客户 Tab 端点
- [ ] 出参契约字段齐备(至少 contactId / name / phone / company / position),返回空列表或固定 mock
- [ ] 端点在 Swagger 分组下可见,前端可自描述联调
- [ ] 代码注释标注"降级 stub,A4 就位后切真源",与快照池现状口径一致
- [ ] 空客户 / 不存在客户 id 时返回空列表而非报错
## Answer
新增 `CustomerContactDTO`(contactId/name/phone/company/position)+ 服务方法 `listCustomerContacts(customerId)`(impl 返回空 ArrayList,注释标注降级 stub、A4 切真源、签名不变)+ 控制器端点 `GET /api/opportunity/customer/{customerId}/contacts`(Swagger「A3 商机管理/商机详情/客户」分组可见)。空/不存在 customerId 返回空列表不报错。crm-opportunity 编译通过(BUILD SUCCESS)。
## 路由更正(20260901)
初版误用 REST 路径参数 `GET /customer/{customerId}/contacts`,违反本 Controller 既定规范(类注释白纸黑字:「GET 查询,id 走 @RequestParam」,全域客户/跟进/勘察/团队端点一致)。已改为 **`GET /api/opportunity/customer/contacts?customerId=`**(`@RequestParam`),对齐 `/customer/list?oppId=` 风格。重编译 + 重打 jar + 回归全绿(新路由 200/空数组/边界不报错),demo 与 verify-03.py 调用同步改址。

29
.scratch/opp-customer-dependency/issues/03-frontend-integration-regression.md

@ -0,0 +1,29 @@
# 03 — 商机前端联调全链路回归
**What to build:** 起服务,实跑一遍商机前端主链路,确认前端工程师能端到端联调通过:在商机新增页"搜客户 → 选客户 → 选客户角色 → 客户联系人级联刷新 → 提交新增商机"整条路径全绿,两个字典下拉(customer_role + customer_type)出数,客户联系人级联端点不 404。同时与前端对齐 baseURL 契约(商机侧客户能力走 `/api/opportunity/customer/*`,而非报告理想路径 `/api/customer/*`),如有偏差记录并约定别名或前端改址。
**Blocked by:** 01, 02
**Status:** resolved
- [ ] 启动服务,`enabled-list` 对 customer_role 与 customer_type 均出数
- [ ] 候选客户搜索 `POST /api/opportunity/customer/search` 返回 customerId + customerName
- [ ] 客户联系人 stub 端点按已选客户返回契约一致的响应(空/mock),前端级联可对上、不 404
- [ ] 走通"搜客户 → 选客户 → 选角色 → 联系人级联 → 提交新增商机"全链路,无 404 / 500
- [ ] 与前端确认客户能力实际路径(`/api/opportunity/customer/*`),偏差点记录在案并给出对齐方案
## Answer
重打 crm-app fat jar(含 #01/#02 源码改动)→ `SPRING_PROFILES_ACTIVE=verify` 起服务(连远程 DB 8.129.84.155,Tomcat 8080,Started 成功)→ 跑 `verify-03.py` 回归,全绿:
| 校验 | 结果 |
|---|---|
| `GET /api/dict/item/enabled-list?groupCode=customer_role` | 200 success,6 项 |
| `GET /api/dict/item/enabled-list?groupCode=customer_type` | 200 success,6 项(总包/工程商/投资方/设计院/集成商/投标公司,原始字节 `\xe6\x80\xbb\xe5\x8c\x85`=UTF-8「总包」,控制台乱码为 GBG 终端所致,接口出参正确) |
| `POST /api/opportunity/customer/search`(oppId=749662392819908608, keyword='') | 200 success,返回 10 条 customerId+customerName |
| `GET /api/opportunity/customer/contacts?customerId=` | 200 success,data 为空数组(stub),不 404 |
| 不存在客户 id `?customerId=999999999` | 200 success,空数组,不报错 |
**baseURL 契约结论**:客户能力实际路径为 `/api/opportunity/customer/*`(非报告理想的 `/api/customer/*`)。前端 demo(#04)按 `/api/opportunity/customer/*` 对接;A4 客户模块建成后如迁到独立 `/api/customer/*`,届时前端改址或后端加别名(本期不做)。
结果留存:`.scratch/opp-customer-dependency/verify-03.py` / `verify-03-result.json`

35
.scratch/opp-customer-dependency/issues/04-demo-frontend-acceptance.md

@ -0,0 +1,35 @@
# 04 — 前端 demo 客户信息联调验收
**What to build:** 在既有商机 E2E demo(`.scratch/opportunity-e2e/demo/index.html`)上补齐"客户信息区"联调界面,让用户能在浏览器里点着验收前面三张票的成果。demo 需真实调用后端接口,把本次最小联调范围端到端串起来:新增/添加关联客户流程中——"客户类型"与"客户角色"两个下拉从字典接口取真值、关联客户搜索走候选池接口、选中客户后"客户联系人"下拉调 stub 端点级联刷新(空/mock 也要能看出级联在动)、最终提交新增/关联成功。demo 的 dict 预加载已含 `customer_role`,本票补 `customer_type` 分组并把客户信息区控件接上真接口。
**Blocked by:** 01, 02, 03
**Status:** resolved
- [ ] demo 的 `DICT_GROUPS` 预加载补上 `customer_type`,"客户类型"下拉出 6 项真值
- [ ] "客户角色"下拉从 `customer_role` 字典出 6 项真值
- [ ] 关联客户搜索框调 `POST /api/opportunity/customer/search`,输入关键字能出候选客户
- [ ] 选中关联客户后"客户联系人"下拉调 stub 端点级联刷新(切换客户会重新拉取、可见级联行为),空/mock 返回不报错
- [ ] 走通"搜客户→选客户→选角色→联系人级联→提交"整条链路,无 404/500,结果可在 demo 上看到
- [ ] 用户在浏览器打开 demo 完成人工验收
## Answer
在既有 demo `.scratch/opportunity-e2e/demo/index.html` 上完成客户信息区联调改造:
- `DICT_GROUPS` 增补 `customer_type`,demo 启动预加载。
- 关联客户 subtab 新增「+ 添加关联客户」按钮 → `dlgAddCustomer()` 弹窗:客户类型筛选(customer_type 字典)/ 关联客户搜索(`POST /customer/search`)/ 选客户后客户联系人级联(`GET /customer/{id}/contacts` stub)/ 客户角色(customer_role 字典)/ 是否主要 → 提交 `POST /customer/add`
**Playwright 无头回归全绿**(`drive-demo04.py`,连线上后端 :8080 verify profile):
| 校验点 | 结果 |
|---|---|
| 字典预加载 | customer_type=6, customer_role=6 |
| 弹窗下拉渲染 | customerType 7 项(全部+6)、customerRole 7 项(必选+6) |
| 客户搜索 | 返回 10 条候选 |
| 选客户 + 联系人级联 | 选中「华东设计院(E2E第二客户)」,联系人下拉级联刷新(stub 空 → 显示「已触发」提示项) |
| 提交关联 | 成功,客户列表 +1 |
| 控制台错误 | 无 |
截图证据:`.scratch/opp-customer-dependency/demo04-1..5-*.png`(客户subtab / 弹窗 / 搜索结果 / 选中级联 / 关联后)。
**待用户人工验收**:起服务 `SPRING_PROFILES_ACTIVE=verify java -jar crm-app/target/crm-app-1.0.0-SNAPSHOT.jar`,浏览器打开 demo index.html → 选人登录 → 打开任一商机详情 → 关联客户 Tab → +添加关联客户,走一遍搜索/级联/提交。

60
.scratch/opp-customer-dependency/verify-03-result.json

@ -0,0 +1,60 @@
{
"dict:customer_role": {
"http": 200,
"success": true,
"count": 6,
"names": [
"总包",
"工程商",
"投资方",
"设计院",
"集成商",
"投标公司"
]
},
"dict:customer_type": {
"http": 200,
"success": true,
"count": 6,
"names": [
"总包",
"工程商",
"投资方",
"设计院",
"集成商",
"投标公司"
]
},
"pick_opp": {
"http": 200,
"oppId": "749662392819908608",
"total": "5"
},
"customer_search": {
"http": 200,
"success": true,
"count": 10,
"sample": [
{
"customerId": "920000000000000950",
"customerName": "安捷机电安装(seed快照)"
},
{
"customerId": "920000000000000900",
"customerName": "仁济医院分院(seed快照)"
}
]
},
"contacts_stub": {
"http": 200,
"success": true,
"data_is_list": true,
"count": 0
},
"contacts_nonexistent": {
"http": 200,
"success": true,
"data_is_list": true,
"count": 0
}
}

50
.scratch/opp-customer-dependency/verify-03.py

@ -0,0 +1,50 @@
import requests, json, sys
BASE='http://localhost:8080'
UID='739564171091247104'
s=requests.Session()
def tok():
r=s.get(f'{BASE}/api/auth/debug/token',params={'userId':UID},timeout=15)
return r.json()['data']
T=tok()
H={'Authorization':'Bearer '+T}
def g(path,**pp):
return s.get(BASE+path,headers=H,params=pp,timeout=15)
def p(path,**data):
hh=dict(H); hh['Content-Type']='application/x-www-form-urlencoded'
return s.post(BASE+path,headers=hh,data=data,timeout=15)
out={}
# 1) dict enabled-list: customer_role + customer_type
for grp in ['customer_role','customer_type']:
r=g('/api/dict/item/enabled-list',groupCode=grp)
j=r.json()
items=j.get('data') or []
out[f'dict:{grp}']={'http':r.status_code,'success':j.get('success'),'count':len(items),
'names':[i.get('name') for i in items]}
# 2) candidate customer search (need an oppId). find one via page
r=p('/api/opportunity/page',viewType='MINE',pageNum=1,pageSize=5)
recs=(r.json().get('data') or {}).get('content') or []
oppId=recs[0].get('id') if recs else None
out['pick_opp']={'http':r.status_code,'oppId':oppId,'total':(r.json().get('data') or {}).get('total')}
if oppId:
r=p('/api/opportunity/customer/search',oppId=oppId,keyword='',pageNum=1,pageSize=5)
j=r.json(); data=j.get('data') or {}
cand=data.get('content') or data.get('records') or []
out['customer_search']={'http':r.status_code,'success':j.get('success'),'count':len(cand),
'sample':cand[:2]}
# 3) contacts stub for first candidate (or any customerId)
cid=cand[0].get('customerId') if cand else 1
r=g('/api/opportunity/customer/contacts', customerId=cid)
j=r.json()
out['contacts_stub']={'http':r.status_code,'success':j.get('success'),
'data_is_list':isinstance(j.get('data'),list),'count':len(j.get('data') or [])}
# contacts for a non-existent customer -> should still be empty list, not error
r=g('/api/opportunity/customer/contacts', customerId=999999999)
j=r.json()
out['contacts_nonexistent']={'http':r.status_code,'success':j.get('success'),
'data_is_list':isinstance(j.get('data'),list),'count':len(j.get('data') or [])}
print(json.dumps(out,ensure_ascii=False,indent=2))

95
.scratch/opp-customer-dependency/wayfinder/商机前端联调工作量评估.md

@ -0,0 +1,95 @@
# 商机模块前端联调工作量评估(最小可行范围)
> **评估日期**:2026-09-01
> **评估目标**:**仅**为"商机模块前端工程师能顺利与后端联调"这一最小可行范围估算工作量
> **范围纪律**:忽略 A4 客户模块完整功能建设、非必要功能完善、架构重构;只做"让前端各表单/弹窗/下拉框有真接口可打通"必须的事
> **依据**:`商机模块前置客户接口依赖分析报告.md` + 商机侧现状代码交叉核对(`crm-opportunity` / `crm-dict`
---
## 0. 一句话结论
**满足前端联调的最小工作量 ≈ 0.5~1.5 人日**,**远小于**原分析报告隐含的"A4 客户模块提前开发(6 接口 + 2 字典 + 商机侧 5 项配套)"体量。
原因:分析报告是为"**A4 客户模块提前排期**"写的**依赖清单**(回答"客户模块要开发什么");而本次问题是"**前端能不能联调**"(回答"前端打接口会不会 404 / 拿不到数据")。二者范围差一个数量级——**大部分 P0 联调路径商机侧已有降级实现,接口已经在跑,前端现在就能打通。**
---
## 1. 现状核对:哪些接口已经能联调(一手代码证据)
| 前端触点 | 报告列的"依赖接口" | 商机侧现状 | 前端能否联调 |
|---|---|---|---|
| 关联客户搜索(字段15 / 弹窗查询区) | `POST /api/customer/search` | **已存在** `POST /api/opportunity/customer/search`(快照池降级,`OpportunityCustomerMapper.searchCandidatePool`) | ✅ **现在就能打**,返回 `customerId + customerName` |
| 添加关联客户提交 | 客户写入 | **已存在** `POST /api/opportunity/customer/add`(`OpportunityCustomerAddDTO`:customerId/角色/是否主要) | ✅ 能打通"选客户→提交"闭环 |
| 客户列表 / 设主要 | — | **已存在** `/customer/list`、`/customer/set-primary` | ✅ |
| 客户角色下拉(字段16 / 弹窗) | crm-dict `customer_role` | **已 seed**(`DictDataInitializer` 6 项:总包/工程商/…/投标公司) | ✅ 走通用 `GET /api/dict/item/enabled-list?groupCode=customer_role` |
| 新建商机主表单提交 | `POST /api/opportunity` | **已存在** `OpportunityCreateController` + `CreateOpportunityRequest`(已含 customerId/customerName 快照位) | ✅ 主链路可提交 |
| 客户类型下拉(弹窗筛选) | crm-dict `customer_type` | **未 seed** ❌ | ⚠ 下拉开天窗(见 §2 缺口1) |
| 客户联系人(字段17 级联 / 弹窗) | `GET /api/customer/{id}/contacts` | **无任何实现** ❌ | ❌ 打了 404(见 §2 缺口2) |
| 商机关键联系人全局搜索(字段13/14) | `GET /api/customer/contact/search` | **无实现**,且商机主表未建模 ❌ | ❌(P1,非必填,不阻塞主链路) |
| 快速创建客户(弹窗入口) | `POST /api/customer/quick-create` | **无实现** ❌ | ❌(P1,表单无此入口,不阻塞) |
| 查看客户详情跳转(Tab) | `GET /api/customer/{id}/detail` | **无实现** ❌ | ❌(P2,展示增强,不阻塞) |
**结论**:报告 P0 三件里,`customer/search` 和 `customer/{id}`(回显所需字段)实质已被商机侧降级接口覆盖,**唯一真正阻塞主链路联调的硬缺口是"按客户查联系人"(字段17 级联)**,外加一条 `customer_type` 字典 seed。
---
## 2. 满足前端联调必须补的缺口(最小集)
### 缺口 1 — `customer_type` 字典 seed 【必做,工作量最小】
- **现状**:`customer_role` 已 seed,`customer_type` 完全缺失。
- **联调影响**:弹窗"客户类型"筛选下拉、客户信息 Tab"客户类型"列取不到选项 → 下拉开天窗。
- **最小做法**:在 `DictDataInitializer` 仿 `customer_role` 加一条 `GroupSeed("customer_type", "客户类型", …)`,6 项(总包/工程商/投资方/设计院/集成商/投标公司)。**纯 seed,无表结构变更。**
- **工作量**:**~0.5 h**(含重启验证 `enabled-list` 出数)。
### 缺口 2 — 客户联系人接口(字段17 级联)【主链路联调硬依赖】
- **现状**:`GET /api/customer/{id}/contacts` 无实现;商机侧亦无联系人子表。
- **联调影响**:验收标准 8"选关联客户后客户联系人范围同步更新"打不通 → 前端级联联动无接口可对。
- **最小做法(联调桩,非 A4 真实现)**:在商机侧加一个**降级 stub 端点**(对齐现有快照池套路),例如 `GET /api/opportunity/customer/{customerId}/contacts`,返回**空列表或固定 mock**,让前端级联逻辑能对上契约、不 404。真实数据待 A4。
- 若产品要求联调阶段就有真联系人数据,则需商机侧补 `opportunity_customer_contact` 快照子表 + 查询——**这已超出"最小联调"范围,建议延后**。
- **工作量**:**stub 版 ~1~2 h**;真实子表版 ~1 人日(不建议纳入本次)。
### 缺口 3 — 候选池出参补列(可选,联调体验增强)
- **现状**:`OpportunityCustomerCandidateDTO` 只有 `customerId + customerName`;弹窗结果表要 7 列(类型/地区/联系人/电话/负责人)。
- **联调影响**:**不阻塞**——前端能拿到 id+名即可完成"选客户→提交"闭环;其余列前端可先留空/占位。
- **建议**:**本次不做**(属 A4 真源就位后补齐,签名不变)。
---
## 3. 明确排除项(不在"满足联调"范围)
| 排除项 | 报告优先级 | 排除理由 |
|---|---|---|
| A4 客户模块建库建模(`crm-customer` 模块、customer 主表) | 报告全篇前提 | 联调不需要真模块,降级接口已覆盖主链路 |
| `GET /api/customer/contact/search`(字段13/14 全局联系人搜索) | P1 | 非必填字段,主链路"选客户→选联系人→提交"不含此字段也走得通 |
| `POST /api/customer/quick-create` | P1 | 表单无此入口,仅弹窗有,不阻塞表单联调 |
| `GET /api/customer/{id}/detail` | P2 | 展示增强,可用基础信息接口过渡 |
| 商机侧字段13/14 主表建模(v29 增量) | §5.3 | 需产品拍板,属功能完善非联调阻塞 |
| 快照池切真源、出参7列补齐 | §5.4 / §缺口3 | A4 就位后的事,签名不变,联调路径不受影响 |
| 电话脱敏口径、重复商机校验阈值等待确认项 | §6 | 报告已注明"不阻塞接口排期" |
---
## 4. 工作量汇总
| 任务 | 必要性 | 工作量 |
|---|---|---|
| ① `customer_type` 字典 seed(仿 customer_role) | **必做** | 0.5 h |
| ② 客户联系人接口 stub(`/customer/{id}/contacts` 返回空/mock,防 404) | **必做** | 1~2 h |
| ③ 联调回归:起服务,走一遍"搜客户→选客户→选角色→(联系人级联)→提交"全链路 + dict enabled-list 出数 | **必做** | 1~2 h |
| **小计(最小可行联调)** | | **约 0.5~1 人日** |
| ④(可选)候选池出参补 7 列 + 商机侧字段13/14 建模 | 缓 | +1~1.5 人日 |
> **给排期的一句话**:只为"前端能联调"——**半天到一天**即可(一条字典 seed + 一个联系人 stub 端点 + 一轮联调回归)。报告里那份 A4 完整接口清单是**客户模块自身的开发排期**,不应算进"商机前端联调"的工时里。
---
## 5. 关键风险 / 待产品确认(影响估算的变量)
1. **联系人接口要不要真数据?** 若联调阶段前端就要看到真实联系人级联(而非空 stub),工作量从 2 h 升到 ~1 人日(需建 `opportunity_customer_contact` 快照子表)。**建议先 stub,A4 就位补真源。**
2. **前端是否已按 `/api/opportunity/customer/*` 契约开发?** 若前端按报告里的理想路径 `/api/customer/*` 编码,则需前端改 baseURL 或后端加路由别名——需双方对齐一次(0.5 h 沟通)。
3. **字段13/14 是否本期联调?** 若产品坚持本期联调关键联系人,需先拍板建模再估(超本次范围)。
---
_评估基于:分析报告 + `OpportunitySubController` / `OpportunityCustomerAddDTO` / `OpportunityCustomerMapper` / `CreateOpportunityRequest` / `DictDataInitializer` / `DictItemController` 现状代码交叉核对。_

BIN
.scratch/opportunity-acceptance/boot-run.log

Binary file not shown.

13
.scratch/opportunity-board-display-name/issues/01-stage-template-display-name-unique.md

@ -0,0 +1,13 @@
# 01 — 阶段模板发布校验:同一版本内展示名唯一
**What to build:** 管理员保存/发布阶段模板时,同一模板版本内若出现重复展示名(展示名 = `custom_node_name` 优先、空则带入 `opp_stage` 字典名),发布被拒并返回既有错误码;跨模板同名不受限。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 发布校验能识别同版本内两节点的最终展示名相同(含「自定义名撞自定义名」「自定义名撞回落字典名」两种情况)
- [ ] 校验失败时返回既有阶段模板错误码(64013 / 64017),错误信息指明哪两个节点撞名
- [ ] 仅草稿可改规则不变;发布中/已停用版本仍不可直接改
- [ ] 跨模板同名不触发校验(看板按展示名合并列是允许语义)
- [ ] 覆盖校验逻辑的单测

12
.scratch/opportunity-board-display-name/issues/02-stage-display-name-resolution-contract.md

@ -0,0 +1,12 @@
# 02 — 展示名解析契约(crm-rule 侧能力)
**What to build:** `IOpportunityStageTemplateService` 补齐两个批量能力,供看板两端点复用:① 节点 id → 展示名(`custom_node_name` 优先、空则字典名)的批量反查;② 展示名 → 匹配的节点 id 集合(跨模板同名的节点全部命中)。展示名规则仍由 crm-rule 内部独占,不外泄节点实体与排序知识。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 提供「节点 id 集合 → 展示名映射」的批量查询(同页去重、避免 N+1)
- [ ] 提供「展示名 → 节点 id 集合」的批量反查(跨模板同名全部返回)
- [ ] 展示名回落规则与 `describeStages` 一致:`custom_node_name` 非空取之,否则取 `opp_stage` 字典名
- [ ] 覆盖两个能力的单测

13
.scratch/opportunity-board-display-name/issues/03-board-drop-status-filter.md

@ -0,0 +1,13 @@
# 03 — 看板基础数据集撤销状态过滤
**What to build:** 非公海视图的看板不再叠加 `推进中(2)` 状态过滤。只要商机的阶段有值(`current_stage_id` 非空)就上板——暂缓中/已关闭/已转项目按各自当前节点归列;无阶段值的不上板。viewType 的可见性(MINE/关注/管理/公海)保留不变。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 看板基础数据集不再按 `opp_status = 推进中` 过滤
- [ ] `current_stage_id` 非空的商机(含暂缓中/已关闭/已转项目)都出现在看板聚合结果中
- [ ] `current_stage_id` 为空的商机不上板
- [ ] 公海视图的「待领取且无 owner」工作区定义不受影响
- [ ] 覆盖数据集口径的单测

15
.scratch/opportunity-board-display-name/issues/04-board-group-by-display-name.md

@ -0,0 +1,15 @@
# 04 — 看板按展示名分列(summary + cards 两端点)
**What to build:** `boardStageSummary` 改为按「当前节点的展示名」分组,返回完整有序列摘要——空列 `count=0` 也返回,列顺序由视口阶段模板定(`stageTemplateId` 纯视口选择器,null → 当前用户所属部门绑定模板 → 全公司通用默认模板),视口外展示名按各自模板 `seq_no` 追加列末,跨模板同名合并为一列。`boardCards` 入参从 `stageId`(Long) 改为 `stageName`(String),后端解析成「展示名相等的节点 id 集合」取卡片,卡片新增 `oppStatus`
**Blocked by:** 02、03
**Status:** ready-for-agent
- [ ] `boardStageSummary` 返回 `[{stageName, count, totalAmount}]`,列键为展示名字符串,不再返回节点 id
- [ ] 空列以 `count=0` 出现在返回中,按视口顺序排列
- [ ] 视口外展示名追加在列末,顺序确定(按各自模板 seq_no,撞名取最靠前位置)
- [ ] `stageTemplateId` 只影响列顺序,不过滤哪些商机上板
- [ ] `boardCards` 入参改为 `stageName`,后端解析成节点 id 集合过滤,跨模板同名一次取全
- [ ] 卡片返回带 `oppStatus`
- [ ] 覆盖两个端点的单测

14
.scratch/opportunity-board-display-name/issues/05-board-integration-tests.md

@ -0,0 +1,14 @@
# 05 — 看板按展示名分列的集成测试
**What to build:** 走 API 的端到端集成测试,锁定 01–04 的组合语义:多模板同字典值不同展示名合并列、视口排序 + 视口外追加、空列返回、跨状态上板(暂缓/关闭/已转项目)、无阶段值不上板、cards 按 `stageName` 取卡带 `oppStatus`、阶段模板发布校验拒绝重复展示名。
**Blocked by:** 01、02、03、04
**Status:** ready-for-agent
- [ ] 集成测试覆盖 summary 按展示名分组与视口列顺序
- [ ] 集成测试覆盖跨模板同名合并为一列
- [ ] 集成测试覆盖空列以 `count=0` 返回
- [ ] 集成测试覆盖暂缓中/已关闭/已转项目上板、无阶段值不上板
- [ ] 集成测试覆盖 cards 按 `stageName` 取卡且卡片带 `oppStatus`
- [ ] 集成测试覆盖阶段模板发布校验拒绝重复展示名

12
.scratch/opportunity-board-display-name/issues/06-board-api-docs.md

@ -0,0 +1,12 @@
# 06 — 更新看板接口文档
**What to build:** 更新 `board/stage-summary`、`board/cards` 的接口文档(Bruno 集合或等价 API 文档),反映新契约:summary 返回 `stageName/count/totalAmount` 按展示名分组、空列语义;cards 入参 `stageName`、卡片带 `oppStatus`;移除旧 `stageId` 契约说明。
**Blocked by:** 04
**Status:** ready-for-agent
- [ ] summary 文档描述按展示名分组、空列 `count=0`、视口列顺序语义
- [ ] cards 文档描述入参 `stageName` 与跨模板同名合并取卡语义
- [ ] 卡片字段文档包含 `oppStatus`
- [ ] 旧 `stageId` 契约说明移除

12
.scratch/opportunity-board-display-name/issues/07-code-review.md

@ -0,0 +1,12 @@
# 07 — code-review
**What to build:** 对本次全部改动跑一遍 code-review(Standards + Spec 双轴),对照 ADR-0032 与 `crm-opportunity/CONTEXT.md` 看板定义核查实现一致性,重点:分组键确为展示名、状态过滤确已撤销、视口只定序不过滤、发布校验确已落地、API 契约与 04 定义一致。
**Blocked by:** 01、02、03、04、05、06
**Status:** ready-for-agent
- [ ] Standards 轴:编码规范(UTF-8 无 BOM、单测、命名)通过
- [ ] Spec 轴:实现与 ADR-0032 + CONTEXT 定义逐条一致
- [ ] 无遗留的按节点 id / 按状态分列代码路径
- [ ] 审查问题已记录并闭环或转 wontfix

157
.scratch/opportunity-e2e/demo/index.html

@ -61,6 +61,16 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
.hint { color: #999; font-size: 12px; }
.loading { color: #409eff; }
.mark { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 8px; background: #ececec; color: #8a8a8a; font-size: 11px; vertical-align: middle; cursor: help; }
.lanes { display: flex; gap: 10px; overflow-x: auto; padding: 8px 0 4px; align-items: flex-start; }
.lane { min-width: 224px; width: 224px; flex-shrink: 0; background: #f7f8fa; border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px; }
.laneHead { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
.laneBody { display: flex; flex-direction: column; gap: 6px; min-height: 30px; }
.laneCard { background: #fff; border: 1px solid #e4e7ed; border-radius: 4px; padding: 6px 8px; cursor: pointer; font-size: 12px; display: flex; flex-direction: column; gap: 2px; }
.laneCard:hover { border-color: #409eff; background: #f0f7ff; }
.laneFoot { text-align: center; margin-top: 6px; }
.laneFoot button { padding: 2px 10px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; }
fieldset { border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 10px; }
fieldset legend { padding: 0 6px; font-size: 12px; color: #909399; }
</style>
</head>
<body>
@ -68,6 +78,8 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<h1>商机模块 E2E Demo</h1>
<span>当前用户:</span>
<select id="userSel" onchange="switchUser(this.value)"></select>
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:250px;border-radius:4px;border:1px solid #45566b;background:#2c3e50;color:#fff;padding:4px 8px">
<button onclick="loginByUid()">uid 登录</button>
<span class="who" id="whoBox">未登录</span>
<span class="sp" style="flex:1"></span>
<button class="primary" onclick="dlgCreate()">+ 新建商机</button>
@ -102,6 +114,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
</div>
</div>
<div class="panel"><b>看板汇总(当前视图)<span class="mark" title="D-15:PUBLIC_POOL 视图 summary 未按视图状态过滤(7 vs 实际 2)">D-15</span></b><div id="boardBox" class="stagebar"></div></div>
<div class="panel"><b>看板泳道(当前视图 · 按展示名取卡,offset+limit 滚动加载)</b><div id="laneBox" class="lanes"><span class="hint">随视图加载,每列先拉 5 条,点「更多」续拉</span></div></div>
</section>
<section id="tab-detail" style="display:none">
@ -122,7 +135,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<script>
/* ================= 常量与状态 ================= */
const BASE = 'http://localhost:8080';
const BASE = 'https://ai.itc.vip/crm-api'; // 20260901 验收切测试环境(原 http://localhost:8080,本地起服见 boot-verify.md)
const USERS = [
{ uid: '739564171091247104', name: '罗伟健(管理员,全量可见)' },
{ uid: '744842565802524672', name: '赖永利(A·特战团队)' },
@ -173,6 +186,12 @@ async function okApi(step, fn) {
}
/* ================= 登录选人 ================= */
function loginByUid() {
const uid = document.getElementById('uidInput').value.trim();
if (!uid) { toast('请先输入 userId(测试环境库的真实雪花 id,下拉里的开发库 uid 不适用)', 'err'); return; }
S.user = { uid: uid, name: 'uid直登' };
login();
}
function switchUser(idx) {
S.user = USERS[idx];
localStorage.setItem('demoUser', idx);
@ -190,7 +209,7 @@ async function login() {
}
/* ================= 字典 ================= */
const DICT_GROUPS = ['opp_source', 'pause_reason', 'close_reason', 'pool_reason', 'bid_form', 'locality_type', 'customer_role', 'follow_way', 'survey_seq', 'apply_way', 'result_tag', 'industry', 'opp_stage', 'attachment_type'];
const DICT_GROUPS = ['opp_source', 'pause_reason', 'close_reason', 'pool_reason', 'bid_form', 'locality_type', 'customer_role', 'customer_type', 'follow_way', 'survey_seq', 'apply_way', 'result_tag', 'industry', 'opp_stage', 'attachment_type'];
async function loadDicts() {
for (const g of DICT_GROUPS) {
const items = await tryApi('dict:' + g, () => api('GET', '/api/dict/item/enabled-list', { params: { groupCode: g } }), true);
@ -266,10 +285,39 @@ async function loadList(delta) {
}
function badge(st) { const [n, c] = STATUS[st] || [st, 'b1']; return `<span class="badge ${c}">${n}</span>`; }
async function loadBoard() {
document.getElementById('boardBox').innerHTML = '<span class="loading"></span>';
const box = document.getElementById('boardBox');
box.innerHTML = '<span class="loading"></span>';
const d = await tryApi('board', () => api('POST', '/api/opportunity/board/stage-summary', { form: { viewType: S.view } }));
document.getElementById('boardBox').innerHTML = d && d.length ? d.map(x =>
box.innerHTML = d && d.length ? d.map(x =>
`<span class="node">${esc(x.stageName || x.stageId)}:<b>${x.count}</b> 条 / ¥${x.totalAmount ?? 0}</span>`).join('') : '<span class="empty">无汇总数据</span>';
loadLanes(d || []);
}
/* ---------- 看板泳道(按展示名调 board/cards,offset+limit 滚动加载,返回 < limit 即到底 ---------- */
async function loadLanes(summary) {
const box = document.getElementById('laneBox');
S.lanes = (summary || []).map(x => ({ stageName: x.stageName || String(x.stageId), count: x.count || 0, offset: 0, limit: 5, done: false }));
if (!S.lanes.length) { box.innerHTML = '<span class="empty">无泳道数据</span>'; return; }
box.innerHTML = S.lanes.map((l, i) => `
<div class="lane"><div class="laneHead">${esc(l.stageName)} <b>${l.count}</b></div>
<div class="laneBody" id="laneBody-${i}"><span class="loading"></span></div>
<div class="laneFoot" id="laneFoot-${i}"></div></div>`).join('');
for (let i = 0; i < S.lanes.length; i++) await appendLane(i);
}
async function appendLane(i) {
const l = S.lanes[i];
const body = document.getElementById('laneBody-' + i), foot = document.getElementById('laneFoot-' + i);
const d = await tryApi('board/cards·' + l.stageName, () => api('POST', '/api/opportunity/board/cards',
{ form: { viewType: S.view, stageName: l.stageName, offset: l.offset, limit: l.limit } }), true) || [];
if (l.offset === 0) body.innerHTML = '';
body.insertAdjacentHTML('beforeend', d.map(c =>
`<div class="laneCard" onclick="loadDetail('${esc(String(c.id))}')">
<b>${esc(c.oppName || '')}</b>
<span>${esc(c.ownerNameSnapshot || c.ownerUserId || '—')} · ¥${c.projectAmount ?? 0} ${badge(c.oppStatus)}</span>
</div>`).join('') || (l.offset === 0 ? '<span class="empty">空列</span>' : ''));
l.offset += d.length;
if (d.length < l.limit) { l.done = true; foot.innerHTML = '<span class="hint">到底</span>'; }
else foot.innerHTML = `<button onclick="appendLane(${i})">更多(已 ${l.offset}/${l.count})</button>`;
}
/* ================= 详情 ================= */
@ -362,7 +410,8 @@ async function loadSub(kind) {
const d = await tryApi('customer/list', () => api('GET', '/api/opportunity/customer/list', { params: { oppId: id } }));
show((d || []).map(c => `<div class="kv" style="padding:4px 0"><b>${esc(c.customerRoleName || dictName('customer_role', c.customerRole))}</b>
${esc(c.customerNameSnapshot)}(客户id=${esc(c.customerId)}${c.isPrimaryIntended === 1 ? ',<span class="badge b2">主要意向</span>' : ''})</div>`).join('')
|| '<div class="empty">无关联客户 <span class="hint">(添加接口缺失,seed 经 DB 补入)</span>' + mark('D-01', 'D-02', 'D-03') + '</div>');
+ `<div class="toolbar"><button onclick="dlgAddCustomer()">+ 添加关联客户</button></div>`
|| '<div class="empty">无关联客户</div>');
} else if (kind === 'team') {
const d = await tryApi('team/list', () => api('GET', '/api/opportunity/team/list', { params: { oppId: id } }));
show((d || []).map(t => `<div class="kv" style="padding:4px 0"><b>${esc(t.roleName || t.teamRole || '成员')}</b>${esc(t.userName || t.userId)}</div>`).join('')
@ -456,14 +505,76 @@ async function onProvChange(sel) {
if (!citySel) return;
citySel.innerHTML = '<option value="">加载中…</option>';
S.cities = [];
const distSel = dlgEl.querySelector('[name="districtCode"]');
if (distSel) distSel.innerHTML = '<option value="">(不选,用市码)</option>';
if (!prov) return;
S.cities = (await tryApi('region/child', () => api('GET', '/api/rule/region/list', { params: { parentId: prov.id } }), true)) || [];
citySel.innerHTML = '<option value="">—必选—</option>' +
S.cities.map(c => `<option value="${esc(c.code)}">${esc(c.name)}</option>`).join('');
}
async function onCityChange(sel) {
const city = (S.cities || []).find(c => c.code === sel.value);
const distSel = dlgEl.querySelector('[name="districtCode"]');
if (!distSel) return;
distSel.innerHTML = '<option value="">加载中…</option>';
if (!city) { distSel.innerHTML = '<option value="">(不选,用市码)</option>'; return; }
const d = (await tryApi('region/child2', () => api('GET', '/api/rule/region/list', { params: { parentId: city.id } }), true)) || [];
distSel.innerHTML = '<option value="">(不选,用市码)</option>' +
d.map(c => `<option value="${esc(c.code)}">${esc(c.name)}</option>`).join('') +
(d.length ? '' : '<option value="" disabled>(该市无区县数据)</option>');
}
async function reloadAll() { loadList(1); loadBoard(); if (S.oppId) loadDetail(S.oppId); }
/* ---------- 新建商机(flat create;D-14 后六必填=名称/来源/招标形式/省/市/属地) ---------- */
/* ---------- 添加关联客户(票04 客户信息区联调:类型筛选/搜索/联系人级联/角色/主要) ---------- */
let _custPicked = null;
async function dlgAddCustomer() {
_custPicked = null;
openDlg('添加关联客户', `
${fld('客户类型(筛选)', `<select name="customerType"><option value="">全部客户类型</option>${dictOpts('customer_type')}</select>`)}
${fld('关联客户 *', `<div style="display:flex;gap:6px"><input name="custKw" placeholder="输入客户名关键字" style="flex:1"><button type="button" onclick="doCustSearch()">搜索</button></div>
<div id="custResult" style="max-height:160px;overflow:auto;margin-top:6px"></div>
<div id="custPicked" class="hint">未选择客户</div>`)}
${fld('客户联系人(选客户后级联)', `<select name="customerContactId" id="custContactSel"><option value="">—先选客户—</option></select>`)}
${fld('客户角色 *', `<select name="customerRole"><option value="">—必选—</option>${dictOpts('customer_role')}</select>`)}
${fld('是否主要客户 *', '<select name="isPrimaryIntended"><option value="0"></option><option value="1"></option></select>')}
<div id="addCustErr"></div>
<div class="hint">类型/角色下拉取自 dict enabled-list(customer_type/customer_role);搜索走 POST /api/opportunity/customer/search;联系人级联走 GET /api/opportunity/customer/contacts?customerId=(A4 前为降级 stub,返回空属正常);提交走 POST /api/opportunity/customer/add</div>`,
[{ t: '确定关联', cls: 'ok', fn: doAddCustomer }]);
}
async function doCustSearch() {
const box = document.getElementById('custResult');
box.innerHTML = '搜索中…';
const d = await tryApi('customer/search', () => api('POST', '/api/opportunity/customer/search',
{ form: { oppId: S.oppId, keyword: v('custKw') || '', pageNum: 1, pageSize: 10 } }));
const rows = (d && (d.content || d.records)) || [];
box.innerHTML = rows.map(c =>
`<div class="kv" style="cursor:pointer;padding:4px 6px" onclick='pickCust(${JSON.stringify(String(c.customerId))},${JSON.stringify(String(c.customerName || ''))})'>· ${esc(c.customerName || c.customerId)} <span class="hint">id=${esc(c.customerId)}</span></div>`).join('')
|| '<div class="empty">无匹配客户</div>';
}
async function pickCust(cid, cname) {
_custPicked = { customerId: cid, customerName: cname };
document.getElementById('custPicked').innerHTML = `已选:<b>${esc(cname)}</b>(id=${esc(cid)})`;
// 级联刷新客户联系人(切换客户 → 重新拉取 → 清空重选)
const sel = document.getElementById('custContactSel');
sel.innerHTML = '<option value="">加载中…</option>';
const contacts = await tryApi('customer/contacts', () => api('GET', '/api/opportunity/customer/contacts', { params: { customerId: cid } }), true) || [];
sel.innerHTML = '<option value="">(可空)</option>' + contacts.map(ct =>
`<option value="${esc(ct.contactId)}">${esc(ct.name)}${ct.company ? '(' + esc(ct.company) + ')' : ''}</option>`).join('')
+ (contacts.length ? '' : '<!-- stub 空 -->');
if (!contacts.length) sel.insertAdjacentHTML('beforeend', '<option value="" disabled>(A4 前 stub 无联系人,级联已触发)</option>');
}
async function doAddCustomer() {
const err = document.getElementById('addCustErr');
if (!_custPicked) { err.innerHTML = '<div class="errbox">请先搜索并选择关联客户</div>'; return; }
if (!v('customerRole')) { err.innerHTML = '<div class="errbox">客户角色必选</div>'; return; }
const form = { oppId: S.oppId, customerId: _custPicked.customerId,
customerNameSnapshot: _custPicked.customerName, customerRole: v('customerRole'),
isPrimaryIntended: v('isPrimaryIntended') };
const ok = await okApi('customer/add', () => api('POST', '/api/opportunity/customer/add', { form }));
if (ok) { toast('关联客户成功', 'ok'); closeDlg(); loadSub('customer'); }
}
/* ---------- 新建商机(opp-create-fields 后:7 类字段全量对齐 A3-1-1-2-1,区域三级级联折叠进 cityCode) ---------- */
async function dlgCreate() {
await loadProvinces();
openDlg('新建商机', `
@ -471,15 +582,26 @@ async function dlgCreate() {
${fld('商机来源 *', `<select name="oppSource" onchange="onCrtSource(this)"><option value="">—必选—</option>${dictOpts('opp_source')}</select>`)}
<div id="crtLeadRow" style="display:none">${fld('关联线索 *', '<select name="sourceLeadId"><option value="">加载中…</option></select>')}</div>
${fld('项目区域(省) *', `<select name="provinceCode" onchange="onProvChange(this)"><option value="">—必选—</option>${(S.provs || []).map(p => `<option value="${esc(p.code)}">${esc(p.name)}</option>`).join('')}</select>`)}
${fld('项目区域(市) *', '<select name="cityCode"><option value="">—先选省—</option></select>')}
${fld('项目区域(市) *', '<select name="cityCode" onchange="onCityChange(this)"><option value="">—先选省—</option></select>')}
${fld('区/县(可空)', '<select name="districtCode"><option value="">(不选,用市码)</option></select>')}
${fld('项目属地 *', `<select name="localityType"><option value="">—必选—</option>${dictOpts('locality_type')}</select>`)}
${fld('招标形式 *', `<select name="bidForm"><option value="">—必选—</option>${dictOpts('bid_form')}</select>`)}
${fld('行业', `<select name="industryCode"><option value=""></option>${dictOpts('industry')}</select>`)}
${fld('甲方明确', '<select name="partyAClear"><option value="0"></option><option value="1"></option></select>')}
${fld('甲方', '<input name="partyA">')}
${fld('备注', '<textarea name="remark"></textarea>')}
${fld('项目金额', '<input name="projectAmount" type="number" step="0.01" min="0" placeholder="选填,元">')}
${fld('项目详细地址', '<input name="address" placeholder="选填;省市区之外的门牌地址">')}
<fieldset><legend>关联客户(可选;填了 ID 则客户类型/是否主要必填)</legend>
${fld('关联客户ID(可空)', '<input name="customerId" placeholder="A4 未建:手输 id;customer/search 仅详情场景可用(oppId 必填,新建传 0 返回 66002)">')}
${fld('客户名快照', '<input name="customerName" placeholder="与客户 ID 配套;引用 id + 名快照走法">')}
${fld('客户类型 *', `<select name="customerRole"><option value=""></option>${dictOpts('customer_role')}</select>`)}
${fld('是否主要客户 *', '<select name="isPrimaryIntended"><option value=""></option><option value="1">主要意向客户</option><option value="0">普通关联客户</option></select>')}
${fld('关键联系人', '<input name="keyContactName" placeholder="姓名快照;A4 未建,keyContactId 暂留空">')}
${fld('关键人所在公司', '<input name="keyContactCompany" placeholder="公司快照">')}
</fieldset>
${fld('项目概况', '<textarea name="remark" placeholder="原「备注」,同字段改名非新列"></textarea>')}
<div id="crtErr"></div>
<div class="hint">POST /api/opportunity(flat create;oppSource=线索转入时关联线索必填);无发布中模板时 60001 fail-fast 友好错(D-01 修复行为,非 500)</div>`,
<div class="hint">POST /api/opportunity;oppSource=线索转入时关联线索必填;区/县选中则区县码作 cityCode(不单建 districtCode);填了客户 ID 时客户类型/是否主要必填(后端 66001);=0 普通关联:创建完成允许零主要意向客户,主表主要客户冗余留空;无发布中模板 60001 fail-fast(D-01)</div>`,
[{ t: '创建', cls: 'ok', fn: doCreate }]);
}
async function onCrtSource(sel) {
@ -496,14 +618,22 @@ async function doCreate() {
['cityCode', '项目区域(市)'], ['localityType', '项目属地'], ['bidForm', '招标形式']];
const missing = need.filter(([k]) => !v(k)).map(([, n]) => n);
if (v('oppSource') === 'opp_source_01' && !v('sourceLeadId')) missing.push('关联线索(来源=线索转入时必填)');
const hasCust = !!v('customerId');
if (hasCust && !v('customerRole')) missing.push('客户类型(填了关联客户 ID 时必填)');
if (hasCust && v('isPrimaryIntended') === '') missing.push('是否主要客户(填了关联客户 ID 时必填)');
if (missing.length) {
err.innerHTML = `<div class="errbox">必填缺失(与后端 66001 逐字段校验同款):<br>· ${missing.map(esc).join('<br>· ')}</div>`;
return;
}
const form = { opportunityName: v('opportunityName'), oppSource: v('oppSource'),
provinceCode: v('provinceCode'), cityCode: v('cityCode'),
provinceCode: v('provinceCode'),
cityCode: v('districtCode') || v('cityCode'),
localityType: v('localityType'), bidForm: v('bidForm') };
['sourceLeadId', 'industryCode', 'partyAClear', 'partyA', 'remark'].forEach(k => { if (v(k)) form[k] = v(k); });
['sourceLeadId', 'industryCode', 'partyAClear', 'partyA', 'remark',
'projectAmount', 'address', 'customerRole', 'keyContactName', 'keyContactCompany',
'customerId', 'customerName']
.forEach(k => { if (v(k) !== '') form[k] = v(k); });
if (hasCust) form.isPrimaryIntended = v('isPrimaryIntended');
let newId;
try { newId = await api('POST', '/api/opportunity', { form }); }
catch (e) { err.innerHTML = errHtml(e); return; }
@ -1094,6 +1224,11 @@ async function dlgRuleVersions(id) {
const idx = saved != null && USERS[+saved] ? +saved : 0;
document.getElementById('userSel').value = idx;
S.user = USERS[idx];
if (!BASE.includes('localhost')) {
// 测试环境模式:下拉里的开发库 uid 不适用,等用户 uid 直登
document.getElementById('whoBox').textContent = '测试环境模式:输入 userId 后点「uid 登录」';
return;
}
login();
})();
</script>

BIN
.scratch/opportunity-e2e/demo/shot-12-09-lanes.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-10-lanes-more.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-11-create-dialog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-12-cust-search.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-13-create-result.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-14-detail-after-create.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.scratch/opportunity-e2e/demo/shot-12-15-testenv-mode.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

53
.scratch/opportunity-e2e/issues/12-demo-sync-opp-create-fields.md

@ -0,0 +1,53 @@
# 12 — demo 同步 opp-create-fields + 看板泳道卡片
Type: task
Status: resolved
Blocked by: -
> 【背景】opp-create-fields(提交 acd0d94)补齐商机新建接口 7 类字段后,demo/index.html 新建对话框未同步;用户追加要求顺带体现看板努力的展示名取卡能力(board/cards 泳道视图)。用户将人工重新验收。
## Question
同步 `.scratch/opportunity-e2e/demo/index.html` 至后端最近行为,供用户重新验收:
1. **新建商机对话框补 7 类字段**(spec:`.scratch/opportunity-create-fields/spec.md`,已提交 acd0d94):
- 项目区域省/市/区三级级联,区/县折叠进 cityCode(不新增 districtCode)
- 项目金额 projectAmount、项目详细地址 address
- 客户区块:搜索选客户(customerId)+ 客户类型 customerRole + 是否主要客户 isPrimaryIntended(=0 提示允许零主要意向客户、主表冗余留空)
- 商机关键联系人快照三件套(keyContactId 留空,名/公司手输快照——A4 未建)
- 「备注」label 改「项目概况」
- 前端必填校验对齐:customerId 非空时 customerRole/isPrimaryIntended 必填
2. **看板泳道卡片**:tab1 看板区在 stage-summary 汇总条之上增加泳道列,每列按展示名调 `POST /board/cards`(form: viewType/stageName/offset/limit,offset+limit 滚动翻页、返回 < limit 到底),卡片渲染 oppName/金额/负责人/状态点击开详情依赖工作树未提交的看板代码 起服产物须重新 package注意 fat jar 增量构建跳过陷阱)。
3. Playwright 自测一轮 + 截图落 `demo/`,然后交用户人工验收。
## Answer
## Answer
(20260901 完成并自测通过)demo 同步两块 + 踩实并修复两个平台级缺陷:
1. **新建商机对话框补齐 opp-create-fields 7 类字段**(对齐 A3-1-1-2-1 / acd0d94):
项目区域省→市→区县三级级联(区县码折叠进 cityCode,不单建 districtCode,region API parentId 递归实测 17 区县可选);
项目金额 / 项目详细地址 / 关键联系人快照(姓名+公司手输,keyContactId 按 spec 边界留空);
「备注」label 改「项目概况」;前端必填校验对齐(填了客户 ID 则客户类型/是否主要必填,与后端 66001 同款)。
**客户区块实测修正**:customer/search 的 oppId 必填(传 0 稳定 66002「商机不存在: 0」),新建场景不可用 →
demo 改手输 customerId+customerName(引用 id + 名快照走法,与 DTO javadoc 一致)。
2. **看板泳道卡片**:tab1 新增泳道面板(summary 5 列展示名),每列调 POST /board/cards(form: viewType/stageName/offset/limit,
每列先拉 5 条,「更多」按钮 offset 递增续拉,返回 < limit 渲染到底滚动翻页实测无重复卡
卡片含 oppName/负责人快照/金额/状态徽标,点击开详情。
3. **踩实缺陷 D-23(P1,平台级,已修)**:crm-base `MybatisPlusConfig` 从未注册 `OptimisticLockerInnerInterceptor`
Opportunity `@Version` 预埋失效,且 `updateById(et)` 的 OPTLOCK 占位符与参数注入脱节 →
真实 DB 稳定抛 `BindingException: Parameter 'MP_OPTLOCK_VERSION_ORIGINAL' not found`(HTTP 500/50001)。
opp-create-fields 的 =1 创建分支(intake `linkPrimaryCustomer` 回写主表)是全项目**第一个真实调用**
`oppMapper.updateById` 的路径(set-primary 等全走 LambdaUpdateWrapper 逃过),验收首日即踩爆。
修复:注册拦截器(@Version 语义同时就位,票 13 切主/CAS 受益);修复后 =1 创建 200、
primaryCustomerId/snapshot 正确回写。全项目影响面仅此 1 处调用。
4. **踩实缺陷 D-24(P2,已修)**:`OpportunityDTO`(detail 出参)漏带 address + keyContactId/keyContactNameSnapshot/
keyContactCompanySnapshot,新建成功但详情回显 None。修复:DTO 补 4 同名字段(fromEntity 走
BeanUtils.copyProperties 自动拷贝)。
**自测(Playwright,selftest-12.py)**:泳道 5 列/翻页到底 ✓、对话框 20 字段 ✓、三级级联 ✓、
=1 主要意向路径创建成功 + detail 断言 10 项全绿(880000.5/地址/概况/110101/主客户冗余 99001+名/王工+甲方Y公司)、
=0 普通关联路径创建成功、console/page errors: 0、failed requests: 0;6 张截图落 demo/shot-12-*.png。
回归:crm-opportunity 单测 233/233 全绿;BOM 扫描 0。远程库新增演示数据:demo-12-*/750036429899300864 等 5 条。
**验收入口**:双击 `.scratch/opportunity-e2e/demo/index.html`(后端已按本票产物起服:verify profile + MinIO 凭据)。

2
.scratch/opportunity-e2e/map.md

@ -74,6 +74,8 @@
- **票 11 bruno 示例已解决(20260828)** — 商机模块接口文件 **77 个全真实示例**:存量 48 注入(`inject-examples.py`,实参块+请求示例段换 seed 真实值)+ 规则族 25/自定义视图 4 新入集合(`gen-rules-views.py`,29 接口 .bru + 4 folder.bru,4 新目录树,真实值全部来自 `specimens.json` 32 实测标本——含 8080 补采 stage/scheme versions)。对账 `verify_sync.py`:**71 唯一钥匙全命中、双零差集**(77 文件 = 71 + 3 接口 ×3 页面副本),sync 重跑全部「不动」、实参值/示例受铁律 1 保护不丢。新契约事实:board 两端点源码无 viewType 参数 = D-20 公海看板未过滤根因;scopeKey=opportunity,`OpportunityViewQueryImpl.SAVED_VIEW_SCOPE="opportunity.sales"` 与之不一致疑似新缺陷候选(未实测);viewId=32 位 hex(非雪花);view/save 为 viewId upsert;versions 实体直出泄露在 stage/scheme 坐实(D-19)。接口文档真实示例化就此收口。详见 `issues/11`
- **demo 子表实测与修复(20260829)**`demo-subapi-test.py` 全子 Tab 实测全绿零报错:跟进/勘察真实写入并回显、方案卡 8 模板字段渲染 + 一客户一卡 66009 负路径拦截实拍、团队/附件/日志渲染断言;顺带修 demo 4 处(①子表 toolbar 运算符优先级:有记录时写按钮消失;②卡详情 d.values→d.fields 键名;③写跟进后 loadDetail 刷头部回收锚点;④oplog 三键名对齐实体 opTime/opUserId/opDesc + ROW_ADD/ROW_DELETE 分形态渲染)。工作计划 Tab = P1-4 拍板挂起(A1X);新建商机原型 12 必填 vs 后端 2 必填的对照已答(未落票)。
- **票 12 demo 同步 opp-create-fields + 看板泳道已解决(20260901)**`demo/index.html` 新建对话框补齐 7 类字段(三级级联折叠 cityCode/金额/地址/客户手输快照/关键联系人/项目概况改名/校验对齐)+ 看板泳道(按展示名取卡,offset+limit 滚动加载实测到底无重复卡);Playwright 自测 6 截图全绿 + crm-opportunity 233/233。踩实并修复两缺陷:**D-23(P1 平台级)MybatisPlusConfig 缺 OptimisticLockerInnerInterceptor**——@Version 预埋失效,updateById 真实 DB 稳定炸 MP_OPTLOCK_VERSION_ORIGINAL not found(=1 创建首踩,set-primary 走 wrapper 逃过),已注册修复;**D-24(P2)OpportunityDTO 漏 address/keyContact 三快照回显**,已补。事实:customer/search oppId 必填,新建场景手输快照。详见 `issues/12`
## Ticket 依赖图
```

142
.scratch/opportunity-e2e/selftest-12.py

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""票 12 自测 v2:demo 新建对话框 opp-create-fields 7 类字段 + 看板泳道(展示名取卡 + offset/limit)。
v2 修正oppSource value 精确选v1 index=1 落在 opp_source_01 线索转入触发关联线索必填
客户区块改手输 id+名快照v1 实测 customer/search oppId=0 被后端 66002 拒绝商机不存在: 0
创建后调 detail API 断言新字段落库回显
"""
import io
import re
import sys
import requests
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///D:/code/crm-backend-matt/.scratch/opportunity-e2e/demo/index.html'
SHOT = r'D:\code\crm-backend-matt\.scratch\opportunity-e2e\demo\shot-12-'
BASE = 'https://ai.itc.vip/crm-api'
UID_MAIN = '739564171091247104' # 罗伟健:测试环境可用(赖永利 id 在测试环境 40401,待补导)
console_msgs, page_errors, failed = [], [], []
def sniff(page):
page.on('console', lambda m: console_msgs.append((m.type, m.text))
if m.type in ('error', 'warning') else None)
page.on('pageerror', lambda e: page_errors.append(str(e)))
page.on('requestfailed', lambda r: failed.append((r.url, str(r.failure) if isinstance(r.failure, str) else (r.failure or {}).get('errorText', '?'))))
page.on('response', lambda r: failed.append((r.url, 'HTTP %d' % r.status)) if r.status >= 400 else None)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1500, 'height': 950})
sniff(page)
page.goto(URL)
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# 测试环境模式:boot 不自动登录,uid 直登罗伟健
page.fill('#uidInput', UID_MAIN)
page.click('text=uid 登录')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2500)
# 1) 泳道渲染断言 + 截图
lanes = page.locator('.lane')
lane_n = lanes.count()
lane_heads = [lanes.nth(i).locator('.laneHead').inner_text().replace('\n', ' ') for i in range(lane_n)]
cards = page.locator('.laneCard').count()
more_btns = page.locator('.laneFoot button').count()
page.screenshot(path=SHOT + '09-lanes.png', full_page=False)
print('1) 泳道列数=%d 卡片=%d 「更多」按钮=%d' % (lane_n, cards, more_btns))
for h in lane_heads:
print(' 列:', h)
if more_btns:
page.locator('.laneFoot button').first.click()
page.wait_for_timeout(1500)
foot = page.locator('.laneFoot').first.inner_text()
print(' 点击「更多」后:卡片总数=%d 列脚注=%r' % (page.locator('.laneCard').count(), foot))
page.screenshot(path=SHOT + '10-lanes-more.png', full_page=False)
# 2) 打开新建对话框 + 截图
page.click('text=+ 新建商机')
page.wait_for_timeout(800)
page.screenshot(path=SHOT + '11-create-dialog.png', full_page=False)
dlg = page.locator('dialog:visible')
print('2) 新建对话框可见=%s 字段数(name=*)=%d' % (
dlg.count() > 0, page.locator('dialog [name]').count()))
# 三级级联:省 → 市 → 区县(区县码折叠进 cityCode)
page.select_option('dialog [name="provinceCode"]', index=1)
page.wait_for_timeout(1200)
city_opts = page.locator('dialog [name="cityCode"] option').count()
page.select_option('dialog [name="cityCode"]', index=1)
page.wait_for_timeout(1200)
dist_opts = page.locator('dialog [name="districtCode"] option').count()
if dist_opts > 1:
page.select_option('dialog [name="districtCode"]', index=1)
print('3) 三级级联:市选项=%d 区县选项=%d(选中区县码=%s' % (
city_opts, dist_opts, page.locator('dialog [name="districtCode"]').input_value() or ''))
# 填表:客户手输 id+名(=1 主要意向路径,验证主表冗余)
page.fill('dialog [name="opportunityName"]', 'demo-12-新字段验收')
page.select_option('dialog [name="oppSource"]', 'opp_source_02')
page.select_option('dialog [name="localityType"]', index=1)
page.select_option('dialog [name="bidForm"]', index=1)
page.fill('dialog [name="projectAmount"]', '880000.50')
page.fill('dialog [name="address"]', '天府大道北段1700号环球中心E1')
page.fill('dialog [name="customerId"]', '99001')
page.fill('dialog [name="customerName"]', '智慧园区客户')
page.select_option('dialog [name="customerRole"]', 'customer_role_01')
page.select_option('dialog [name="isPrimaryIntended"]', '1')
page.fill('dialog [name="keyContactName"]', '王工')
page.fill('dialog [name="keyContactCompany"]', '甲方Y公司')
page.fill('dialog [name="remark"]', 'demo自测:项目概况字段')
page.click('dialog button:has-text("创建")')
page.wait_for_timeout(2500)
# 3) 创建结果:toast 或 errbox
toast_txt = page.locator('.toast-item').last.inner_text() if page.locator('.toast-item').count() else ''
err_txt = page.locator('#crtErr .errbox').inner_text() if page.locator('#crtErr .errbox').count() else ''
print('4) 创建结果 toast=%r err=%r' % (toast_txt[:80], err_txt[:200]))
page.screenshot(path=SHOT + '13-create-result.png', full_page=False)
page.wait_for_timeout(1500)
page.screenshot(path=SHOT + '14-detail-after-create.png', full_page=False)
browser.close()
# 4) detail API 断言新字段落库
m = re.search(r'id=(\d+)', toast_txt)
if m:
new_id = m.group(1)
tok = requests.get(BASE + '/api/auth/debug/token', params={'userId': UID_MAIN}).json()['data']
d = requests.get(BASE + '/api/opportunity/detail', headers={'Authorization': 'Bearer ' + tok},
params={'id': new_id}).json()
dv = d.get('data') or {}
checks = {
'projectAmount': dv.get('projectAmount'),
'address': dv.get('address'),
'remark(项目概况)': dv.get('remark'),
'cityCode(区县码)': dv.get('cityCode'),
'customerId': dv.get('customerId'),
'primaryCustomerId(=1冗余)': dv.get('primaryCustomerId'),
'primaryCustomerNameSnapshot': dv.get('primaryCustomerNameSnapshot'),
'keyContactId(留空)': dv.get('keyContactId'),
'keyContactNameSnapshot': dv.get('keyContactNameSnapshot'),
'keyContactCompanySnapshot': dv.get('keyContactCompanySnapshot'),
}
print('5) detail 断言(id=%s):' % new_id)
for k, v in checks.items():
print(' %-28s = %r' % (k, v))
else:
print('5) 创建失败,跳过 detail 断言')
print('== console error/warning:', len(console_msgs))
for t, x in console_msgs[:20]:
print(' [%s] %s' % (t, x[:300]))
print('== page errors:', len(page_errors))
for e in page_errors[:10]:
print(' ', e[:300])
print('== failed requests:', len(failed))
for u, f in failed[:20]:
print(' ', f, '', u[:200])

3
.scratch/patch_board_stagenode.md

@ -0,0 +1,3 @@
**阶段模板节点**:
`opportunity_stage_node`:一个阶段模板版本下的有序节点(`seq_no` + `stage_dict_code` 引用「商机阶段」字典 + `custom_node_name` + `work_goal` 工作目标 + `is_fixed`)。「已转项目」= `is_fixed=1` 固定末节点。模板节点覆盖显示名不反写字典。**同一模板版本内展示名唯一**(`custom_node_name` 优先、空则字典名,发布校验互不重复;跨模板同名允许,看板合并为一列)。
_Avoid_: 阶段字典项(被引用方)、current_stage(运行时指针)

3
.scratch/patch_board_viewform.md

@ -0,0 +1,3 @@
**视图形态**:
与「哪个视图/检索」**正交**的展示形态维度:**列表 list(默认)/ 分屏 split / 看板 board**,标题栏左侧切换。列表与分屏复用列表接口(同查询、同 DataScope、同分页),纯前端渲染;**看板为后端聚合**(按列分组需后端解析展示名)。看板 = 按「当前节点的展示名」分列的卡片流(列头带「共 N 条」计数):展示名 = `custom_node_name` 优先、空则 `opp_stage` 字典名;**不看状态**,`current_stage_id` 有值即上板(暂缓中/已关闭/已转项目阶段有值都上板,无阶段值不上板);列顺序由**视口阶段模板**定(默认 = 当前用户所属部门绑定模板,无则全公司通用默认模板),视口外展示名追加列末,跨模板同名合并为一列。卡片带 `oppStatus` 状态角标。**列配置(自定义字段/列偏好)仅列表视图生效**(grill 拍板 20260821),看板卡片与分屏面板字段固定不跟列偏好。形态偏好落 `crm-preference`(scope=`opportunity.sales`,未设 → list)。
_Avoid_: 形态=视图内容(两轴正交)、看板按节点 id 分列(按展示名分列)、看板按状态过滤(不看状态,阶段有值即上板)、列配置对看板/分屏生效

5
crm-base/src/main/java/com/crm/base/config/MybatisPlusConfig.java

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -26,6 +27,10 @@ public class MybatisPlusConfig {
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 防止全表 update / delete 的误操作
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
// 乐观锁拦截器:@Version 注解生效的前提(Opportunity.version 预埋依赖)。
// 缺它时 updateById(et) 的 OPTLOCK 占位符与参数注入脱节,真实 DB 环境稳定抛
// BindingException: Parameter 'MP_OPTLOCK_VERSION_ORIGINAL' not found(demo 验收 20260901 踩实)。
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 其他模块注册的拦截器(如 DataScopeInterceptor)
if (additionalInterceptors != null) {
additionalInterceptors.forEach(interceptor::addInnerInterceptor);

10
crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java

@ -319,7 +319,15 @@ public class DictDataInitializer implements CommandLineRunner {
new ItemSeed("attachment_type_02", "涉及清单/图纸", "attachment_type_02", 2, null, false),
new ItemSeed("attachment_type_03", "客户需求资料", "attachment_type_03", 3, null, false),
new ItemSeed("attachment_type_04", "招投标相关资料", "attachment_type_04", 4, null, false),
new ItemSeed("attachment_type_99", "其他", "attachment_type_99", 5, null, false))));
new ItemSeed("attachment_type_99", "其他", "attachment_type_99", 5, null, false))),
new GroupSeed("customer_type", "客户类型", 23, "客户主数据类型(商机弹窗客户类型筛选/客户信息 Tab 客户类型列;A4 客户模块前置联调,产品录入 20260901)",
List.of(
new ItemSeed("customer_type_01", "总包", "customer_type_01", 1, null, false),
new ItemSeed("customer_type_02", "工程商", "customer_type_02", 2, null, false),
new ItemSeed("customer_type_03", "投资方", "customer_type_03", 3, null, false),
new ItemSeed("customer_type_04", "设计院", "customer_type_04", 4, null, false),
new ItemSeed("customer_type_05", "集成商", "customer_type_05", 5, null, false),
new ItemSeed("customer_type_06", "投标公司", "customer_type_06", 6, null, false))));
@Override
public void run(String... args) {

22
crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java

@ -31,24 +31,24 @@ class DictDataInitializerTest extends AbstractDictH2Test {
}
@Test
@DisplayName("首装:22 个内置分组 + 220 个内置项全部建库,ref_count 预建,种子默认项生效")
@DisplayName("首装:23 个内置分组 + 226 个内置项全部建库,ref_count 预建,种子默认项生效")
void firstRun_createsBuiltinDictsWithDefaults() {
initializer.run();
List<DictGroup> groups = dictGroupMapper.selectList(null);
assertThat(groups).hasSize(22);
assertThat(groups).hasSize(23);
assertThat(groups).extracting(DictGroup::getCode)
.containsExactlyInAnyOrder("lead_source", "customer_status", "industry",
"brand", "product", "scene", "opp_stage",
"opp_source", "opp_type", "bid_form", "result_tag", "design_config",
"pause_reason", "pool_reason", "project_role", "close_reason",
"locality_type", "follow_way", "survey_seq", "apply_way", "customer_role",
"attachment_type");
"attachment_type", "customer_type");
assertThat(groups).allMatch(g -> Boolean.TRUE.equals(g.getBuiltin()));
assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED);
List<DictItem> items = dictItemMapper.selectList(null);
assertThat(items).hasSize(220); // 160(非 industry) + industry 两级树 60(9一级+51二级)
assertThat(items).hasSize(226); // 166(非 industry) + industry 两级树 60(9一级+51二级)
assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin()));
// 行业两级树:9 个一级(parentId=null)+ 51 个二级(parentId 非空)
@ -61,7 +61,7 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(industryItems).filteredOn(i -> i.getParentId() != null).hasSize(51);
// 引用计数行预建(与新建接口同口径)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220);
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(226);
DictGroup statusGroup = groups.stream()
.filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow();
DictItem potential = dictItemMapper.selectList(null).stream()
@ -82,9 +82,9 @@ class DictDataInitializerTest extends AbstractDictH2Test {
initializer.run();
initializer.run();
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(220);
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220);
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(23);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(226);
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(226);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1);
}
@ -230,11 +230,11 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(dictItemMapper.selectList(null).stream()
.filter(i -> i.getGroupId().equals(userGroup.getId()))).isEmpty();
// 种子其他分组不受影响(继续正常导入):customer_status + industry + brand + product + scene + opp_stage + 8 商机分组
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); // lead_source(用户) + 其余 21 个内置分组
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(23); // lead_source(用户) + 其余 22 个内置分组
assertThat(dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry"))).hasSize(1);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(182); // 220 - 渠道 38(用户分组整组跳过)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(182);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(188); // 226 - 渠道 38(用户分组整组跳过)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(188);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项
}

6
crm-opportunity/CONTEXT.md

@ -143,8 +143,8 @@ _Avoid_: 直接发通知(本期只落待发记录)、三种提醒各建表
_Avoid_: 视图=状态切片(同一商机可跨视图)、预计成交时间(产品二次拍板:商机列表删除该列、不上主表;仅方案卡内部字段票07)、自定义视图绑死商机(落 crm-preference 按 scope 通用,票 14)、内置视图可编辑(内置固定,只自定义视图可增删改)
**视图形态**:
与「哪个视图/检索」**正交**的展示形态维度(原型 line 1281,票 10/14 未定义;grill 补拍 20260821):**列表 list(默认)/ 分屏 split / 看板 board**,标题栏左侧切换。同一份列表数据三种渲染,**后端列表接口不变**(同查询、同 DataScope、同分页),形态 = 纯前端渲染维度;三个工作区 + 最近访问均支持。看板 = 按「当前工作节点」分列的卡片流(列头带「共 N 条」计数),分屏 = 左列表 + 右详情面板(复用详情聚合接口)。**列配置(自定义字段/列偏好)仅列表视图生效**(grill 拍板 20260821),看板卡片与分屏面板字段固定不跟列偏好。形态偏好落 `crm-preference`(scope=`opportunity.sales`,未设 → list)。
_Avoid_: 形态=视图内容(两轴正交)、后端为形态单开接口(同一接口前端渲染)、列配置对看板/分屏生效
与「哪个视图/检索」**正交**的展示形态维度:**列表 list(默认)/ 分屏 split / 看板 board**,标题栏左侧切换。列表与分屏复用列表接口(同查询、同 DataScope、同分页),纯前端渲染;**看板为后端聚合**(按列分组需后端解析展示名)。看板 = 按「当前节点的展示名」分列的卡片流(列头带「共 N 条」计数):展示名 = `custom_node_name` 优先、空则 `opp_stage` 字典名;**不看状态**,`current_stage_id` 有值即上板(暂缓中/已关闭/已转项目阶段有值都上板,无阶段值不上板);列顺序由**视口阶段模板**定(默认 = 当前用户所属部门绑定模板,无则全公司通用默认模板),视口外展示名追加列末,跨模板同名合并为一列。卡片带 `oppStatus` 状态角标。**列配置(自定义字段/列偏好)仅列表视图生效**(grill 拍板 20260821),看板卡片与分屏面板字段固定不跟列偏好。形态偏好落 `crm-preference`(scope=`opportunity.sales`,未设 → list)。
_Avoid_: 形态=视图内容(两轴正交)、看板按节点 id 分列(按展示名分列)、看板按状态过滤(不看状态,阶段有值即上板)、列配置对看板/分屏生效
**最近访问**:
商机级个人浏览行为数据,落 `opportunity_view_log`(`user_id`+`opportunity_id`+`last_view_time`,`UNIQUE(user_id, opportunity_id)`;grill 补拍 20260821,票 10 未定落库方式)。**调用详情聚合接口即 upsert**,个人/管理/公海任意皮肤都算。**不写 `opportunity_oplog`**——访问是高频行为数据,oplog 是字段级审计轨迹(票 09 行为类别不含浏览),两者分离。表内历史行后台 Job 按容量/时间裁剪。「最近访问」视图 = 命中当前用户按 `last_view_time` 倒序取上限(默认 50)。
@ -177,7 +177,7 @@ _Avoid_: 方案卡(运行时单据,模板是配置)
_Avoid_: 阶段字典(模板引用字典,不同层次)、阶段(阶段是运行时轴,模板是配置)
**阶段模板节点**:
`opportunity_stage_node`:一个阶段模板版本下的有序节点(`seq_no` + `stage_dict_code` 引用「商机阶段」字典 + `custom_node_name` + `work_goal` 工作目标 + `is_fixed`)。「已转项目」= `is_fixed=1` 固定末节点。模板节点覆盖显示名不反写字典。
`opportunity_stage_node`:一个阶段模板版本下的有序节点(`seq_no` + `stage_dict_code` 引用「商机阶段」字典 + `custom_node_name` + `work_goal` 工作目标 + `is_fixed`)。「已转项目」= `is_fixed=1` 固定末节点。模板节点覆盖显示名不反写字典。**同一模板版本内展示名唯一**(`custom_node_name` 优先、空则字典名,发布校验互不重复;跨模板同名允许,看板合并为一列)。
_Avoid_: 阶段字典项(被引用方)、current_stage(运行时指针)
**商机公海规则**:

7
crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java

@ -7,6 +7,7 @@ import com.crm.base.security.SecurityUtils;
import com.crm.opportunity.constant.OpportunityConstants;
import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
import com.crm.opportunity.domain.dto.CustomerContactDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
@ -77,6 +78,12 @@ public class OpportunitySubController {
return Result.success(subService.searchCustomers(param));
}
@Operation(summary = "按客户查联系人(降级 stub,A4 就位后切真源)", tags = {"A3 商机管理/商机详情/客户"})
@GetMapping("/customer/contacts")
public Result<List<CustomerContactDTO>> listCustomerContacts(@RequestParam("customerId") Long customerId) {
return Result.success(subService.listCustomerContacts(customerId));
}
@Operation(summary = "设为主要意向客户", tags = {"A3 商机管理/商机详情/客户"})
@PostMapping("/customer/set-primary")
public Result<Void> setPrimaryCustomer(

31
crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CustomerContactDTO.java

@ -0,0 +1,31 @@
package com.crm.opportunity.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 按客户查联系人出参 04-stub原型 A3-1-1-2-1 字段17 / A3-1-1-2-4 弹窗客户联系人
*
* <p>降级 stub客户模块 A4 未建库无 customer_contact DTO 仅定义前端级联
* 需要的出参契约字段当前接口返回空列表 {@code IOpportunitySubService#listCustomerContacts}
* A4 建成后切真源接口签名与本契约不变</p>
*/
@Data
@Schema(description = "客户联系人(降级 stub,A4 就位后切真源)")
public class CustomerContactDTO {
@Schema(description = "联系人ID")
private Long contactId;
@Schema(description = "联系人姓名")
private String name;
@Schema(description = "联系电话(按权限脱敏)")
private String phone;
@Schema(description = "所属公司")
private String company;
@Schema(description = "职务")
private String position;
}

14
crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java

@ -58,6 +58,20 @@ public class OpportunityDTO extends BaseDTO {
@Schema(description = "项目金额(列表展示为「预计金额」)")
private BigDecimal projectAmount;
@Schema(description = "项目详细地址(省市区之外的门牌地址,opp-create-fields)")
private String address;
// -------- 商机关键联系人快照(opp-create-fields) --------
@Schema(description = "关键联系人ID(引用 A4 联系人,未建可空)")
private Long keyContactId;
@Schema(description = "关键联系人姓名快照")
private String keyContactNameSnapshot;
@Schema(description = "关键人所在公司快照")
private String keyContactCompanySnapshot;
// -------- 归属 --------
@Schema(description = "负责人ID")

4
crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java

@ -3,6 +3,7 @@ package com.crm.opportunity.service;
import com.crm.base.domain.result.PageResult;
import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
import com.crm.opportunity.domain.dto.CustomerContactDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
@ -33,6 +34,9 @@ public interface IOpportunitySubService {
/** 候选客户池搜索(票 04 D-02:快照池降级——A4 未建,取关联客户快照去重,排除当前商机已关联) */
PageResult<OpportunityCustomerCandidateDTO> searchCustomers(OpportunityCustomerSearchParam param);
/** 按客户查联系人(stub:A4 未建,现返回空列表供前端级联契约对齐;A4 就位后切真源,签名不变) */
List<CustomerContactDTO> listCustomerContacts(Long customerId);
/** 设为主要意向客户(票 04 D-03:旧主降普通 → 新主置主 → 主表快照回写,三步事务) */
void setPrimaryCustomer(Long oppId, Long customerId, Long operatorId);

9
crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java

@ -12,6 +12,7 @@ import com.crm.base.utils.PageConverter;
import com.crm.opportunity.constant.OpportunityConstants;
import com.crm.opportunity.domain.dto.OpportunityCustomerAddDTO;
import com.crm.opportunity.domain.dto.OpportunityCustomerCandidateDTO;
import com.crm.opportunity.domain.dto.CustomerContactDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamAddDTO;
import com.crm.opportunity.domain.dto.OpportunityTeamUpdateDTO;
import com.crm.opportunity.domain.dto.OpportunityWorkPlanAddDTO;
@ -136,6 +137,14 @@ public class OpportunitySubServiceImpl implements IOpportunitySubService {
return new PageResult<>(page);
}
@Override
public List<CustomerContactDTO> listCustomerContacts(Long customerId) {
// 降级 stub:客户模块 A4 未建、库无 customer_contact 表,先返回空列表
// 供前端字段15→字段17级联契约对齐(不 404);A4 就位后切真源、签名不变。
// 空 customerId / 不存在客户均返回空列表而非报错。
return new ArrayList<>();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setPrimaryCustomer(Long oppId, Long customerId, Long operatorId) {

17
docs/adr/0032-board-groups-by-stage-display-name.md

@ -0,0 +1,17 @@
# 看板按阶段展示名分组(不看状态)
商机看板(board)的分列键从 `current_stage_id`(节点 id)改为「当前节点的展示名」——展示名 = `custom_node_name` 优先、空则带入 `opp_stage` 字典名。看板不再按状态过滤:`current_stage_id` 有值即上板(暂缓中/已关闭/已转项目只要阶段有值都上板,无阶段值不上板)。列顺序由视口阶段模板(`stageTemplateId` 参数,null → 当前用户所属部门绑定模板 → 全公司通用默认模板)的节点 `seq_no` 定,视口外展示名追加列末;同一模板版本内展示名唯一(发布校验),跨模板同名合并为一列。
## Considered Options
- **按节点 id 分组(原实现)**:`current_stage_id` 指向 `opportunity_stage_node` 行,是模板版本私有主键;同一字典值(如 `OPP_STAGE_02`)在多模板/多版本下有多个节点 id,看板会把同一业务阶段拆成多列——弃。
- **按 `stage_dict_code` 分组**:跨模板对同一字典值可设不同 `custom_node_name`,列头取谁无确定答案——弃。
- **按展示名分组(本决策)**:分组键 = 用户实际看到的名字,语义最直观;撞名合并是接受的代价。
## Consequences
- 看板列语义与状态轴(轴1)解耦,只认阶段轴(轴2);卡片需带 `oppStatus` 供前端渲染状态角标。
- `boardStageSummary` 返回完整有序列摘要(空列 `count=0` 也返回);空列是否渲染由前端决定。
- `boardCards` 寻址键从 `stageId`(Long) 改为 `stageName`(String),后端解析成「展示名相等的节点 id 集合」过滤。
- 阶段模板发布校验新增:同一模板版本内展示名唯一。
- D-15「看板列 = 在办商机(推进中)」作废。

189
logs/crm-app-boot.log

@ -0,0 +1,189 @@
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.5)
2026-09-01 09:35:38.587 [main] [] INFO com.crm.app.CrmAppApplication - Starting CrmAppApplication v1.0.0-SNAPSHOT using Java 17.0.15 with PID 4576 (D:\code\crm-backend-matt\crm-app\target\crm-app-1.0.0-SNAPSHOT.jar started by luowj in D:\code\crm-backend-matt)
2026-09-01 09:35:38.612 [main] [] INFO com.crm.app.CrmAppApplication - The following 1 profile is active: "verify"
2026-09-01 09:35:43.818 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2026-09-01 09:35:43.824 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-09-01 09:35:43.963 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 107 ms. Found 0 JPA repository interfaces.
2026-09-01 09:35:44.055 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode
2026-09-01 09:35:44.062 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2026-09-01 09:35:44.112 [main] [] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 13 ms. Found 0 Redis repository interfaces.
2026-09-01 09:35:44.892 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'crmBaseMapper' and 'com.crm.base.mapper.CrmBaseMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.892 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'authIdentityMapper' and 'com.crm.auth.mapper.AuthIdentityMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.893 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'authUserMapper' and 'com.crm.auth.mapper.AuthUserMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.894 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysDataScopeModuleMapper' and 'com.crm.auth.mapper.SysDataScopeModuleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.894 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysDeptMapper' and 'com.crm.auth.mapper.SysDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.895 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysMenuMapper' and 'com.crm.auth.mapper.SysMenuMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.895 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleDataScopeMapper' and 'com.crm.auth.mapper.SysRoleDataScopeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.896 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleMapper' and 'com.crm.auth.mapper.SysRoleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.896 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleMenuMapper' and 'com.crm.auth.mapper.SysRoleMenuMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.896 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysUserDeptMapper' and 'com.crm.auth.mapper.SysUserDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.897 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysUserRoleMapper' and 'com.crm.auth.mapper.SysUserRoleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.897 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'fileInfoMapper' and 'com.crm.file.mapper.FileInfoMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.898 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictGroupDefaultMapper' and 'com.crm.dict.mapper.DictGroupDefaultMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.899 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictGroupMapper' and 'com.crm.dict.mapper.DictGroupMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.899 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictItemMapper' and 'com.crm.dict.mapper.DictItemMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.900 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictRefCountMapper' and 'com.crm.dict.mapper.DictRefCountMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.900 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolMapper' and 'com.crm.rule.mapper.LeadPoolMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.900 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolMemberMapper' and 'com.crm.rule.mapper.LeadPoolMemberMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.901 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolProvinceMapper' and 'com.crm.rule.mapper.LeadPoolProvinceMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.901 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolRegionMapper' and 'com.crm.rule.mapper.LeadPoolRegionMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.901 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPoolRuleDeptMapper' and 'com.crm.rule.mapper.OpportunityPoolRuleDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.902 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPoolRuleMapper' and 'com.crm.rule.mapper.OpportunityPoolRuleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.902 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardFieldMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardFieldMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.902 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardTemplateDeptMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardTemplateDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.903 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardTemplateMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardTemplateMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.903 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeFieldDefMapper' and 'com.crm.rule.mapper.OpportunitySchemeFieldDefMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.903 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageNodeMapper' and 'com.crm.rule.mapper.OpportunityStageNodeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.903 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageTemplateDeptMapper' and 'com.crm.rule.mapper.OpportunityStageTemplateDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.903 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageTemplateMapper' and 'com.crm.rule.mapper.OpportunityStageTemplateMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRegionMapper' and 'com.crm.rule.mapper.SysRegionMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'userColumnPreferenceMapper' and 'com.crm.preference.mapper.UserColumnPreferenceMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'userSavedViewMapper' and 'com.crm.preference.mapper.UserSavedViewMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadAttachmentMapper' and 'com.crm.lead.mapper.LeadAttachmentMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadFeedbackMapper' and 'com.crm.lead.mapper.LeadFeedbackMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.904 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadFollowMapper' and 'com.crm.lead.mapper.LeadFollowMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.905 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadHistoryMapper' and 'com.crm.lead.mapper.LeadHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.905 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadMapper' and 'com.crm.lead.mapper.LeadMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.905 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityAttachmentMapper' and 'com.crm.opportunity.mapper.OpportunityAttachmentMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.905 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityCustomerMapper' and 'com.crm.opportunity.mapper.OpportunityCustomerMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.905 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityFocusMapper' and 'com.crm.opportunity.mapper.OpportunityFocusMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.906 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityFollowMapper' and 'com.crm.opportunity.mapper.OpportunityFollowMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.906 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityMapper' and 'com.crm.opportunity.mapper.OpportunityMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.908 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityOplogMapper' and 'com.crm.opportunity.mapper.OpportunityOplogMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.908 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPendingNoticeMapper' and 'com.crm.opportunity.mapper.OpportunityPendingNoticeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.908 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardMapper' and 'com.crm.opportunity.mapper.OpportunitySchemeCardMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.909 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardValueMapper' and 'com.crm.opportunity.mapper.OpportunitySchemeCardValueMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.909 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySiteSurveyMapper' and 'com.crm.opportunity.mapper.OpportunitySiteSurveyMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.909 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageHistoryMapper' and 'com.crm.opportunity.mapper.OpportunityStageHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.910 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStatusHistoryMapper' and 'com.crm.opportunity.mapper.OpportunityStatusHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.911 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityTeamMapper' and 'com.crm.opportunity.mapper.OpportunityTeamMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.911 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityViewLogMapper' and 'com.crm.opportunity.mapper.OpportunityViewLogMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.912 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityWorkPlanMapper' and 'com.crm.opportunity.mapper.OpportunityWorkPlanMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.913 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - No MyBatis mapper was found in '[com.crm.**.mapper]' package. Please check your configuration.
2026-09-01 09:35:44.921 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'crmBaseMapper' and 'com.crm.base.mapper.CrmBaseMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.921 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'authIdentityMapper' and 'com.crm.auth.mapper.AuthIdentityMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.921 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'authUserMapper' and 'com.crm.auth.mapper.AuthUserMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.921 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysDataScopeModuleMapper' and 'com.crm.auth.mapper.SysDataScopeModuleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.922 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysDeptMapper' and 'com.crm.auth.mapper.SysDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.923 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysMenuMapper' and 'com.crm.auth.mapper.SysMenuMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.925 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleDataScopeMapper' and 'com.crm.auth.mapper.SysRoleDataScopeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.926 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleMapper' and 'com.crm.auth.mapper.SysRoleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.928 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRoleMenuMapper' and 'com.crm.auth.mapper.SysRoleMenuMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.929 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysUserDeptMapper' and 'com.crm.auth.mapper.SysUserDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.929 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysUserRoleMapper' and 'com.crm.auth.mapper.SysUserRoleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.929 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'fileInfoMapper' and 'com.crm.file.mapper.FileInfoMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.929 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictGroupDefaultMapper' and 'com.crm.dict.mapper.DictGroupDefaultMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.930 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictGroupMapper' and 'com.crm.dict.mapper.DictGroupMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.930 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictItemMapper' and 'com.crm.dict.mapper.DictItemMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.930 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'dictRefCountMapper' and 'com.crm.dict.mapper.DictRefCountMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.930 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolMapper' and 'com.crm.rule.mapper.LeadPoolMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.930 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolMemberMapper' and 'com.crm.rule.mapper.LeadPoolMemberMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.931 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolProvinceMapper' and 'com.crm.rule.mapper.LeadPoolProvinceMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.931 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadPoolRegionMapper' and 'com.crm.rule.mapper.LeadPoolRegionMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.931 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPoolRuleDeptMapper' and 'com.crm.rule.mapper.OpportunityPoolRuleDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.931 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPoolRuleMapper' and 'com.crm.rule.mapper.OpportunityPoolRuleMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.931 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardFieldMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardFieldMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.932 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardTemplateDeptMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardTemplateDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.934 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardTemplateMapper' and 'com.crm.rule.mapper.OpportunitySchemeCardTemplateMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.935 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeFieldDefMapper' and 'com.crm.rule.mapper.OpportunitySchemeFieldDefMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.935 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageNodeMapper' and 'com.crm.rule.mapper.OpportunityStageNodeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.935 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageTemplateDeptMapper' and 'com.crm.rule.mapper.OpportunityStageTemplateDeptMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageTemplateMapper' and 'com.crm.rule.mapper.OpportunityStageTemplateMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'sysRegionMapper' and 'com.crm.rule.mapper.SysRegionMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'userColumnPreferenceMapper' and 'com.crm.preference.mapper.UserColumnPreferenceMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'userSavedViewMapper' and 'com.crm.preference.mapper.UserSavedViewMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadAttachmentMapper' and 'com.crm.lead.mapper.LeadAttachmentMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.936 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadFeedbackMapper' and 'com.crm.lead.mapper.LeadFeedbackMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.937 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadFollowMapper' and 'com.crm.lead.mapper.LeadFollowMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.937 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadHistoryMapper' and 'com.crm.lead.mapper.LeadHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.937 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'leadMapper' and 'com.crm.lead.mapper.LeadMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.937 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityAttachmentMapper' and 'com.crm.opportunity.mapper.OpportunityAttachmentMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.937 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityCustomerMapper' and 'com.crm.opportunity.mapper.OpportunityCustomerMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityFocusMapper' and 'com.crm.opportunity.mapper.OpportunityFocusMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityFollowMapper' and 'com.crm.opportunity.mapper.OpportunityFollowMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityMapper' and 'com.crm.opportunity.mapper.OpportunityMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityOplogMapper' and 'com.crm.opportunity.mapper.OpportunityOplogMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityPendingNoticeMapper' and 'com.crm.opportunity.mapper.OpportunityPendingNoticeMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.938 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardMapper' and 'com.crm.opportunity.mapper.OpportunitySchemeCardMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.939 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySchemeCardValueMapper' and 'com.crm.opportunity.mapper.OpportunitySchemeCardValueMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.941 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunitySiteSurveyMapper' and 'com.crm.opportunity.mapper.OpportunitySiteSurveyMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.941 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStageHistoryMapper' and 'com.crm.opportunity.mapper.OpportunityStageHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.941 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityStatusHistoryMapper' and 'com.crm.opportunity.mapper.OpportunityStatusHistoryMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.942 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityTeamMapper' and 'com.crm.opportunity.mapper.OpportunityTeamMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.942 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityViewLogMapper' and 'com.crm.opportunity.mapper.OpportunityViewLogMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.942 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - Skipping MapperFactoryBean with name 'opportunityWorkPlanMapper' and 'com.crm.opportunity.mapper.OpportunityWorkPlanMapper' mapperInterface. Bean already defined with the same name!
2026-09-01 09:35:44.942 [main] [] WARN o.m.s.mapper.ClassPathMapperScanner - No MyBatis mapper was found in '[com.crm.**.mapper]' package. Please check your configuration.
2026-09-01 09:35:49.202 [main] [] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http)
2026-09-01 09:35:49.254 [main] [] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2026-09-01 09:35:49.254 [main] [] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.31]
2026-09-01 09:35:49.436 [main] [] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2026-09-01 09:35:49.436 [main] [] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 10567 ms
2026-09-01 09:35:51.400 [main] [] INFO c.crm.auth.config.PermissionConfig - 数据权限受控表扫描完成:0 张
2026-09-01 09:35:51.803 [main] [] INFO c.crm.base.config.CustomIdGenerator - 雪花ID生成器初始化完成,workerId=0, datacenterId=0
_ _ |_ _ _|_. ___ _ | _
| | |\/|_)(_| | |_\ |_)||_|_\
/ |
3.5.7
2026-09-01 09:35:56.263 [main] [] INFO o.h.jpa.internal.util.LogHelper - HHH000204: Processing PersistenceUnitInfo [name: default]
2026-09-01 09:35:56.971 [main] [] INFO org.hibernate.Version - HHH000412: Hibernate ORM core version 6.5.3.Final
2026-09-01 09:35:57.233 [main] [] INFO o.h.c.i.RegionFactoryInitiator - HHH000026: Second-level cache disabled
2026-09-01 09:36:00.566 [main] [] INFO o.s.o.j.p.SpringPersistenceUnitInfo - No LoadTimeWeaver setup: ignoring JPA class transformer
2026-09-01 09:36:00.890 [main] [] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2026-09-01 09:36:03.704 [main] [] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@350ede1
2026-09-01 09:36:03.712 [main] [] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2026-09-01 09:36:04.338 [main] [] WARN org.hibernate.orm.deprecation - HHH90000025: MySQLDialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default)
2026-09-01 09:36:21.751 [main] [] INFO o.h.e.t.j.p.i.JtaPlatformInitiator - HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Java HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-09-01 09:36:32.404 [main] [] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-01 09:36:37.615 [main] [] WARN c.c.a.c.DebugTokenController - ============================================================
2026-09-01 09:36:37.615 [main] [] WARN c.c.a.c.DebugTokenController - 调试 token 接口 /api/auth/debug/token 已启用(verify profile)
2026-09-01 09:36:37.615 [main] [] WARN c.c.a.c.DebugTokenController - 严禁用于生产环境!任何知道 userId 的人可冒充该用户。
2026-09-01 09:36:37.616 [main] [] WARN c.c.a.c.DebugTokenController - 参见 docs/adr/0014-verify-profile-debug-token-endpoint.md
2026-09-01 09:36:37.616 [main] [] WARN c.c.a.c.DebugTokenController - ============================================================
2026-09-01 09:36:46.694 [main] [] WARN o.s.b.a.s.s.UserDetailsServiceAutoConfiguration -
Using generated security password: c99cce8f-643e-4f9b-8f9c-2dfa146e2b37
This generated password is for development use only. Your security configuration must be updated before running your application in production.
2026-09-01 09:36:46.782 [main] [] INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer - Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager
2026-09-01 09:36:51.202 [main] [] INFO c.c.a.c.DataScopeModuleStartupValidator - 数据权限模块校验通过:0 张受控表,4 个注册模块
2026-09-01 09:36:51.284 [main] [] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/'
2026-09-01 09:36:51.402 [main] [] INFO com.crm.app.CrmAppApplication - Started CrmAppApplication in 75.435 seconds (process running for 78.321)
2026-09-01 09:36:51.413 [main] [] INFO com.crm.auth.config.DataInitializer - 执行权限数据初始化检查...
2026-09-01 09:36:52.478 [main] [] INFO com.crm.auth.config.DataInitializer - 权限数据初始化检查完成
2026-09-01 09:36:52.479 [main] [] INFO c.c.d.c.DictPermissionInitializer - 执行数据字典权限种子化检查...
2026-09-01 09:36:53.136 [main] [] INFO c.c.d.c.DictPermissionInitializer - 数据字典权限种子化检查完成:10 个权限点
2026-09-01 09:36:53.136 [main] [] INFO c.c.r.c.RulePermissionInitializer - 执行线索规则权限种子化检查(仅菜单)...
2026-09-01 09:36:53.288 [main] [] INFO c.c.r.c.RulePermissionInitializer - 线索规则权限种子化检查完成:仅菜单(0 个 button)
2026-09-01 09:36:53.289 [main] [] INFO c.c.l.c.LeadPermissionInitializer - 执行线索管理权限种子化检查(仅菜单)...
2026-09-01 09:36:54.299 [main] [] INFO c.c.l.c.LeadPermissionInitializer - 线索管理权限种子化检查完成:4 个菜单(0 个 button)
2026-09-01 09:36:54.299 [main] [] INFO c.c.r.config.RegionDataInitializer - 执行行政区划全量种子导入检查...
2026-09-01 09:36:56.836 [main] [] INFO c.c.r.config.RegionDataInitializer - 行政区划种子导入完成:对账 3432 行(省 34 / 市 342 / 区 3056),存量 3432 行,新增 0 行
2026-09-01 09:36:56.840 [main] [] INFO c.c.r.c.OpportunityRulePermissionInitializer - 执行商机规则权限种子化检查(仅菜单)...
2026-09-01 09:36:57.131 [main] [] INFO c.c.r.c.OpportunityRulePermissionInitializer - 商机规则权限种子化检查完成:仅菜单(0 个 button)
2026-09-01 09:36:57.132 [main] [] INFO c.c.r.c.OpportunityStageTemplatePermissionInitializer - 执行商机阶段模板权限种子化检查(仅菜单)...
2026-09-01 09:36:59.045 [main] [] INFO c.c.r.c.OpportunityStageTemplatePermissionInitializer - 商机阶段模板权限种子化检查完成:仅菜单(0 个 button)
2026-09-01 09:36:59.047 [main] [] INFO c.c.r.c.SchemeFieldDefInitializer - 执行方案卡系统字段库种子化检查...
2026-09-01 09:36:59.098 [main] [] INFO c.c.r.c.SchemeFieldDefInitializer - 方案卡系统字段库种子化检查完成:现存 16 个,新增 0 个
2026-09-01 09:36:59.178 [main] [] INFO c.c.r.c.SchemeFieldDefInitializer - 方案卡兜底模板已存在(code=OPP_SCHEME_TPL_01,含已停用),跳过种子化
2026-09-01 09:36:59.179 [main] [] INFO c.c.r.c.OpportunitySchemeCardTemplatePermissionInitializer - 执行方案卡模板权限种子化检查(仅菜单)...
2026-09-01 09:36:59.560 [main] [] INFO c.c.r.c.OpportunitySchemeCardTemplatePermissionInitializer - 方案卡模板权限种子化检查完成:仅菜单(0 个 button)
2026-09-01 09:37:00.003 [main] [] INFO c.c.r.c.StageTemplateInitializer - 默认阶段模板已存在(code=OPP_STAGE_TPL_01,含已停用),跳过种子化
2026-09-01 09:37:00.004 [main] [] INFO c.c.dict.config.DictDataInitializer - 执行内置字典幂等初始化检查...
2026-09-01 09:37:09.202 [http-nio-8080-exec-3] [] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet'
2026-09-01 09:37:09.219 [http-nio-8080-exec-3] [] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 16 ms
2026-09-01 09:37:21.283 [http-nio-8080-exec-7] [687e484002fb4a53b8979e2e9c7ece66] WARN c.c.a.c.DebugTokenController - [verify] 为 userId=739564171091247104 签发调试 token
2026-09-01 09:37:22.315 [main] [] INFO c.c.dict.config.DictDataInitializer - 内置字典幂等初始化检查完成:23 个分组
2026-09-01 09:37:23.630 [main] [] WARN com.crm.file.config.MinioConfig - MinIO bucket [crm] 初始化失败,文件功能可能不可用:The Access Key Id you provided does not exist in our records.
2026-09-01 09:37:40.201 [http-nio-8080-exec-3] [74105c06296b4b9cbf27d92ffe1b1836] WARN c.c.a.c.DebugTokenController - [verify] 为 userId=739564171091247104 签发调试 token
2026-09-01 09:37:44.596 [http-nio-8080-exec-3] [78251347142b454fb5196a63cb924a09] WARN c.c.a.c.DebugTokenController - [verify] 为 userId=739564171091247104 签发调试 token

1224
logs/crm-app.log

File diff suppressed because it is too large

BIN
logs/crm-app.log.2026-08-31.0.gz

Binary file not shown.
Loading…
Cancel
Save