diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index 337e2fd0..03f85510 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -54,6 +54,12 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)") private Boolean hasException; + @Schema(description = "该运单是否存在未了结的整单异常;整单异常不归属任何节点,故每个节点都带此标记") + private Boolean hasWaybillException; + + @Schema(description = "与该节点相关的未了结异常ID,按上报时间升序;相关 = 归属该节点 ∪ 整单异常") + private List relatedExceptionIds = new ArrayList<>(); + @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空") @JsonSerialize(using = ToStringSerializer.class) private Long blockedExceptionId; 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..e1735c5d 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,19 @@ public interface IDriverWaybillService { */ Map findPunchNode(Long waybillId, String nodeCode); + /** + * 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。 + *

+ * 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。 + */ + boolean isNodePunched(Long waybillId, String nodeCode); + + /** + * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + *

+ * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 + */ + void assertCurrentDriverOwnsWaybill(Long waybillId); + } 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..a2c9236c 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 @@ -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 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.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(未达终态:已完成 / 已撤销)。 *

@@ -843,7 +856,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return exceptionDisposalMapper.selectList(Wrappers.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 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()) 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..e8842af0 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 @@ -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); } + /** + * 该运单的指定节点是否已有未了结(未达终态)的异常。 + *

+ * 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, + * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 + * 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。 + */ + private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { + Long count = baseMapper.selectCount(Wrappers.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 buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.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()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index bdc9c0fb..7b69302b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -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 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 { } /** - * 异常处置是否已了结(已完成 / 已撤销)。 + * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 *

- * 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。 + * 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。 */ - 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(); + } + + /** + * 该异常是否与某节点相关。 + *

+ * 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。 + * 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。 + */ + public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) { + return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode); } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java new file mode 100644 index 00000000..a3669df1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -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; + +/** + * 司机端节点卡片上的异常标签与「相关异常」列表。 + *

+ * 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。 + * 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的; + * 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。 + */ +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 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 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 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> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture()); + for (LambdaQueryWrapper 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 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; + } + +} 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/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java new file mode 100644 index 00000000..fa84fb62 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -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; + +/** + * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 + *

+ * 口径只有一条:司机档案里自述绑定的车牌要与运单的主车或挂车车牌匹配。 + * 它不是「运单上的承运司机」——那两件事互不推导;也不是「谁提交了这份运单材料」。 + * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + */ +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> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).getOne(captor.capture()); + LambdaQueryWrapper 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 = 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; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java new file mode 100644 index 00000000..9d0dd4a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -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; + +/** + * 异常处置列表按运单过滤。 + *

+ * 过滤必须落在运单主键上:运单号是模糊匹配,{@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(1, 10)); + } + + @Test + @DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配") + void waybillIdFilterIsExact() { + ExceptionDisposalVO query = new ExceptionDisposalVO(); + query.setWaybillId(WAYBILL_ID); + + LambdaQueryWrapper 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 wrapper = capturedQuery(new ExceptionDisposalVO()); + + assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql()); + } + + // ------------------------------------------------------------ 夹具 + + private LambdaQueryWrapper capturedQuery(ExceptionDisposalVO query) { + IPage page = service.selectPage(new Page<>(1, 10), query); + assertTrue(page.getRecords().isEmpty()); + + ArgumentCaptor> 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); + } + +} 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..36e5c086 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -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; + +/** + * 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。 + *

+ * 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事: + * 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定, + * 但解除条件互不相同,所以断言分开下。 + */ +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 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 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 capturedUnresolvedQuery() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectCount(captor.capture()); + return captor.getValue(); + } + + /** 上述查询的参数值,集合型参数摊平成元素 */ + private List capturedUnresolvedParams() { + LambdaQueryWrapper query = capturedUnresolvedQuery(); + // MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值 + query.getTargetSql(); + Collection values = query.getParamNameValuePairs().values(); + List 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 = 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 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 223a17ec..3584712d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, * 所以断言下在它们身上就覆盖了两条链路。 + *

+ * 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。 */ 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)); + } + }