diff --git a/.scratch/customer-module/issues/01-domain-context.md b/.scratch/customer-module/issues/01-domain-context.md new file mode 100644 index 0000000..e6e64a3 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/02-customer-main-table.md b/.scratch/customer-module/issues/02-customer-main-table.md new file mode 100644 index 0000000..b71cd5f --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/03-ownership-views.md b/.scratch/customer-module/issues/03-ownership-views.md new file mode 100644 index 0000000..c979bff --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/04-create-dedup-and-company-lookup.md b/.scratch/customer-module/issues/04-create-dedup-and-company-lookup.md new file mode 100644 index 0000000..a7bff74 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/05-customer-stage.md b/.scratch/customer-module/issues/05-customer-stage.md new file mode 100644 index 0000000..410e58e --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/06-contacts-crud.md b/.scratch/customer-module/issues/06-contacts-crud.md new file mode 100644 index 0000000..b101462 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/07-detail-follow-oplog.md b/.scratch/customer-module/issues/07-detail-follow-oplog.md new file mode 100644 index 0000000..6d85522 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/08-transfer.md b/.scratch/customer-module/issues/08-transfer.md new file mode 100644 index 0000000..94fa928 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/09-import.md b/.scratch/customer-module/issues/09-import.md new file mode 100644 index 0000000..6a88465 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/10-overdue-reminder-rule.md b/.scratch/customer-module/issues/10-overdue-reminder-rule.md new file mode 100644 index 0000000..2a8522f --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/issues/11-saved-view-integration.md b/.scratch/customer-module/issues/11-saved-view-integration.md new file mode 100644 index 0000000..c60fc62 --- /dev/null +++ b/.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)_ diff --git a/.scratch/customer-module/map.md b/.scratch/customer-module/map.md new file mode 100644 index 0000000..0a7f78a --- /dev/null +++ b/.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 + + + +- **[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 + + + +| 票 | 主题 | 依赖 | 前沿位置 | +|---|---|---|---| +| 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 + + + +- **战略协议**(A4-4-9/10 等级/新增/编辑)— 独立子域,另 effort。 +- **联系人图谱**(职务二级联动树 + 人脉关系连线 + 层级自动生成)— 复杂可视化,另 effort;本图职务只留占位列。 +- **客户查重列表 + 客户合并**(A4-6-1/2/3)— grill Q4 拍板不做;查重口径写死在新增流程。 +- **OCR 快捷新增**(联系人/客户 OCR 识别)— 外部服务,划出;只在新增流程留 seam。 +- **企查查等工商数据服务内部**— 只定调用契约 seam(调用点/回填范围/异常/精确查重触发),不深挖服务实现。 +- **客户阶段「已成交客户」**(由 A5 项目首次中标驱动)— 留 seam,本图只做潜在→重潜。 +- **「关联项目」详情页签**(读 A5 项目模块)— A5 未建,划出。 +- **联系人导入**— 划出(客户导入本图做,联系人导入随图谱 effort)。 diff --git a/.scratch/opp-customer-dependency/demo-addcustomer.js b/.scratch/opp-customer-dependency/demo-addcustomer.js new file mode 100644 index 0000000..846b3bf --- /dev/null +++ b/.scratch/opp-customer-dependency/demo-addcustomer.js @@ -0,0 +1,50 @@ +/* ---------- 添加关联客户(票04 客户信息区联调:类型筛选/搜索/联系人级联/角色/主要) ---------- */ +let _custPicked = null; +async function dlgAddCustomer() { + _custPicked = null; + openDlg('添加关联客户', ` + ${fld('客户类型(筛选)', ``)} + ${fld('关联客户 *', `
+
+
未选择客户
`)} + ${fld('客户联系人(选客户后级联)', ``)} + ${fld('客户角色 *', ``)} + ${fld('是否主要客户 *', '')} +
+
类型/角色下拉取自 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
`, + [{ 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 => + `
· ${esc(c.customerName || c.customerId)} id=${esc(c.customerId)}
`).join('') + || '
无匹配客户
'; +} +async function pickCust(cid, cname) { + _custPicked = { customerId: cid, customerName: cname }; + document.getElementById('custPicked').innerHTML = `已选:${esc(cname)}(id=${esc(cid)})`; + // 级联刷新客户联系人(切换客户 → 重新拉取 → 清空重选) + const sel = document.getElementById('custContactSel'); + sel.innerHTML = ''; + const contacts = await tryApi('customer/contacts', () => api('GET', '/api/opportunity/customer/contacts', { params: { customerId: cid } }), true) || []; + sel.innerHTML = '' + contacts.map(ct => + ``).join('') + + (contacts.length ? '' : ''); + if (!contacts.length) sel.insertAdjacentHTML('beforeend', ''); +} +async function doAddCustomer() { + const err = document.getElementById('addCustErr'); + if (!_custPicked) { err.innerHTML = '
请先搜索并选择关联客户
'; return; } + if (!v('customerRole')) { err.innerHTML = '
客户角色必选
'; 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 后六必填=名称/来源/招标形式/省/市/属地) ---------- */ diff --git a/.scratch/opp-customer-dependency/demo04-1-customer-subtab.png b/.scratch/opp-customer-dependency/demo04-1-customer-subtab.png new file mode 100644 index 0000000..4ea7cca Binary files /dev/null and b/.scratch/opp-customer-dependency/demo04-1-customer-subtab.png differ diff --git a/.scratch/opp-customer-dependency/demo04-2-dialog-open.png b/.scratch/opp-customer-dependency/demo04-2-dialog-open.png new file mode 100644 index 0000000..e984afc Binary files /dev/null and b/.scratch/opp-customer-dependency/demo04-2-dialog-open.png differ diff --git a/.scratch/opp-customer-dependency/demo04-3-search-results.png b/.scratch/opp-customer-dependency/demo04-3-search-results.png new file mode 100644 index 0000000..aae8714 Binary files /dev/null and b/.scratch/opp-customer-dependency/demo04-3-search-results.png differ diff --git a/.scratch/opp-customer-dependency/demo04-4-selected-cascade.png b/.scratch/opp-customer-dependency/demo04-4-selected-cascade.png new file mode 100644 index 0000000..1fb8c4a Binary files /dev/null and b/.scratch/opp-customer-dependency/demo04-4-selected-cascade.png differ diff --git a/.scratch/opp-customer-dependency/demo04-5-after-add.png b/.scratch/opp-customer-dependency/demo04-5-after-add.png new file mode 100644 index 0000000..b890876 Binary files /dev/null and b/.scratch/opp-customer-dependency/demo04-5-after-add.png differ diff --git a/.scratch/opp-customer-dependency/drive-demo04.py b/.scratch/opp-customer-dependency/drive-demo04.py new file mode 100644 index 0000000..03bd985 --- /dev/null +++ b/.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') diff --git a/.scratch/opp-customer-dependency/issues/01-customer-type-dict-seed.md b/.scratch/opp-customer-dependency/issues/01-customer-type-dict-seed.md new file mode 100644 index 0000000..29c6c61 --- /dev/null +++ b/.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 回归验证出数)。 diff --git a/.scratch/opp-customer-dependency/issues/02-customer-contacts-stub.md b/.scratch/opp-customer-dependency/issues/02-customer-contacts-stub.md new file mode 100644 index 0000000..639ad2a --- /dev/null +++ b/.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 调用同步改址。 diff --git a/.scratch/opp-customer-dependency/issues/03-frontend-integration-regression.md b/.scratch/opp-customer-dependency/issues/03-frontend-integration-regression.md new file mode 100644 index 0000000..9f979fb --- /dev/null +++ b/.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`。 diff --git a/.scratch/opp-customer-dependency/issues/04-demo-frontend-acceptance.md b/.scratch/opp-customer-dependency/issues/04-demo-frontend-acceptance.md new file mode 100644 index 0000000..f0ddfde --- /dev/null +++ b/.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 → +添加关联客户,走一遍搜索/级联/提交。 diff --git a/.scratch/opp-customer-dependency/verify-03-result.json b/.scratch/opp-customer-dependency/verify-03-result.json new file mode 100644 index 0000000..fdff5d6 --- /dev/null +++ b/.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 + } +} diff --git a/.scratch/opp-customer-dependency/verify-03.py b/.scratch/opp-customer-dependency/verify-03.py new file mode 100644 index 0000000..bd917da --- /dev/null +++ b/.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)) diff --git a/.scratch/opp-customer-dependency/wayfinder/商机前端联调工作量评估.md b/.scratch/opp-customer-dependency/wayfinder/商机前端联调工作量评估.md new file mode 100644 index 0000000..af93ec8 --- /dev/null +++ b/.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` 现状代码交叉核对。_ diff --git a/.scratch/opportunity-acceptance/boot-run.log b/.scratch/opportunity-acceptance/boot-run.log index a31a0e7..2d5fce8 100644 Binary files a/.scratch/opportunity-acceptance/boot-run.log and b/.scratch/opportunity-acceptance/boot-run.log differ diff --git a/.scratch/opportunity-board-display-name/issues/01-stage-template-display-name-unique.md b/.scratch/opportunity-board-display-name/issues/01-stage-template-display-name-unique.md new file mode 100644 index 0000000..dac4835 --- /dev/null +++ b/.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),错误信息指明哪两个节点撞名 +- [ ] 仅草稿可改规则不变;发布中/已停用版本仍不可直接改 +- [ ] 跨模板同名不触发校验(看板按展示名合并列是允许语义) +- [ ] 覆盖校验逻辑的单测 diff --git a/.scratch/opportunity-board-display-name/issues/02-stage-display-name-resolution-contract.md b/.scratch/opportunity-board-display-name/issues/02-stage-display-name-resolution-contract.md new file mode 100644 index 0000000..202d527 --- /dev/null +++ b/.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` 字典名 +- [ ] 覆盖两个能力的单测 diff --git a/.scratch/opportunity-board-display-name/issues/03-board-drop-status-filter.md b/.scratch/opportunity-board-display-name/issues/03-board-drop-status-filter.md new file mode 100644 index 0000000..286d299 --- /dev/null +++ b/.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」工作区定义不受影响 +- [ ] 覆盖数据集口径的单测 diff --git a/.scratch/opportunity-board-display-name/issues/04-board-group-by-display-name.md b/.scratch/opportunity-board-display-name/issues/04-board-group-by-display-name.md new file mode 100644 index 0000000..12cb291 --- /dev/null +++ b/.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` +- [ ] 覆盖两个端点的单测 diff --git a/.scratch/opportunity-board-display-name/issues/05-board-integration-tests.md b/.scratch/opportunity-board-display-name/issues/05-board-integration-tests.md new file mode 100644 index 0000000..8c32f5a --- /dev/null +++ b/.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` +- [ ] 集成测试覆盖阶段模板发布校验拒绝重复展示名 diff --git a/.scratch/opportunity-board-display-name/issues/06-board-api-docs.md b/.scratch/opportunity-board-display-name/issues/06-board-api-docs.md new file mode 100644 index 0000000..df686f9 --- /dev/null +++ b/.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` 契约说明移除 diff --git a/.scratch/opportunity-board-display-name/issues/07-code-review.md b/.scratch/opportunity-board-display-name/issues/07-code-review.md new file mode 100644 index 0000000..df8809b --- /dev/null +++ b/.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 diff --git a/.scratch/opportunity-e2e/demo/index.html b/.scratch/opportunity-e2e/demo/index.html index 352bffb..4f16d5a 100644 --- a/.scratch/opportunity-e2e/demo/index.html +++ b/.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; } @@ -68,6 +78,8 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef

商机模块 E2E Demo

当前用户: + + 未登录 @@ -102,6 +114,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
看板汇总(当前视图)D-15
+
看板泳道(当前视图 · 按展示名取卡,offset+limit 滚动加载)
随视图加载,每列先拉 5 条,点「更多」续拉