Browse Source

feat(dict): 票09 步4 industry 重种两级树 60 项 + 种子对内置项权威(剪除旧8)

- ItemSeed 加 parentCode (兼容旧6参构造); upsertItem 按 parentCode 同组解析 parentId (一级先入库故可查到)
- run 增 pruneStaleBuiltinItems: 种子对 builtin 项权威, 不在种子的旧内置项连 ref_count 一并剪除; 用户项(builtin=false)绝不触碰 (US-41)
- industry 8旧扁平项(manufacturing/it/...) → 全系统通用两级树 9一级+51二级 (产品 20260825 全树)
- 测试: 首装/幂等 168→220, 用户跳过 130→182; 加 industry 树结构断言(9一级/51二级); removedFromSeed 语义翻转为剪除 + 新增用户项不剪除测试; 51 全绿
master
luoweijian 2 weeks ago
parent
commit
68b62e6e33
  1. 129
      crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
  2. 51
      crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java

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

@ -19,6 +19,7 @@ import org.springframework.stereotype.Component;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
/** /**
* 内置字典幂等初始化T08ADR-0015 * 内置字典幂等初始化T08ADR-0015
@ -91,16 +92,77 @@ public class DictDataInitializer implements CommandLineRunner {
new ItemSeed("potential", "潜在客户", "potential", 1, null, true), new ItemSeed("potential", "潜在客户", "potential", 1, null, true),
new ItemSeed("active", "合作中", "active", 2, null, false), new ItemSeed("active", "合作中", "active", 2, null, false),
new ItemSeed("lost", "已流失", "lost", 3, null, false))), new ItemSeed("lost", "已流失", "lost", 3, null, false))),
new GroupSeed("industry", "所属行业", 3, "客户所属行业", new GroupSeed("industry", "所属行业", 3, "全系统通用两级行业树(客户选二级/商机选一级,产品 20260825)",
List.of( List.of(
new ItemSeed("manufacturing", "制造业", "manufacturing", 1, null, false), // 一级 1 政府机关
new ItemSeed("it", "信息技术", "it", 2, null, false), new ItemSeed("gov", "政府机关", "gov", 1, null, false),
new ItemSeed("finance", "金融", "finance", 3, null, false), new ItemSeed("gov_1", "人大政协", "gov_1", 1, null, false, "gov"),
new ItemSeed("healthcare", "医疗健康", "healthcare", 4, null, false), new ItemSeed("gov_2", "应急局", "gov_2", 2, null, false, "gov"),
new ItemSeed("education", "教育", "education", 5, null, false), new ItemSeed("gov_3", "烟草局", "gov_3", 3, null, false, "gov"),
new ItemSeed("retail", "零售", "retail", 6, null, false), new ItemSeed("gov_4", "疾控中心", "gov_4", 4, null, false, "gov"),
new ItemSeed("real_estate", "房地产", "real_estate", 7, null, false), new ItemSeed("gov_5", "大数据中心", "gov_5", 5, null, false, "gov"),
new ItemSeed("other", "其他", "other", 8, null, false))), new ItemSeed("gov_6", "税务局", "gov_6", 6, null, false, "gov"),
new ItemSeed("gov_7", "教育局", "gov_7", 7, null, false, "gov"),
new ItemSeed("gov_8", "民政局", "gov_8", 8, null, false, "gov"),
new ItemSeed("gov_9", "人社局", "gov_9", 9, null, false, "gov"),
new ItemSeed("gov_10", "财政局", "gov_10", 10, null, false, "gov"),
new ItemSeed("gov_11", "国土局", "gov_11", 11, null, false, "gov"),
new ItemSeed("gov_12", "水利局", "gov_12", 12, null, false, "gov"),
new ItemSeed("gov_13", "林业局", "gov_13", 13, null, false, "gov"),
new ItemSeed("gov_14", "商务局", "gov_14", 14, null, false, "gov"),
new ItemSeed("gov_15", "文体旅局", "gov_15", 15, null, false, "gov"),
new ItemSeed("gov_16", "气象局", "gov_16", 16, null, false, "gov"),
new ItemSeed("gov_17", "退役军人事务部", "gov_17", 17, null, false, "gov"),
new ItemSeed("gov_18", "融媒体中心", "gov_18", 18, null, false, "gov"),
new ItemSeed("gov_19", "人防办", "gov_19", 19, null, false, "gov"),
// 一级 2 政法国防
new ItemSeed("law", "政法国防", "law", 2, null, false),
new ItemSeed("law_1", "公安", "law_1", 1, null, false, "law"),
new ItemSeed("law_2", "检察院", "law_2", 2, null, false, "law"),
new ItemSeed("law_3", "法院", "law_3", 3, null, false, "law"),
new ItemSeed("law_4", "司法厅", "law_4", 4, null, false, "law"),
new ItemSeed("law_5", "监狱", "law_5", 5, null, false, "law"),
new ItemSeed("law_6", "海关-边检站", "law_6", 6, null, false, "law"),
new ItemSeed("law_7", "武警", "law_7", 7, null, false, "law"),
new ItemSeed("law_8", "特警", "law_8", 8, null, false, "law"),
new ItemSeed("law_9", "消防", "law_9", 9, null, false, "law"),
// 一级 3 教育
new ItemSeed("edu", "教育", "edu", 3, null, false),
new ItemSeed("edu_1", "普教", "edu_1", 1, null, false, "edu"),
new ItemSeed("edu_2", "职校", "edu_2", 2, null, false, "edu"),
new ItemSeed("edu_3", "党校", "edu_3", 3, null, false, "edu"),
// 一级 4 医疗健康
new ItemSeed("health", "医疗健康", "health", 4, null, false),
new ItemSeed("health_1", "医院", "health_1", 1, null, false, "health"),
new ItemSeed("health_2", "康养中心", "health_2", 2, null, false, "health"),
// 一级 5 金融
new ItemSeed("finance", "金融", "finance", 5, null, false),
new ItemSeed("finance_1", "银行", "finance_1", 1, null, false, "finance"),
new ItemSeed("finance_2", "保险公司", "finance_2", 2, null, false, "finance"),
// 一级 6 交通
new ItemSeed("transport", "交通", "transport", 6, null, false),
new ItemSeed("transport_1", "轨道交通", "transport_1", 1, null, false, "transport"),
new ItemSeed("transport_2", "机场", "transport_2", 2, null, false, "transport"),
new ItemSeed("transport_3", "高铁", "transport_3", 3, null, false, "transport"),
new ItemSeed("transport_4", "高速公路", "transport_4", 4, null, false, "transport"),
// 一级 7 能源与制造
new ItemSeed("energy", "能源与制造", "energy", 7, null, false),
new ItemSeed("energy_1", "石化-能源-矿业", "energy_1", 1, null, false, "energy"),
new ItemSeed("energy_2", "发电站-核电站", "energy_2", 2, null, false, "energy"),
new ItemSeed("energy_3", "电力实训基地", "energy_3", 3, null, false, "energy"),
new ItemSeed("energy_4", "工厂", "energy_4", 4, null, false, "energy"),
// 一级 8 文化旅游
new ItemSeed("tourism", "文化旅游", "tourism", 8, null, false),
new ItemSeed("tourism_1", "文旅", "tourism_1", 1, null, false, "tourism"),
new ItemSeed("tourism_2", "酒店", "tourism_2", 2, null, false, "tourism"),
new ItemSeed("tourism_3", "展馆-博物馆", "tourism_3", 3, null, false, "tourism"),
// 一级 9 园区与基建
new ItemSeed("park", "园区与基建", "park", 9, null, false),
new ItemSeed("park_1", "智慧园区", "park_1", 1, null, false, "park"),
new ItemSeed("park_2", "集团大楼", "park_2", 2, null, false, "park"),
new ItemSeed("park_3", "商业中心", "park_3", 3, null, false, "park"),
new ItemSeed("park_4", "智算中心", "park_4", 4, null, false, "park"),
new ItemSeed("park_5", "低空飞行基地", "park_5", 5, null, false, "park"))),
new GroupSeed("brand", "品牌", 4, "线索品牌(对齐蓝湖原型)", new GroupSeed("brand", "品牌", 4, "线索品牌(对齐蓝湖原型)",
List.of( List.of(
new ItemSeed("itc", "itc", "itc", 1, null, false), new ItemSeed("itc", "itc", "itc", 1, null, false),
@ -276,6 +338,7 @@ public class DictDataInitializer implements CommandLineRunner {
for (ItemSeed item : group.items()) { for (ItemSeed item : group.items()) {
upsertItem(persisted, item); upsertItem(persisted, item);
} }
pruneStaleBuiltinItems(persisted, group);
for (ItemSeed item : group.items()) { for (ItemSeed item : group.items()) {
if (item.defaultItem()) { if (item.defaultItem()) {
setDefaultIfAbsent(persisted, item); setDefaultIfAbsent(persisted, item);
@ -366,6 +429,18 @@ public class DictDataInitializer implements CommandLineRunner {
DictItem exist = dictItemMapper.selectOne(new LambdaQueryWrapper<DictItem>() DictItem exist = dictItemMapper.selectOne(new LambdaQueryWrapper<DictItem>()
.eq(DictItem::getGroupId, group.getId()) .eq(DictItem::getGroupId, group.getId())
.eq(DictItem::getCode, seed.code())); .eq(DictItem::getCode, seed.code()));
// 两级树形:解析父项 id(同组、按 parentCode 查;一级先于二级入库故可查到)
Long parentId = null;
if (seed.parentCode() != null) {
DictItem parent = dictItemMapper.selectOne(new LambdaQueryWrapper<DictItem>()
.eq(DictItem::getGroupId, group.getId())
.eq(DictItem::getCode, seed.parentCode()));
if (parent == null) {
throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID,
"种子二级项父未先入库(检查种子顺序):" + group.getCode() + "/" + seed.code());
}
parentId = parent.getId();
}
if (exist == null) { if (exist == null) {
DictItem created = new DictItem(); DictItem created = new DictItem();
created.setGroupId(group.getId()); created.setGroupId(group.getId());
@ -374,6 +449,7 @@ public class DictDataInitializer implements CommandLineRunner {
created.setValue(seed.value()); created.setValue(seed.value());
created.setSortNo(seed.sortNo()); created.setSortNo(seed.sortNo());
created.setDescription(seed.description()); created.setDescription(seed.description());
created.setParentId(parentId);
created.setStatus(DictConstants.STATUS_ENABLED); created.setStatus(DictConstants.STATUS_ENABLED);
created.setBuiltin(true); created.setBuiltin(true);
created.setDeleteKey(0L); created.setDeleteKey(0L);
@ -388,10 +464,11 @@ public class DictDataInitializer implements CommandLineRunner {
return; return;
} }
if (Boolean.TRUE.equals(exist.getBuiltin())) { if (Boolean.TRUE.equals(exist.getBuiltin())) {
// 只加/改不删:更新名称/描述/排序号;不碰编码/状态/内置标记/字典值(值属业务契约,管理员本地化后不被种子覆盖) // 只加/改不删:更新名称/描述/排序号 + 父项归属(两级树);不碰编码/状态/内置标记/字典值
exist.setName(seed.name()); exist.setName(seed.name());
exist.setSortNo(seed.sortNo()); exist.setSortNo(seed.sortNo());
exist.setDescription(seed.description()); exist.setDescription(seed.description());
exist.setParentId(parentId);
dictItemMapper.updateById(exist); dictItemMapper.updateById(exist);
} else { } else {
log.warn("字典项 {}/{} 非内置(builtin=false),跳过种子更新(绝不触碰用户字典)", log.warn("字典项 {}/{} 非内置(builtin=false),跳过种子更新(绝不触碰用户字典)",
@ -399,6 +476,31 @@ public class DictDataInitializer implements CommandLineRunner {
} }
} }
/**
* 剪除内置项中已不在种子的项使种子对内置项权威支持口径改版如 industry 8 新树
* <p>仅硬删 builtin=true 且未被引用的项用户项builtin=false绝不触碰US-41
* 同事务清理其引用计数行一级/二级一并删除无外键无顺序要求</p>
*/
private void pruneStaleBuiltinItems(DictGroup group, GroupSeed seed) {
if (!Boolean.TRUE.equals(group.getBuiltin())) {
return;
}
Set<String> seedCodes = seed.items().stream().map(ItemSeed::code).collect(Collectors.toSet());
List<DictItem> builtinItems = dictItemMapper.selectList(new LambdaQueryWrapper<DictItem>()
.eq(DictItem::getGroupId, group.getId())
.eq(DictItem::getBuiltin, true));
for (DictItem item : builtinItems) {
if (seedCodes.contains(item.getCode())) {
continue;
}
dictItemMapper.deleteById(item.getId());
dictRefCountMapper.delete(new LambdaQueryWrapper<DictRefCount>()
.eq(DictRefCount::getGroupCode, group.getCode())
.eq(DictRefCount::getItemCode, item.getCode()));
log.info("剪除旧内置字典项(已不在种子):{}/{}", group.getCode(), item.getCode());
}
}
/** /**
* 种子默认项仅分组尚无默认且默认项自身启用时设置 * 种子默认项仅分组尚无默认且默认项自身启用时设置
* <p>管理员已设默认或停用默认项后自动取消都不会被种子覆盖/复活</p> * <p>管理员已设默认或停用默认项后自动取消都不会被种子覆盖/复活</p>
@ -430,7 +532,12 @@ public class DictDataInitializer implements CommandLineRunner {
/*-------- 种子结构(Excel 替换口的数据契约,包私有供测试注入)--------*/ /*-------- 种子结构(Excel 替换口的数据契约,包私有供测试注入)--------*/
record ItemSeed(String code, String name, String value, record ItemSeed(String code, String name, String value,
Integer sortNo, String description, boolean defaultItem) { Integer sortNo, String description, boolean defaultItem, String parentCode) {
/** 便捷构造:无父项(顶层/扁平项),parentCode=null */
ItemSeed(String code, String name, String value,
Integer sortNo, String description, boolean defaultItem) {
this(code, name, value, sortNo, description, defaultItem, null);
}
} }
record GroupSeed(String code, String name, Integer sortNo, record GroupSeed(String code, String name, Integer sortNo,

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

@ -31,7 +31,7 @@ class DictDataInitializerTest extends AbstractDictH2Test {
} }
@Test @Test
@DisplayName("首装:22 个内置分组 + 168 个内置项全部建库,ref_count 预建,种子默认项生效") @DisplayName("首装:22 个内置分组 + 220 个内置项全部建库,ref_count 预建,种子默认项生效")
void firstRun_createsBuiltinDictsWithDefaults() { void firstRun_createsBuiltinDictsWithDefaults() {
initializer.run(); initializer.run();
@ -48,11 +48,20 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED); assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED);
List<DictItem> items = dictItemMapper.selectList(null); List<DictItem> items = dictItemMapper.selectList(null);
assertThat(items).hasSize(168); // 163 + 资料类型5 assertThat(items).hasSize(220); // 160(非 industry) + industry 两级树 60(9一级+51二级)
assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin())); assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin()));
// 行业两级树:9 个一级(parentId=null)+ 51 个二级(parentId 非空)
DictGroup industryGroup = groups.stream()
.filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow();
List<DictItem> industryItems = items.stream()
.filter(i -> i.getGroupId().equals(industryGroup.getId())).toList();
assertThat(industryItems).hasSize(60);
assertThat(industryItems).filteredOn(i -> i.getParentId() == null).hasSize(9);
assertThat(industryItems).filteredOn(i -> i.getParentId() != null).hasSize(51);
// 引用计数行预建(与新建接口同口径) // 引用计数行预建(与新建接口同口径)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(168); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220);
DictGroup statusGroup = groups.stream() DictGroup statusGroup = groups.stream()
.filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow(); .filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow();
DictItem potential = dictItemMapper.selectList(null).stream() DictItem potential = dictItemMapper.selectList(null).stream()
@ -74,8 +83,8 @@ class DictDataInitializerTest extends AbstractDictH2Test {
initializer.run(); initializer.run();
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(168); assertThat(dictItemMapper.selectCount(null)).isEqualTo(220);
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(168); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1);
} }
@ -157,8 +166,8 @@ class DictDataInitializerTest extends AbstractDictH2Test {
} }
@Test @Test
@DisplayName("只加不删:种子外既有内置项在重跑后仍保留") @DisplayName("种子权威:种子外旧内置项重跑后被剪除(支持口径改版,如 industry 树形化)")
void removedFromSeed_notDeleted() { void removedFromSeed_prunedForBuiltin() {
initializer.run(); initializer.run();
DictGroup industry = dictGroupMapper.selectList(null).stream() DictGroup industry = dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow(); .filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow();
@ -174,9 +183,31 @@ class DictDataInitializerTest extends AbstractDictH2Test {
legacy.setDeleteKey(0L); legacy.setDeleteKey(0L);
dictItemMapper.insert(legacy); dictItemMapper.insert(legacy);
initializer.run();
// builtin 且不在种子 → 被剪除
assertThat(dictItemMapper.selectList(null).stream()
.filter(i -> i.getCode().equals("legacy_removed"))).isEmpty();
}
@Test
@DisplayName("剪除只碰内置项:内置分组下用户手建项(builtin=false)不被剪除")
void removedFromSeed_userItemNeverPruned() {
initializer.run();
DictGroup industry = dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow();
DictItem userItem = new DictItem();
userItem.setGroupId(industry.getId());
userItem.setCode("user_custom");
userItem.setName("管理员自建行业");
userItem.setValue("USER_CUSTOM");
userItem.setStatus(DictConstants.STATUS_ENABLED);
userItem.setBuiltin(false);
userItem.setDeleteKey(0L);
dictItemMapper.insert(userItem);
initializer.run(); initializer.run();
assertThat(dictItemMapper.selectList(null).stream() assertThat(dictItemMapper.selectList(null).stream()
.filter(i -> i.getCode().equals("legacy_removed"))).hasSize(1); .filter(i -> i.getCode().equals("user_custom"))).hasSize(1);
} }
@Test @Test
@ -202,8 +233,8 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); // lead_source(用户) + 其余 21 个内置分组 assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); // lead_source(用户) + 其余 21 个内置分组
assertThat(dictGroupMapper.selectList(null).stream() assertThat(dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry"))).hasSize(1); .filter(g -> g.getCode().equals("industry"))).hasSize(1);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(130); // 168 - 渠道 38(用户分组整组跳过) assertThat(dictItemMapper.selectCount(null)).isEqualTo(182); // 220 - 渠道 38(用户分组整组跳过)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(130); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(182);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项 assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项
} }

Loading…
Cancel
Save