✨ 节点异常上报增加打卡与重复上报校验

This commit is contained in:
2026-10-07 12:16:22 +08:00
parent bf37c47856
commit 7e84b670b6
5 changed files with 489 additions and 1 deletions
@@ -108,4 +108,11 @@ public interface IDriverWaybillService {
*/
Map<String, Object> findPunchNode(Long waybillId, String nodeCode);
/**
* 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。
* <p>
* 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。
*/
boolean isNodePunched(Long waybillId, String nodeCode);
}
@@ -805,6 +805,20 @@ 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);
}
/**
* 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。
* <p>
@@ -147,7 +147,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 +169,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;
@@ -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,328 @@
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.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());
}
// ------------------------------------------------------------ 夹具
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);
}
}