From 7e84b670b633fe0924c5a71f33a5f98c92ea3a83 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 12:16:22 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E8=8A=82=E7=82=B9=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=B8=8A=E6=8A=A5=E5=A2=9E=E5=8A=A0=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E4=B8=8E=E9=87=8D=E5=A4=8D=E4=B8=8A=E6=8A=A5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IDriverWaybillService.java | 7 + .../impl/DriverWaybillServiceImpl.java | 14 + .../impl/ExceptionDisposalServiceImpl.java | 30 +- .../impl/DriverWaybillNodePunchTest.java | 111 ++++++ .../impl/ExceptionDisposalReportGateTest.java | 328 ++++++++++++++++++ 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index 30d7b5b8..a41d714a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -108,4 +108,11 @@ public interface IDriverWaybillService { */ Map findPunchNode(Long waybillId, String nodeCode); + /** + * 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。 + *

+ * 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。 + */ + boolean isNodePunched(Long waybillId, String nodeCode); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 40b11591..1f0e57af 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -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.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) + .anyMatch(key::equalsIgnoreCase); + } + /** * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 65249bc3..1d686126 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -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); } + /** + * 该运单的指定节点是否已有未了结(未达终态)的异常。 + *

+ * 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, + * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 + * 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。 + */ + private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { + List existing = baseMapper.selectList(Wrappers.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; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java new file mode 100644 index 00000000..a045e054 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -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; + +/** + * 节点是否已打卡的判定。 + *

+ * 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除, + * 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。 + * 节点编码按忽略大小写比对——流水里的写法与过程配置的 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; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java new file mode 100644 index 00000000..658612a0 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -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; + +/** + * 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。 + *

+ * 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事: + * 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定, + * 但解除条件互不相同,所以断言分开下。 + */ +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> 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 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 = mockStatic(AuthUtil.class); + MockedStatic 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 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 node(String key, String name, String type) { + Map 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); + } + +}