✨ 节点卡片下发整单异常标记与相关异常列表,列表支持按运单精确过滤

This commit is contained in:
2026-10-07 12:23:02 +08:00
parent 75cd448e2d
commit f9654fbe30
7 changed files with 473 additions and 0 deletions
@@ -54,6 +54,12 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)")
private Boolean hasException;
@Schema(description = "该运单是否存在未了结的整单异常;整单异常不归属任何节点,故每个节点都带此标记")
private Boolean hasWaybillException;
@Schema(description = "与该节点相关的未了结异常ID,按上报时间升序;相关 = 归属该节点 ∪ 整单异常")
private List<String> relatedExceptionIds = new ArrayList<>();
@Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空")
@JsonSerialize(using = ToStringSerializer.class)
private Long blockedExceptionId;
@@ -1103,6 +1103,24 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
}
// 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空),
// 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。
// 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点
// 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。
List<ExceptionDisposal> unresolved = unresolvedExceptions(waybill.getId());
List<String> unresolvedCodes = unresolved.stream()
.map(item -> Func.toStr(item.getNodeCode(), "").trim())
.collect(Collectors.toList());
List<Long> unresolvedIds = unresolved.stream()
.map(ExceptionDisposal::getId)
.collect(Collectors.toList());
boolean hasWaybillException = WaybillProcessSupport.hasWaybillLevelException(unresolvedCodes);
for (DriverPunchNodeVO vo : nodes) {
vo.setHasWaybillException(hasWaybillException);
vo.setRelatedExceptionIds(
WaybillProcessSupport.relatedExceptionIds(vo.getKey(), unresolvedCodes, unresolvedIds));
}
for (DriverPunchNodeVO vo : nodes) {
if (!Boolean.TRUE.equals(vo.getDone())
&& !Boolean.TRUE.equals(vo.getBlocked())
@@ -385,6 +385,9 @@ public class ExceptionDisposalServiceImpl
private LambdaQueryWrapper<ExceptionDisposal> buildQuery(ExceptionDisposalVO query) {
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getIsDeleted, 0)
// 按运单精确过滤,供司机端「本运单的异常」列表使用。
// 不用运单号:它是模糊匹配,YD001 会命中 YD0011。
.eq(query.getWaybillId() != null, ExceptionDisposal::getWaybillId, query.getWaybillId())
.like(Func.isNotEmpty(query.getWaybillNo()), ExceptionDisposal::getWaybillNo, query.getWaybillNo())
.like(Func.isNotEmpty(query.getVehicleNo()), ExceptionDisposal::getVehicleNo, query.getVehicleNo())
.like(Func.isNotEmpty(query.getReporterName()), ExceptionDisposal::getReporterName, query.getReporterName())
@@ -390,6 +390,45 @@ public final class WaybillProcessSupport {
return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status);
}
/**
* 是否存在未了结的整单异常。
* <p>
* 整单异常不归属任何过程节点,它的节点编码为空;它作用于整张运单,
* 所以该运单每个可打卡节点都要挂上「整单异常」标记。
*/
public static boolean hasWaybillLevelException(List<String> unresolvedNodeCodes) {
if (unresolvedNodeCodes == null) {
return false;
}
return unresolvedNodeCodes.stream().anyMatch(code -> stringVal(code).isEmpty());
}
/**
* 与某节点相关的未了结异常 id,一律以字符串返回。
* <p>
* 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。
* 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。
* <p>
* 顺序保持入参顺序,调用方按上报时间升序取出未了结异常即可。
* id 转成字符串是因为主键是 int64,前端直收数字会丢精度。
*/
public static List<String> relatedExceptionIds(String nodeKey, List<String> unresolvedNodeCodes,
List<Long> unresolvedIds) {
if (unresolvedNodeCodes == null || unresolvedIds == null) {
return Collections.emptyList();
}
String key = stringVal(nodeKey);
List<String> related = new ArrayList<>();
int size = Math.min(unresolvedNodeCodes.size(), unresolvedIds.size());
for (int i = 0; i < size; i++) {
String code = stringVal(unresolvedNodeCodes.get(i));
if (code.isEmpty() || code.equalsIgnoreCase(key)) {
related.add(String.valueOf(unresolvedIds.get(i)));
}
}
return related;
}
/**
* 过程配置里的在途节点 key;配置中无在途节点时返回空串。
*/
@@ -0,0 +1,232 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.ArgumentCaptor;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机端节点卡片上的异常标签与「相关异常」列表。
* <p>
* 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。
* 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的;
* 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。
*/
class DriverPunchNodeCardTest {
private static final Long WAYBILL_ID = 5501L;
/** 接单不参与打卡,故不出现在节点卡片里 */
private static final String PROCESS_JSON = """
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
""";
private IWaybillService waybillService;
private WaybillNodePunchMapper nodePunchMapper;
private WaybillEnroutePunchMapper enroutePunchMapper;
private ExceptionDisposalMapper exceptionDisposalMapper;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
exceptionDisposalMapper);
Waybill waybill = waybill();
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch("load")));
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
}
@Test
@DisplayName("整单异常挂到每个可打卡节点,含已打卡的与在途的")
void waybillLevelExceptionIsTaggedOnEveryNode() {
stubUnresolved(nodeException("load", 11L), waybillException(12L));
Map<String, DriverPunchNodeVO> nodes = punchNodes();
assertEquals(3, nodes.size(), "接单不是打卡节点,不该出现在卡片里");
assertTrue(Boolean.TRUE.equals(nodes.get("load").getDone()), "装货这条流水已打卡");
for (DriverPunchNodeVO node : nodes.values()) {
assertTrue(Boolean.TRUE.equals(node.getHasWaybillException()),
"整单异常作用于整张运单," + node.getKey() + " 也该挂标签");
assertTrue(node.getRelatedExceptionIds().contains("12"),
node.getKey() + " 的相关异常里应当有整单异常");
}
}
@Test
@DisplayName("节点自己的异常只进它自己的相关列表,不牵连后续节点")
void nodeExceptionOnlyBelongsToItsOwnNode() {
stubUnresolved(nodeException("load", 11L));
Map<String, DriverPunchNodeVO> nodes = punchNodes();
assertTrue(Boolean.TRUE.equals(nodes.get("load").getHasException()));
assertEquals(List.of("11"), nodes.get("load").getRelatedExceptionIds());
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasException()),
"签收只是被装货的异常牵连阻断,不挂「异常」标签");
assertTrue(nodes.get("sign").getRelatedExceptionIds().isEmpty());
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasWaybillException()));
}
@Test
@DisplayName("两种异常同时存在时,该节点的相关列表有两条且按上报时间升序")
void bothKindsAreListedInReportOrder() {
stubUnresolved(nodeException("load", 11L), waybillException(12L));
DriverPunchNodeVO load = punchNodes().get("load");
assertEquals(List.of("11", "12"), load.getRelatedExceptionIds());
}
@Test
@DisplayName("异常全部了结后两个字段都回到「无」")
void withoutUnresolvedExceptionsNoTagAndNoRelatedIds() {
Map<String, DriverPunchNodeVO> nodes = punchNodes();
for (DriverPunchNodeVO node : nodes.values()) {
assertFalse(Boolean.TRUE.equals(node.getHasWaybillException()));
assertTrue(node.getRelatedExceptionIds().isEmpty());
}
}
@Test
@DisplayName("未了结异常按上报时间升序取出,卡片顺序才有意义")
void unresolvedExceptionsAreReadInReportOrder() {
stubUnresolved(nodeException("load", 11L));
punchNodes();
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture());
for (LambdaQueryWrapper<ExceptionDisposal> wrapper : captor.getAllValues()) {
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("report_time"), "未了结异常要按上报时间取:" + sql);
assertTrue(sql.contains("disposal_status"), "未了结的判据要落在处置状态上:" + sql);
}
}
@Test
@DisplayName("相关异常 id 序列化成字符串,前端拿到大整数不会丢精度")
void relatedExceptionIdsSerializeAsStrings() {
stubUnresolved(nodeException("load", 9007199254740993L));
String json = JsonUtil.toJson(punchNodes().get("load"));
assertTrue(json.contains("\"9007199254740993\""), json);
}
// ------------------------------------------------------------ 夹具
private void stubUnresolved(ExceptionDisposal... disposals) {
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(disposals));
}
private Map<String, DriverPunchNodeVO> punchNodes() {
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
}
private ExceptionDisposal nodeException(String nodeCode, Long id) {
return exception(id, nodeCode, LocalDateTime.of(2026, 10, 6, 9, 30));
}
private ExceptionDisposal waybillException(Long id) {
return exception(id, null, LocalDateTime.of(2026, 10, 6, 10, 0));
}
private ExceptionDisposal exception(Long id, String nodeCode, LocalDateTime reportTime) {
ExceptionDisposal disposal = new ExceptionDisposal();
disposal.setId(id);
disposal.setWaybillId(WAYBILL_ID);
disposal.setNodeCode(nodeCode);
disposal.setDisposalStatus("pending");
disposal.setIsDeleted(0);
disposal.setReportTime(reportTime);
return disposal;
}
private WaybillNodePunch nodePunch(String nodeCode) {
WaybillNodePunch punch = new WaybillNodePunch();
punch.setId(6501L);
punch.setWaybillId(WAYBILL_ID);
punch.setNodeCode(nodeCode);
punch.setPunchTime(new Date());
return punch;
}
private Waybill waybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610070001");
waybill.setProcessJson(PROCESS_JSON);
waybill.setBusinessStatus("running");
waybill.setIsDeleted(0);
return waybill;
}
}
@@ -0,0 +1,115 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.ArgumentCaptor;
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IWaybillService;
import java.lang.reflect.Field;
import java.util.Collection;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.verify;
import static org.mockito.Mockito.when;
/**
* 异常处置列表按运单过滤。
* <p>
* 过滤必须落在运单主键上:运单号是模糊匹配,{@code YD001} 会命中 {@code YD0011},
* 从某个节点点进来的「本运单异常」会多出别人的记录。
*/
class ExceptionDisposalListQueryTest {
private static final Long WAYBILL_ID = 5401L;
private ExceptionDisposalMapper disposalMapper;
private ExceptionDisposalServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() throws Exception {
disposalMapper = mock(ExceptionDisposalMapper.class);
service = new ExceptionDisposalServiceImpl(
mock(ExceptionDisposalFollowRecordMapper.class),
mock(IWaybillService.class),
mock(IDriverWaybillService.class));
inject("baseMapper", disposalMapper);
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
}
@Test
@DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配")
void waybillIdFilterIsExact() {
ExceptionDisposalVO query = new ExceptionDisposalVO();
query.setWaybillId(WAYBILL_ID);
LambdaQueryWrapper<ExceptionDisposal> wrapper = capturedQuery(query);
assertTrue(wrapper.getTargetSql().contains("waybill_id ="),
"按运单过滤要用等值:" + wrapper.getTargetSql());
assertTrue(wrapper.getParamNameValuePairs().values().stream()
.anyMatch(value -> WAYBILL_ID.equals(value)
|| (value instanceof Collection<?> values && values.contains(WAYBILL_ID))),
"过滤条件要带上运单ID:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getTargetSql().contains("waybill_no"),
"运单号是 like,会把前缀相同的运单一起捞回来:" + wrapper.getTargetSql());
}
@Test
@DisplayName("不传运单ID时不加运单条件,列表原有入口行为不变")
void withoutWaybillIdNoWaybillCondition() {
LambdaQueryWrapper<ExceptionDisposal> wrapper = capturedQuery(new ExceptionDisposalVO());
assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql());
}
// ------------------------------------------------------------ 夹具
private LambdaQueryWrapper<ExceptionDisposal> capturedQuery(ExceptionDisposalVO query) {
IPage<ExceptionDisposalVO> page = service.selectPage(new Page<>(1, 10), query);
assertTrue(page.getRecords().isEmpty());
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(disposalMapper).selectPage(any(), captor.capture());
return captor.getValue();
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}
@@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与
* {@link WaybillProcessSupport#unfinishedPrerequisiteKeys},
* 所以断言下在它们身上就覆盖了两条链路。
* <p>
* 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。
*/
class WaybillProcessSupportTest {
@@ -129,4 +131,62 @@ class WaybillProcessSupportTest {
"在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了");
}
// ------------------------------------------------------------ 异常标签与相关异常
@Test
@DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点")
void relatedExceptionsCoverWaybillLevelForEveryNode() {
List<String> nodeCodes = Arrays.asList("load", "");
List<Long> ids = Arrays.asList(11L, 12L);
assertEquals(Arrays.asList("11", "12"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids));
assertEquals(Collections.singletonList("12"), WaybillProcessSupport.relatedExceptionIds("sign", nodeCodes, ids),
"装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事");
}
@Test
@DisplayName("相关异常 id 保持入参顺序,即上报时间升序")
void relatedExceptionIdsKeepReportOrder() {
List<String> nodeCodes = Arrays.asList("", "load");
List<Long> ids = Arrays.asList(21L, 22L);
assertEquals(Arrays.asList("21", "22"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids));
}
@Test
@DisplayName("节点编码忽略大小写")
void relatedExceptionIdsIgnoreCase() {
assertEquals(Collections.singletonList("31"),
WaybillProcessSupport.relatedExceptionIds(
"load", Collections.singletonList("LOAD"), Collections.singletonList(31L)));
}
@Test
@DisplayName("id 一律下发字符串:主键是 int64,前端直收数字会丢精度")
void relatedExceptionIdsAreStrings() {
List<String> ids = WaybillProcessSupport.relatedExceptionIds(
"load", Collections.singletonList("load"), Collections.singletonList(9007199254740993L));
assertEquals(Collections.singletonList("9007199254740993"), ids);
}
@Test
@DisplayName("整单异常的判定只看有没有不归属节点的异常")
void waybillLevelExceptionFlag() {
assertTrue(WaybillProcessSupport.hasWaybillLevelException(Arrays.asList("load", "")));
assertTrue(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList(null)));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList("load")));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList()));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(null));
}
@Test
@DisplayName("异常全部了结后,整单标记与相关 id 都回到「无」")
void withoutUnresolvedExceptionsNothingIsTagged() {
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList()));
assertTrue(WaybillProcessSupport
.relatedExceptionIds("load", Collections.emptyList(), Collections.emptyList())
.isEmpty());
}
}