♻️ 生效配置与运单快照的取法、二选一规则收进 LiveProcessConfigSupport 一处

原先三处各写一份(运单服务、司机运单服务、运次读服务),且规则并不完全一样:司机端与运次读服务
在生效配置没有可打卡节点时回退快照,运单服务不回退。两份长得一样却不一样,改一处没有任何提示。

新类把取配置(单项目 LIKE + 逗号分段精确比对、批量一次查全)与两个变体都收在一处,并在 javadoc 里
写明各自适用面:接单需求、节点门禁、节点订正读 resolve(生效配置是权威,不能被快照顶掉);
司机端打卡页与列表卡读 resolveForPunch(配置被改坏时回退快照,避免打卡页空白)。
三处的 SQL 形状与逐分支行为保持不变,调用点只留一行委托。

新增 LiveProcessConfigSupportTest(8 项)覆盖两条规则的全部形态与项目 id 精确比对;
blade-transport 全量 365 项用例通过。
This commit is contained in:
2026-10-18 02:44:46 +08:00
parent 30c5606832
commit 1720f9192f
5 changed files with 342 additions and 120 deletions
@@ -48,8 +48,8 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
@@ -1185,55 +1185,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson,
* 避免项目配置改坏后司机端打卡页空白。
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照,
* 避免项目配置改坏后司机端打卡页空白。取法与规则都在 {@link LiveProcessConfigSupport}。
*/
private String resolveProcessJson(Waybill waybill) {
if (waybill == null) {
return null;
}
String snapshot = waybill.getProcessJson();
String live = loadLiveProcessConfigJson(waybill.getProjectId());
if (Func.isNotEmpty(live)) {
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
return live;
}
// 动态配置存在但无可打卡节点:仍回退快照
if (Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return live;
}
return snapshot;
}
private String loadLiveProcessConfigJson(Long projectId) {
if (projectId == null) {
return null;
}
String projectIdStr = String.valueOf(projectId);
return processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectIdStr)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectIdStr))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(","))
.map(String::trim)
.anyMatch(projectId::equals);
return LiveProcessConfigSupport.resolveForPunch(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
}
/**
@@ -30,7 +30,6 @@ import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
@@ -40,12 +39,12 @@ import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
@@ -287,6 +286,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
* <p>
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
*/
private void applyLiveProcessConfig(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
@@ -297,60 +297,18 @@ public class TripQueryServiceImpl implements ITripQueryService {
if (projectIds.isEmpty()) {
return;
}
Map<Long, String> live = liveProcessConfigs(projectIds);
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
if (live.isEmpty()) {
return;
}
for (Waybill row : rows) {
String liveJson = live.get(row.getProjectId());
if (Func.isNotEmpty(liveJson)) {
row.setProcessJson(resolveProcessJson(liveJson, row.getProcessJson()));
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
}
}
}
/** 生效配置与快照二选一:生效配置没有可打卡节点时回退快照,两边都取不到节点就按生效配置来 */
private String resolveProcessJson(String liveJson, String snapshot) {
if (WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()
&& Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return liveJson;
}
/** 生效中的过程配置:项目 → 节点配置 JSON,取最近更新的一条 */
private Map<Long, String> liveProcessConfigs(Set<Long> projectIds) {
List<ProcessConfig> configs = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime));
Map<Long, String> live = new LinkedHashMap<>();
for (Long projectId : projectIds) {
if (projectId == null) {
continue;
}
String projectIdStr = String.valueOf(projectId);
for (ProcessConfig config : configs) {
if (Func.isEmpty(config.getNodeConfigJson())
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
continue;
}
live.put(projectId, config.getNodeConfigJson());
break;
}
}
return live;
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(",")).map(String::trim).anyMatch(projectId::equals);
}
/**
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
*/
@@ -45,7 +45,6 @@ import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
@@ -66,6 +65,7 @@ import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.WaybillWrapper;
@@ -822,27 +822,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return null;
}
/** 动态过程配置优先,回退运单快照 */
/**
* 生效配置优先,回退运单快照。
* <p>
* 取法与二选一规则在 {@link LiveProcessConfigSupport};这里不用「生效配置没有可打卡节点就回退快照」
* 那一版——接单需求、节点门禁、节点订正都读这份配置判断节点存不存在,生效配置是权威,
* 不能因为快照里有节点而被顶掉。
*/
private String resolveLiveProcessJson(Waybill waybill) {
if (waybill.getProjectId() != null) {
String projectId = String.valueOf(waybill.getProjectId());
String live = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectId)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
if (Func.isNotEmpty(live)) {
return live;
}
if (waybill == null) {
return null;
}
return waybill.getProcessJson();
return LiveProcessConfigSupport.resolve(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
}
private Map<String, List<String>> buildVoucherTypesIndex(String processJson) {
@@ -1175,15 +1168,6 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setDriverRejectReason(null);
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds)) {
return false;
}
return List.of(projectIds.split(",")).stream()
.map(String::trim)
.anyMatch(projectId::equals);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BusinessRemoveResultVO removeWaybill(String ids) {
@@ -0,0 +1,161 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.service.IProcessConfigService;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。
* <p>
* 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取
* 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。
* 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 ——
* 只靠 LIKE 会把「1234」命中到「12345」上。
* <p>
* 两个二选一版本的区别是**得到什么结果**,不是风格:
* <ul>
* <li>{@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。
* 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。</li>
* <li>{@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。
* 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。</li>
* </ul>
*/
public final class LiveProcessConfigSupport {
private LiveProcessConfigSupport() {
}
/**
* 单个项目的生效配置 JSON,没有则返回 null
*
* @param service 过程配置服务
* @param projectId 项目主键
*/
public static String byProject(IProcessConfigService service, Long projectId) {
if (service == null || projectId == null) {
return null;
}
String projectIdStr = String.valueOf(projectId);
return service.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectIdStr)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(config -> containsProjectId(config.getProjectIds(), projectIdStr))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
}
/**
* 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查
*
* @param service 过程配置服务
* @param projectIds 项目主键集合,允许含 null
*/
public static Map<Long, String> byProjects(IProcessConfigService service, Collection<Long> projectIds) {
Map<Long, String> live = new LinkedHashMap<>();
if (service == null || Func.isEmpty(projectIds)) {
return live;
}
Set<Long> wanted = new LinkedHashSet<>();
projectIds.stream().filter(Objects::nonNull).forEach(wanted::add);
if (wanted.isEmpty()) {
return live;
}
List<ProcessConfig> configs = service.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime));
if (Func.isEmpty(configs)) {
return live;
}
for (Long projectId : wanted) {
String projectIdStr = String.valueOf(projectId);
for (ProcessConfig config : configs) {
if (Func.isEmpty(config.getNodeConfigJson())
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
continue;
}
live.put(projectId, config.getNodeConfigJson());
break;
}
}
return live;
}
/**
* 生效配置优先,取不到时回退运单快照
*
* @param liveJson 生效配置 JSON,可为空
* @param snapshot 运单快照 JSON,可为空
*/
public static String resolve(String liveJson, String snapshot) {
return Func.isNotEmpty(liveJson) ? liveJson : snapshot;
}
/**
* 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来
*
* @param liveJson 生效配置 JSON,可为空
* @param snapshot 运单快照 JSON,可为空
*/
public static String resolveForPunch(String liveJson, String snapshot) {
if (Func.isEmpty(liveJson)) {
return snapshot;
}
if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) {
return liveJson;
}
if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return liveJson;
}
/** 项目集合是逗号串,必须按逗号分段精确比对 */
private static boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(","))
.map(String::trim)
.anyMatch(projectId::equals);
}
}
@@ -0,0 +1,158 @@
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.service.IProcessConfigService;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 生效配置的取法与「生效配置 vs 快照」二选一的唯一实现处。
* <p>
* 两条规则必须分得清:接单需求与节点门禁读 {@code resolve}(生效配置是权威,不能因为快照里有节点被顶掉),
* 司机端打卡页读 {@code resolveForPunch}(生效配置被改坏成没有可打卡节点时回退快照,避免打卡页空白)。
* 排序由 SQL 负责(更新时间倒序、创建时间倒序),这里按「已排好序的列表」构造输入。
*/
class LiveProcessConfigSupportTest {
/** 有可打卡节点(装货,punch=是) */
private static final String PUNCH_JSON =
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
/** 一条可打卡节点都没有(装货被关掉 punch) */
private static final String NO_PUNCH_JSON =
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":false}]";
/** 另一个项目用的、与 PUNCH_JSON 可区分的一份 */
private static final String OTHER_PUNCH_JSON =
"[{\"key\":\"unload\",\"name\":\"卸货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
private IProcessConfigService processConfigService;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
}
@BeforeEach
void setUp() {
processConfigService = mock(IProcessConfigService.class);
}
private void stubConfigs(ProcessConfig... configs) {
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(configs));
}
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
private static ProcessConfig config(String projectIds, String nodeConfigJson) {
ProcessConfig config = new ProcessConfig();
config.setProjectIds(projectIds);
config.setNodeConfigJson(nodeConfigJson);
return config;
}
@Test
@DisplayName("单项目:命中项目的第一条生效配置(列表已按更新时间倒序)")
void singleProjectTakesFirstHit() {
stubConfigs(config("999", OTHER_PUNCH_JSON), config("1234,5678", PUNCH_JSON));
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("单项目:项目 id 按逗号分段精确比对,1234 不命中 12345")
void singleProjectMatchesExactId() {
stubConfigs(config("12345", PUNCH_JSON));
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("单项目:没有生效配置、或项目为空时返回 null")
void singleProjectWithoutConfig() {
stubConfigs();
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
assertNull(LiveProcessConfigSupport.byProject(processConfigService, null));
assertNull(LiveProcessConfigSupport.byProject(null, 1234L));
}
@Test
@DisplayName("单项目:节点配置为空的配置跳过,取后面那条有内容的")
void singleProjectSkipsEmptyNodeJson() {
stubConfigs(config("1234", ""), config("1234", PUNCH_JSON));
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("批量:一次查全后按项目分别命中,未命中的项目不进结果")
void batchByProject() {
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L, 4321L));
assertEquals(2, live.size());
assertEquals(PUNCH_JSON, live.get(1234L));
assertEquals(OTHER_PUNCH_JSON, live.get(5678L));
assertTrue(live.containsKey(1234L));
}
@Test
@DisplayName("resolve:生效配置优先,取不到才用快照")
void resolvePrefersLive() {
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolve(PUNCH_JSON, NO_PUNCH_JSON));
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolve(null, NO_PUNCH_JSON));
assertNull(LiveProcessConfigSupport.resolve(null, null));
}
@Test
@DisplayName("resolveForPunch:生效配置没有可打卡节点时才回退快照,两边都没有节点仍用生效配置")
void resolveForPunchFallsBackToSnapshot() {
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(PUNCH_JSON, NO_PUNCH_JSON),
"生效配置有可打卡节点:用生效配置");
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, PUNCH_JSON),
"生效配置没有可打卡节点、快照有:回退快照");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, NO_PUNCH_JSON),
"两边都没有可打卡节点:用生效配置");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(null, NO_PUNCH_JSON),
"项目没有生效配置:用快照");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, null),
"快照为空:用生效配置");
}
@Test
@DisplayName("生效配置只查一次:批量取法与单项目取法都不逐条回查项目")
void configQueryIsInvokedOncePerCall() {
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L));
verify(processConfigService, times(1)).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
verify(processConfigService, never()).getById(any());
}
}