🔀 合并异常上报门禁实现
This commit is contained in:
+6
@@ -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;
|
||||
|
||||
+15
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+37
-7
@@ -114,12 +114,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd");
|
||||
private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
/**
|
||||
* 异常处置终态:已完成 / 已撤销。只有终态解除打卡门禁——「处理中」只表示有人
|
||||
* 跟进过,问题可能仍在,不放行。
|
||||
*/
|
||||
private static final List<String> SETTLED_EXCEPTION_STATUSES = Arrays.asList("completed", "revoked");
|
||||
|
||||
/** 被异常阻断时的不可打卡原因(司机端展示) */
|
||||
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
|
||||
|
||||
@@ -805,6 +799,25 @@ 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 比不出来
|
||||
return nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId))
|
||||
.stream()
|
||||
.anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
|
||||
// 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定
|
||||
Driver driver = requireCurrentDriver();
|
||||
loadDriverWaybill(waybillId, currentBoundPlates(driver));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。
|
||||
* <p>
|
||||
@@ -843,7 +856,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return exceptionDisposalMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
|
||||
.orderByAsc(ExceptionDisposal::getReportTime)
|
||||
.orderByAsc(ExceptionDisposal::getId));
|
||||
}
|
||||
@@ -1082,6 +1095,23 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
}
|
||||
|
||||
// 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空),
|
||||
// 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。
|
||||
// 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点
|
||||
// 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。
|
||||
// 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64,
|
||||
// 前端直收数字会丢精度。
|
||||
List<ExceptionDisposal> unresolved = unresolvedExceptions(waybill.getId());
|
||||
boolean hasWaybillException = unresolved.stream()
|
||||
.anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode()));
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
vo.setHasWaybillException(hasWaybillException);
|
||||
vo.setRelatedExceptionIds(unresolved.stream()
|
||||
.filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode()))
|
||||
.map(item -> String.valueOf(item.getId()))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (!Boolean.TRUE.equals(vo.getDone())
|
||||
&& !Boolean.TRUE.equals(vo.getBlocked())
|
||||
|
||||
+38
-1
@@ -54,6 +54,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -138,6 +139,11 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("请关联运单后再上报");
|
||||
}
|
||||
|
||||
// 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。
|
||||
// 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
@@ -147,7 +153,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 +175,22 @@ public class ExceptionDisposalServiceImpl
|
||||
return toVO(disposal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单的指定节点是否已有未了结(未达终态)的异常。
|
||||
* <p>
|
||||
* 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算,
|
||||
* 「处理中」同样占位——它只是有人跟进过,问题可能仍在。
|
||||
* 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。
|
||||
*/
|
||||
private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) {
|
||||
Long count = baseMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
|
||||
.apply("LOWER(TRIM(node_code)) = {0}", nodeCode.trim().toLowerCase(Locale.ROOT)));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */
|
||||
private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) {
|
||||
Waybill waybill = null;
|
||||
@@ -352,6 +386,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())
|
||||
|
||||
+25
-5
@@ -58,6 +58,8 @@ public final class WaybillProcessSupport {
|
||||
/** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */
|
||||
private static final String DISPOSAL_COMPLETED = "completed";
|
||||
private static final String DISPOSAL_REVOKED = "revoked";
|
||||
/** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */
|
||||
public static final List<String> SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED);
|
||||
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
|
||||
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
@@ -381,13 +383,31 @@ public final class WaybillProcessSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常处置是否已了结(已完成 / 已撤销)。
|
||||
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
|
||||
* <p>
|
||||
* 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。
|
||||
* 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。
|
||||
*/
|
||||
public static boolean isSettledDisposal(String disposalStatus) {
|
||||
String status = stringVal(disposalStatus);
|
||||
return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status);
|
||||
public static boolean sameNodeCode(String left, String right) {
|
||||
String leftCode = stringVal(left);
|
||||
String rightCode = stringVal(right);
|
||||
return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。
|
||||
*/
|
||||
public static boolean isWaybillLevelException(String exceptionNodeCode) {
|
||||
return stringVal(exceptionNodeCode).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 该异常是否与某节点相关。
|
||||
* <p>
|
||||
* 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。
|
||||
* 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。
|
||||
*/
|
||||
public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) {
|
||||
return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+232
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+111
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+172
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+115
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
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.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
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.selectCount(any())).thenReturn(0L);
|
||||
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");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁")
|
||||
void processingNodeExceptionRejectsReport() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
List<Object> params = capturedUnresolvedParams();
|
||||
assertTrue(params.contains("completed") && params.contains("revoked"),
|
||||
"未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报")
|
||||
void settledNodeExceptionAllowsReportAgain() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(0L);
|
||||
|
||||
submitOk("load");
|
||||
|
||||
assertEquals("load", savedDisposal().getNodeCode());
|
||||
assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")),
|
||||
"只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上")
|
||||
void otherNodesExceptionDoesNotBlockReport() {
|
||||
stubNode("unload", "卸货", "cargo");
|
||||
stubUnresolvedCount(0L);
|
||||
|
||||
submitOk("unload");
|
||||
|
||||
assertEquals("unload", savedDisposal().getNodeCode());
|
||||
assertTrue(capturedUnresolvedParams().contains("unload"),
|
||||
"节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点")
|
||||
void nodeCodeComparisonIgnoresCase() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(TRIM(node_code)) ="),
|
||||
"忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql());
|
||||
assertTrue(capturedUnresolvedParams().contains("load"),
|
||||
"参数取节点 key 的小写形式:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
@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");
|
||||
|
||||
String sql = capturedUnresolvedQuery().getTargetSql();
|
||||
|
||||
assertTrue(sql.contains("waybill_id"),
|
||||
"未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql);
|
||||
assertTrue(sql.contains("is_deleted"),
|
||||
"已删除的异常不算未了结:" + sql);
|
||||
assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID),
|
||||
"运单条件取本次上报的运单:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 整单异常不受门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报")
|
||||
void waybillLevelReportIsNotGated() {
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
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());
|
||||
verify(disposalMapper, never()).selectCount(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 stubUnresolvedCount(long count) {
|
||||
when(disposalMapper.selectCount(any())).thenReturn(count);
|
||||
}
|
||||
|
||||
/** 上报门禁查未了结异常的那条 count 查询 */
|
||||
private LambdaQueryWrapper<ExceptionDisposal> capturedUnresolvedQuery() {
|
||||
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(disposalMapper).selectCount(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/** 上述查询的参数值,集合型参数摊平成元素 */
|
||||
private List<Object> capturedUnresolvedParams() {
|
||||
LambdaQueryWrapper<ExceptionDisposal> query = capturedUnresolvedQuery();
|
||||
// MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值
|
||||
query.getTargetSql();
|
||||
Collection<Object> values = query.getParamNameValuePairs().values();
|
||||
List<Object> params = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value instanceof Collection<?> nested) {
|
||||
params.addAll(nested);
|
||||
} else {
|
||||
params.add(value);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与
|
||||
* {@link WaybillProcessSupport#unfinishedPrerequisiteKeys},
|
||||
* 所以断言下在它们身上就覆盖了两条链路。
|
||||
* <p>
|
||||
* 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。
|
||||
*/
|
||||
class WaybillProcessSupportTest {
|
||||
|
||||
@@ -129,4 +131,45 @@ class WaybillProcessSupportTest {
|
||||
"在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 异常标签与相关异常
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点")
|
||||
void relatesToNodeCoversWaybillLevelForEveryNode() {
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", null),
|
||||
"整单异常不归属任何节点,对每个节点都算相关");
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", ""));
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", "load"));
|
||||
assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"),
|
||||
"装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码忽略大小写,两侧空白不影响判定")
|
||||
void relatesToNodeIgnoresCaseAndBlank() {
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD"));
|
||||
assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD "));
|
||||
assertFalse(WaybillProcessSupport.relatesToNode("load", "unload"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常的判定只看节点编码是否为空")
|
||||
void waybillLevelExceptionFlag() {
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(null));
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(""));
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(" "));
|
||||
assertFalse(WaybillProcessSupport.isWaybillLevelException("load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等")
|
||||
void sameNodeCodeIgnoresCaseAndBlank() {
|
||||
assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD"));
|
||||
assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load"));
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload"));
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点");
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等");
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode(null, null));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user