🔀 合并异常上报门禁后端实现

This commit is contained in:
2026-10-07 12:28:05 +08:00
11 changed files with 1181 additions and 1 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;
@@ -108,4 +108,19 @@ public interface IDriverWaybillService {
*/
Map<String, Object> findPunchNode(Long waybillId, String nodeCode);
/**
* 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。
* <p>
* 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。
*/
boolean isNodePunched(Long waybillId, String nodeCode);
/**
* 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。
* <p>
* 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案,
* 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。
*/
void assertCurrentDriverOwnsWaybill(Long waybillId);
}
@@ -805,6 +805,27 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
}
@Override
public boolean isNodePunched(Long waybillId, String nodeCode) {
if (waybillId == null || Func.isEmpty(nodeCode)) {
return false;
}
// 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来
String key = nodeCode.trim();
return nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId))
.stream()
.map(punch -> Func.toStr(punch.getNodeCode(), "").trim())
.anyMatch(key::equalsIgnoreCase);
}
@Override
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
// 与司机打卡、司机接单共用同一套归属判定,不另立第二套规则
Driver driver = requireCurrentDriver();
loadDriverWaybill(waybillId, currentBoundPlates(driver));
}
/**
* 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。
* <p>
@@ -1082,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())
@@ -138,6 +138,11 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("请关联运单后再上报");
}
// 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。
// 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。
// 整单异常同样受这条约束:没有节点不等于没有上报人。
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
@@ -147,7 +152,19 @@ public class ExceptionDisposalServiceImpl
if (nodeCfg == null) {
throw new ServiceException("异常节点不存在");
}
disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg));
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
// 上报门禁之一:已打卡说明这个环节已经过去,事后才发现的问题走整单异常。
// 在途例外——它可重复打卡、没有完成含义,打卡与否不说明环节是否已经过去。
if (!WaybillProcessSupport.isTransitNodePublic(nodeCfg)
&& driverWaybillService.isNodePunched(disposal.getWaybillId(), nodeKey)) {
throw new ServiceException("该节点已打卡,不能上报异常");
}
// 上报门禁之二:同一节点同时至多一条未了结异常,处理中也占位。
// 约束是同时性的:异常了结后该节点重新可以上报。
if (hasUnresolvedNodeException(disposal.getWaybillId(), nodeKey)) {
throw new ServiceException("该节点已有未处理异常,处理完成后才能再次上报");
}
disposal.setNodeCode(nodeKey);
disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg));
}
@@ -157,6 +174,22 @@ public class ExceptionDisposalServiceImpl
return toVO(disposal);
}
/**
* 该运单的指定节点是否已有未了结(未达终态)的异常。
* <p>
* 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算,
* 「处理中」同样占位——它只是有人跟进过,问题可能仍在。
* 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。
*/
private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) {
List<ExceptionDisposal> existing = baseMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0));
return existing.stream().anyMatch(item ->
nodeCode.equalsIgnoreCase(Func.toStr(item.getNodeCode(), "").trim())
&& !WaybillProcessSupport.isSettledDisposal(item.getDisposalStatus()));
}
/** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */
private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) {
Waybill waybill = null;
@@ -352,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,111 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
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.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
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.util.List;
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.when;
/**
* 节点是否已打卡的判定。
* <p>
* 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除,
* 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。
* 节点编码按忽略大小写比对——流水里的写法与过程配置的 key 不保证一致。
*/
class DriverWaybillNodePunchTest {
private static final Long WAYBILL_ID = 5201L;
private WaybillNodePunchMapper nodePunchMapper;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
}
@BeforeEach
void setUp() {
nodePunchMapper = mock(WaybillNodePunchMapper.class);
service = new DriverWaybillServiceImpl(
mock(IWaybillService.class),
mock(IDriverService.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
nodePunchMapper,
mock(ExceptionDisposalMapper.class));
}
@Test
@DisplayName("没有该节点的打卡流水就是未打卡")
void withoutPunchRecordNodeIsNotPunched() {
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
assertFalse(service.isNodePunched(WAYBILL_ID, "load"));
}
@Test
@DisplayName("有该节点的打卡流水即已打卡")
void punchRecordMakesNodePunched() {
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("load")));
assertTrue(service.isNodePunched(WAYBILL_ID, "load"));
}
@Test
@DisplayName("别的节点的打卡流水不算本节点已打卡")
void otherNodePunchDoesNotCount() {
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("arrive_scene")));
assertFalse(service.isNodePunched(WAYBILL_ID, "load"));
}
@Test
@DisplayName("流水里的节点编码大小写与查询不一致时仍算同一节点")
void nodeCodeComparisonIgnoresCase() {
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("LOAD")));
assertTrue(service.isNodePunched(WAYBILL_ID, "load"));
}
@Test
@DisplayName("运单或节点缺失时判为未打卡,不抛异常")
void missingArgumentsMeanNotPunched() {
assertFalse(service.isNodePunched(null, "load"));
assertFalse(service.isNodePunched(WAYBILL_ID, " "));
}
private WaybillNodePunch punch(String nodeCode) {
WaybillNodePunch punch = new WaybillNodePunch();
punch.setId(6201L);
punch.setWaybillId(WAYBILL_ID);
punch.setNodeCode(nodeCode);
return punch;
}
}
@@ -0,0 +1,172 @@
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.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.Waybill;
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.util.Collection;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机对运单的归属校验(异常上报等按运单操作的前置条件)。
* <p>
* 口径只有一条:司机档案里自述绑定的车牌要与运单的主车或挂车车牌匹配。
* 它不是「运单上的承运司机」——那两件事互不推导;也不是「谁提交了这份运单材料」。
* 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。
*/
class DriverWaybillOwnershipTest {
private static final Long WAYBILL_ID = 5301L;
private static final Long USER_ID = 7001L;
private static final String PLATE = "桂AAW3255";
private IDriverService driverService;
private IWaybillService waybillService;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() {
driverService = mock(IDriverService.class);
waybillService = mock(IWaybillService.class);
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
mock(WaybillNodePunchMapper.class),
mock(ExceptionDisposalMapper.class));
}
@Test
@DisplayName("登录账号没有司机档案时被拒:管理端与调度员不提供代报")
void withoutDriverProfileIsRejected() {
when(driverService.getOne(any())).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("未找到当前登录司机档案", exception.getMessage());
verify(waybillService, never()).getOne(any());
}
@Test
@DisplayName("司机档案里没有绑定车辆时被拒")
void withoutBoundPlateIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("当前司机未绑定车辆", exception.getMessage());
}
@Test
@DisplayName("运单不在该司机绑定的车牌下时被拒")
void waybillOfAnotherPlateIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(PLATE));
when(waybillService.getOne(any())).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("运单不存在或无权操作", exception.getMessage());
}
@Test
@DisplayName("本车运单放行,且查询确实按司机绑定的车牌收敛")
void ownWaybillPasses() {
when(driverService.getOne(any())).thenReturn(driver(PLATE));
when(waybillService.getOne(any())).thenReturn(waybill());
assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID));
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(waybillService).getOne(captor.capture());
LambdaQueryWrapper<Waybill> wrapper = captor.getValue();
assertTrue(wrapper.getTargetSql().contains("vehicle_no"),
"归属查询必须落在运单车牌上:" + wrapper.getTargetSql());
assertTrue(wrapper.getParamNameValuePairs().values().stream()
.anyMatch(value -> PLATE.equals(value)
|| (value instanceof Collection<?> values && values.contains(PLATE))),
"归属查询必须带上司机绑定的车牌:" + wrapper.getParamNameValuePairs());
}
@Test
@DisplayName("运单ID为空时被拒")
void withoutWaybillIdIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(PLATE));
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(null));
assertEquals("运单ID不能为空", exception.getMessage());
}
// ------------------------------------------------------------ 夹具
private void runOwnershipCheck(Long waybillId) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
service.assertCurrentDriverOwnsWaybill(waybillId);
}
}
private Driver driver(String drivingVehicle) {
Driver driver = new Driver();
driver.setId(9001L);
driver.setUserId(USER_ID);
driver.setDriverName("韦司机");
driver.setDrivingVehicle(drivingVehicle);
return driver;
}
private Waybill waybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610070001");
waybill.setVehicleNo(PLATE);
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);
}
}
@@ -0,0 +1,355 @@
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.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IWaybillService;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.HashMap;
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.assertThrows;
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.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。
* <p>
* 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事:
* 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定,
* 但解除条件互不相同,所以断言分开下。
*/
class ExceptionDisposalReportGateTest {
private static final Long WAYBILL_ID = 5101L;
private static final Long USER_ID = 7001L;
private ExceptionDisposalMapper disposalMapper;
private IWaybillService waybillService;
private IDriverWaybillService driverWaybillService;
private ExceptionDisposalServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
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);
waybillService = mock(IWaybillService.class);
driverWaybillService = mock(IDriverWaybillService.class);
service = new ExceptionDisposalServiceImpl(
mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService);
inject("baseMapper", disposalMapper);
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
when(disposalMapper.selectList(any())).thenReturn(List.of());
when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1);
}
// ------------------------------------------------------------ 已打卡
@Test
@DisplayName("已打卡的普通节点不能再上报异常")
void punchedNodeRejectsReport() {
stubNode("load", "装货", "cargo");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true);
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已打卡,不能上报异常", exception.getMessage());
}
@Test
@DisplayName("在途已打卡仍可上报:它可重复打卡、没有完成含义,打卡与否不说明环节已经过去")
void punchedTransitNodeStillAcceptsReport() {
stubNode("transit", "在途", "transit");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "transit")).thenReturn(true);
submitOk("transit");
assertEquals("transit", savedDisposal().getNodeCode());
}
@Test
@DisplayName("未打卡的普通节点照常可上报")
void unpunchedNodeAcceptsReport() {
stubNode("load", "装货", "cargo");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
submitOk("load");
assertEquals("load", savedDisposal().getNodeCode());
assertEquals("装货", savedDisposal().getNodeName());
}
// ------------------------------------------------------------ 已有未了结异常
@Test
@DisplayName("节点已有待处理异常时拒绝再报")
void pendingNodeExceptionRejectsReport() {
stubNode("load", "装货", "cargo");
stubUnresolvedNodeException("load", "pending");
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
}
@Test
@DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁")
void processingNodeExceptionRejectsReport() {
stubNode("load", "装货", "cargo");
stubUnresolvedNodeException("load", "processing");
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
}
@Test
@DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报")
void settledNodeExceptionAllowsReportAgain() {
stubNode("load", "装货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(
nodeException("load", "completed"),
nodeException("load", "revoked")));
submitOk("load");
assertEquals("load", savedDisposal().getNodeCode());
}
@Test
@DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上")
void otherNodesExceptionDoesNotBlockReport() {
stubNode("unload", "卸货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("load", "pending")));
submitOk("unload");
assertEquals("unload", savedDisposal().getNodeCode());
}
@Test
@DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点")
void nodeCodeComparisonIgnoresCase() {
stubNode("load", "装货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("LOAD", "pending")));
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
}
@Test
@DisplayName("门禁拿过程配置里的节点 key 去比对,而不是请求体里那串写法")
void gateUsesNodeKeyResolvedFromProcessConfig() {
// 请求体写的是大写,过程配置里的 key 是小写;打卡流水按配置的 key 落库
when(driverWaybillService.findPunchNode(WAYBILL_ID, "LOAD")).thenReturn(node("load", "装货", "cargo"));
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true);
ServiceException exception = submitExpectRejected("LOAD");
assertEquals("该节点已打卡,不能上报异常", exception.getMessage());
}
@Test
@DisplayName("未了结异常的查询按运单收敛,并排除已删除的行")
void unresolvedQueryIsScopedToWaybillAndSkipsDeletedRows() {
stubNode("load", "装货", "cargo");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
submitOk("load");
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(disposalMapper).selectList(captor.capture());
String sql = captor.getValue().getTargetSql();
assertTrue(sql.contains("waybill_id"),
"未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql);
assertTrue(sql.contains("is_deleted"),
"已删除的异常不算未了结:" + sql);
}
// ------------------------------------------------------------ 整单异常不受门禁
@Test
@DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报")
void waybillLevelReportIsNotGated() {
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(null, "pending")));
submitOk(null);
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
verify(disposalMapper).insert(captor.capture());
assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个");
verify(driverWaybillService, never()).findPunchNode(any(), any());
verify(driverWaybillService, never()).isNodePunched(any(), any());
}
@Test
@DisplayName("上报人取登录态,请求体里的上报人一律被忽略")
void reporterComesFromLoginState() {
stubNode("load", "装货", "cargo");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
ExceptionDisposal request = request("load");
request.setReporterId(999999L);
request.setReporterName("冒充的人");
runSubmit(request);
assertEquals(USER_ID, savedDisposal().getReporterId());
assertEquals("韦司机", savedDisposal().getReporterName());
}
// ------------------------------------------------------------ 上报人归属
@Test
@DisplayName("上报人不属于该运单时在落库前就被拒")
void reportIsRejectedWhenReporterDoesNotOwnWaybill() {
stubNode("load", "装货", "cargo");
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
doThrow(new ServiceException("运单不存在或无权操作"))
.when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID);
ServiceException exception = submitExpectRejected("load");
assertEquals("运单不存在或无权操作", exception.getMessage());
}
@Test
@DisplayName("整单异常同样校验上报人:没有节点不等于没有上报人")
void waybillLevelReportAlsoChecksReporterOwnership() {
doThrow(new ServiceException("未找到当前登录司机档案"))
.when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID);
ServiceException exception = submitExpectRejected(null);
assertEquals("未找到当前登录司机档案", exception.getMessage());
}
// ------------------------------------------------------------ 夹具
private void stubNode(String key, String name, String type) {
when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type));
when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false);
}
private void stubUnresolvedNodeException(String nodeCode, String status) {
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(nodeCode, status)));
}
private ServiceException submitExpectRejected(String nodeCode) {
ServiceException exception = assertThrows(ServiceException.class, () -> runSubmit(request(nodeCode)));
// 门禁在落库之前:被拒时库里不能多一行
verify(disposalMapper, never()).insert(any(ExceptionDisposal.class));
return exception;
}
private void submitOk(String nodeCode) {
runSubmit(request(nodeCode));
}
private void runSubmit(ExceptionDisposal request) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
MockedStatic<UserCache> userCache = mockStatic(UserCache.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
userCache.when(() -> UserCache.getUserRealName(USER_ID)).thenReturn("韦司机");
service.submitReport(request);
}
}
private ExceptionDisposal savedDisposal() {
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
verify(disposalMapper).insert(captor.capture());
return captor.getValue();
}
private ExceptionDisposal request(String nodeCode) {
ExceptionDisposal request = new ExceptionDisposal();
request.setWaybillId(WAYBILL_ID);
request.setExceptionType("cargo_damage");
request.setReportDescription("装货时发现外包装破损");
request.setNodeCode(nodeCode);
return request;
}
private ExceptionDisposal nodeException(String nodeCode, String status) {
ExceptionDisposal disposal = new ExceptionDisposal();
disposal.setId(6101L);
disposal.setWaybillId(WAYBILL_ID);
disposal.setNodeCode(nodeCode);
disposal.setDisposalStatus(status);
disposal.setIsDeleted(0);
disposal.setReportTime(LocalDateTime.of(2026, 10, 6, 9, 30));
return disposal;
}
private Map<String, Object> node(String key, String name, String type) {
Map<String, Object> cfg = new HashMap<>();
cfg.put("key", key);
cfg.put("name", name);
cfg.put("type", type);
cfg.put("enabled", true);
cfg.put("punch", true);
return cfg;
}
private Waybill waybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610070001");
waybill.setVehicleNo("桂AAW3255");
waybill.setIsDeleted(0);
return waybill;
}
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());
}
}