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

95 lines
9.1 KiB

# 09 — 字典项两级树形能力(DictItem 加 parent_id)
**Type:** grilling → task(本票先 grill 拍板不变式,再落地)
**Status:** done
**Blocked by:** 04
## Problem Statement
现有 `DictItem` 是**扁平模型**(`group_id + code + name + value + sort_no + status`,无 `parent_id`)。ADR-0015 spec 开篇即写「两级结构(分组 → 字典项)」——即层级止步于「分组含一批平铺项」,**字典项自身之间没有父子关系**。
商机模块(20260825 产品拍板)要求「行业」字典是**多级树形**:一级 9 个大类(政府机关 / 政法国防 / 教育 / 医疗健康 / 金融 / 交通 / 能源与制造 / 文化旅游 / 园区与基建),每个大类下挂若干二级细分(如 政府机关 → 人大政协 / 应急局 / 烟草局 / …)。且明确:
- **全系统通用**:客户、商机共用同一套 `industry`(不单建 `industry_code`)——顺带解掉此前挂起的「路 A/B」之争,选路 A 的反面:**复用并覆盖现有 `industry`**。
- **商机只选到一级**:商机主表 `industry_code` 字段存一级 code。
- 客户侧是否选到二级 = **待拍板**(见 Q6)。
现有 `industry` 分组种子是 8 项旧口径(制造业 / 信息技术 / 金融 / 医疗健康 / 教育 / 零售 / 房地产 / 其他),与新树完全不同口径,**覆盖会造成客户存量数据 code 悬空**(见 Q5)。
## 为什么必须先 grill(不能直接种)
`DictItem``parent_id`**crm-dict 基础模型扩展**,影响面远超「种一批值」:
- 实体 + DDL(现有行 parent_id=null=顶层)
- `DictItemDTO`(加 parentId / 可能 children 树形出参)
- `DictItemServiceImpl` 校验不变式(见下)
- `DictQueryService` 新增树形查询 + Caffeine 缓存结构调整
- 现有 45 个 crm-dict 测试 + 全系统扁平消费方(lead / customer / opportunity)回归
- 前端字典管理页树形录入/展示契约
砸坏一个被全系统依赖的模块风险太高,故按仓库 issue-tracker 规矩先立票 grill。
## 待拍板不变式(grill 逐条)
- **Q1 层级上限**:两级封顶?还是允许任意深度树?(商机只需两级,但字典是通用能力——限死两级更简单、更可控;开放多级更通用但校验/查询复杂。)
- **Q2 父子约束**:二级项的 `parent_id` 必须指向**同分组内**的另一项;禁止跨组;禁止自引用;禁止循环。一级项 `parent_id = null`。是否要求「有子项的项不能自己再当别人的子项」(即强制恰好两层)?
- **Q3 停用/删除级联**:
- 停用一级项时,其二级子项是否连带不可选(对齐分组停用→整组不可选的既有语义)?
- 删除一级项:若其下有存活子项,**拦截**(提示先删子项)还是**级联软删**?(建议拦截,与「删分组需组内无项」的既有保守语义一致。)
- **Q4 树形查询 API**:`DictQueryService` 加什么?`listTree(groupCode)` 返回嵌套?`listChildren(groupCode, parentCode)`?`getItem` 扁平接口保持不变(向后兼容所有现有消费方)。缓存怎么建(现 Caffeine 按 groupCode→扁平 list,加树需另建 key 还是复用 list 内存组树)。
- **Q5 存量数据安全**:现有 `industry` 8 项(manufacturing/it/finance/…)**是否已有客户业务数据引用**?覆盖成新树前必须确认——若有存量,旧 code 会悬空(客户列表行业列显示空)。需要 DBA/产品确认,或提供 code 映射迁移。
- **Q6 客户侧选到几级**:商机存一级 code 已定。客户模块 `industry` 字段是选一级还是可选到二级?决定二级项是否只是「商机的分类维度」还是「客户可直接选的值」。
- **Q7 商机存一级的落库形态**:商机 `industry_code` 存一级项 code(如 `industry_gov`)。二级仅作字典维护/展示层级,不进商机主表?确认。
## 实现范围(Q 拍板后)
1. `DictItem``parentId`(nullable,顶层为 null);DDL 自动加列。
2. `DictItemDTO``parentId`(+ 视 Q4 决定是否加 children)。
3. `DictItemServiceImpl.saveItem` 增校验:父项存在且同组、层级上限(Q1)、无自引用/循环(Q2);`doDeleteItem` 增级联策略(Q3)。
4. `DictQueryService` 增树形查询(Q4)+ 缓存。
5. 种子:`industry` 分组按新树重种(9 一级 + 全部二级),处理存量覆盖(Q5)。
6. 测试:树形 CRUD / 父子约束 / 级联 / 树形查询;回归现有 45 扁平测试。
## 附:产品给定的行业全树(20260825,待 Q 拍板后种)
一级(9)→ 二级:
- 政府机关:人大政协 / 应急局 / 烟草局 / 疾控中心 / 大数据中心 / 税务局 / 教育局 / 民政局 / 人社局 / 财政局 / 国土局 / 水利局 / 林业局 / 商务局 / 文体旅局 / 气象局 / 退役军人事务部 / 融媒体中心 / 人防办
- 政法国防:公安 / 检察院 / 法院 / 司法厅 / 监狱 / 海关-边检站 / 武警 / 特警 / 消防
- 教育:普教 / 职校 / 党校
- 医疗健康:医院 / 康养中心
- 金融:银行 / 保险公司
- 交通:轨道交通 / 机场 / 高铁 / 高速公路
- 能源与制造:石化-能源-矿业 / 发电站-核电站 / 电力实训基地 / 工厂
- 文化旅游:文旅 / 酒店 / 展馆-博物馆
- 园区与基建:智慧园区 / 集团大楼 / 商业中心 / 智算中心 / 低空飞行基地
## Answer(20260825 grill 拍板)
**Status → resolved(进入实现)**
- **Q1 层级上限**:**两级封顶**。一级 `parent_id=null`,二级挂一级;不做任意深度树(YAGNI,将来需三级再开票)。
- **Q2 父子约束**:采纳。二级 `parent_id` 必须指向**同分组内**的一级项;禁跨组、禁自引用、禁循环;一级 `parent_id=null`;**父项自身必须是一级**(parent_id=null),不允许挂到二级下(Q1 两级的强约束)。
- **Q3 停用/删除级联**:采纳建议。(a) 停用一级项 → 其二级子项**连带不可选**(对齐"停用分组→整组不可选"语义,`effectiveSelectable` 需回溯父项状态);(b) 删除有存活子项的一级项 → **拦截**并提示先删子项(对齐"删分组需组内无项"的保守语义),不级联软删。
- **Q4 树形查询 API + 缓存**:采纳。`getItem`(扁平按 code)**保持不变**,现存消费方零改动;新增 `listTree(groupCode)`(一级带 children 嵌套)+ `listChildren(groupCode, parentCode)`;缓存**复用现有 Caffeine 按 groupCode 的扁平 list**,内存组树,不新建缓存 key。
- **Q5 存量数据**:⭐ **无存量数据**。现有 `industry` 8 项无客户业务引用 → 种子直接**覆盖重种**为新树,零迁移、无悬空风险。
- **Q6 客户侧选到几级**:**客户选到二级、商机选一级**。二级项是客户模块可直接落库的真实值(非纯分类维度)。
- **Q7 商机落库形态**:商机主表 `industry_code` **只存一级项 code**;二级仅供客户选择与字典层级维护,不进商机主表。
### 实现顺序(每步独立编译测试提交)
1. `DictItem``parentId` + `DictItemDTO``parentId`(DDL 自动加列,现有行 null=顶层)。
2. `DictItemServiceImpl.saveItem` 父子校验(Q2)+ `doDeleteItem` 删除拦截(Q3-b)+ `effectiveSelectable` 回溯父项停用(Q3-a)。
3. `DictQueryService``listTree`/`listChildren`(Q4),扁平内存组树。
4. 种子:`industry` 分组重种新树(9 一级 + 全二级,覆盖旧 8 项);测试断言同步。
5. 回归现有 45 扁平测试全绿 + 新增树形测试。
### 实现落地(20260825,5 步全绿,Status → done)
- **步1** DictItem/DictItemDTO 加 parentId(nullable,顶层=null);H2 测试 schema dict_item 加 parent_id;45 全绿。
- **步2** saveItem 父项校验(同组/父为一级/禁自引用,父不存在 63012、跨组或超两级 63001);doDeleteItem 删一级项若有存活子项则拒 63010;enrich effectiveSelectable 批量回溯父状态;+3 测试,48 全绿。
- **步3** listTree 复用扁平启用列表内存组两级树(不新建缓存 key);listChildren 取一级直接启用子项;DictItemDTO 加 children;getItem/listEnabledItems 扁平接口不变,消费方零改动;+2 测试,50 全绿。
- **步4** ItemSeed 加 parentCode(兼容旧 6 参构造);upsertItem 按 parentCode 同组解析 parentId;run 增 pruneStaleBuiltinItems(种子对 builtin 项权威,剪除旧 8 扁平项连 ref_count,用户项 builtin=false 绝不触碰);industry 8 旧项 → 9 一级+51 二级;首装 168→220、用户跳过 130→182;removedFromSeed 语义翻转为剪除 + 新增用户项不剪除测试;51 全绿。
- **步5** OpportunityViewQueryImpl.fillDictNames 从不存在的 industry_code 分组改查 industry 两级树分组;回归 dict 51 / lead 116 / opportunity 152 全绿。
**item 总量演进**:168 →(industry 8→60)→ **220**。**注意**:pruneStaleBuiltinItems 使种子成为 builtin 项的权威源(此前“只加不删”语义在 builtin 项上被 Q5 覆盖决策取代;用户项 builtin=false 仍绝不触碰,US-41 不变)。