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 5806b545..337e2fd0 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 @@ -39,7 +39,7 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)") private Boolean actionable; - @Schema(description = "是否被未了结的异常阻断:该节点及其后的节点不可打卡") + @Schema(description = "是否被未了结的异常阻断:异常所在节点及其后的节点不可打卡;在途只要有未了结异常即不可打卡") private Boolean blocked; @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)") 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 dec19dae..40b11591 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 @@ -869,7 +869,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 断言指定节点未被异常门禁阻断。 *

- * 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。 + * 普通节点:阻断范围是未了结异常所在节点及其后的节点。 + * 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。 + * 前端禁用只是体验层,这里才是强制点。 */ private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) { Set blocked = WaybillProcessSupport.blockedNodeKeys( @@ -931,8 +933,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 组装过程配置 punch=是 的打卡节点列表。 *

- * 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关); - * 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。 + * 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级—— + * 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。 * 默认展开:第一个未完成、且未被异常阻断的可见节点。 * 非在途节点「已打卡」以节点打卡表为准。 * 节点字段(定位/货量/凭证)取自动态过程配置。 @@ -1020,19 +1022,22 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodes.add(vo); } - // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡。 + // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡; + // 在途另按运单级——该运单只要还有未了结异常,在途就不可打卡。 // 已打卡节点不受影响,仍可展开查看回填内容。 ExceptionDisposal blocker = blockingException(waybill, processJson); - if (blocker != null) { - String blockerNodeCode = Func.toStr(blocker.getNodeCode(), "").trim(); - Set blockedKeys = WaybillProcessSupport.blockedNodeKeysFrom(processJson, blockerNodeCode); + Set blockedKeys = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (!blockedKeys.isEmpty()) { for (DriverPunchNodeVO vo : nodes) { if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { continue; } vo.setBlocked(true); vo.setActionable(false); - vo.setBlockedExceptionId(blocker.getId()); + if (blocker != null) { + vo.setBlockedExceptionId(blocker.getId()); + } vo.setLockReason(LOCK_REASON_EXCEPTION); } } 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 c04c46cd..8e0291e8 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 @@ -405,9 +405,14 @@ public final class WaybillProcessSupport { /** * 计算被异常门禁阻断的过程节点 key。 *

- * 阻断范围:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 + * 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 * 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。 *

+ * **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡, + * 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成, + * 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途, + * 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。 + *

* 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 * @@ -415,8 +420,16 @@ public final class WaybillProcessSupport { * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 */ public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { - String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes); - return blockedNodeKeysFrom(processJson, earliest); + Set blocked = new LinkedHashSet<>( + blockedNodeKeysFrom(processJson, earliestBlockingNodeCode(processJson, unresolvedNodeCodes))); + if (unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return blocked; + } + String transitKey = transitNodeKey(processJson); + if (Func.isNotEmpty(transitKey)) { + blocked.add(transitKey); + } + return blocked; } /** 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 new file mode 100644 index 00000000..2aa846c2 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -0,0 +1,132 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。 + *

+ * 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验, + * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 + * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, + * 所以断言下在它们身上就覆盖了两条链路。 + */ +class WaybillProcessSupportTest { + + /** 与线上「石灰石包头运输项目」实际下发的过程配置同序:在途排在发货之后、到货之前 */ + private static final String TRANSPORT_JSON = """ + [ + {"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"arrive_scene","name":"到场","type":"punch","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","type":"cargo","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true} + ] + """; + + private static Set keys(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + // ------------------------------------------------------------ 异常门禁 + + @Test + @DisplayName("无未了结异常时谁都不阻断") + void withoutUnresolvedExceptionNothingIsBlocked() { + assertTrue(WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Collections.emptyList()).isEmpty()); + } + + @Test + @DisplayName("普通节点:异常所在节点及其后节点阻断,之前的不受影响") + void unresolvedExceptionBlocksNodeAndItsSuccessors() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("arrival")); + + assertTrue(blocked.containsAll(keys("arrival", "unload", "sign")), "异常所在节点及其后节点都要锁"); + assertFalse(blocked.contains("arrive_scene"), "异常之前的节点不该被锁"); + assertFalse(blocked.contains("load")); + assertFalse(blocked.contains("dispatch")); + // 在途是运单级的例外:异常落在它之后也锁,见 transitIsBlockedByExceptionOnLaterNode + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("在途:异常落在它之后的「卸货」时同样阻断——门禁是运单级,不看异常在哪个节点") + void transitIsBlockedByExceptionOnLaterNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload")); + + assertTrue(blocked.contains("transit"), + "运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形"); + assertEquals(keys("transit", "unload", "sign"), blocked); + } + + @Test + @DisplayName("在途:异常落在它之前的「装货」时也阻断") + void transitIsBlockedByExceptionOnEarlierNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load")); + + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("未指明节点的异常(存量数据)阻断全部打卡节点,含在途") + void exceptionWithoutNodeBlocksEverything() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList("")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断") + void earliestExceptionDecidesTheRange() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload", "arrive_scene")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("配置里没有在途节点时不凭空造一个 key") + void withoutTransitNodeNothingIsInvented() { + String noTransit = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + assertEquals(keys("load", "unload"), WaybillProcessSupport.blockedNodeKeys(noTransit, List.of("load"))); + } + + // ------------------------------------------------------------ 顺序门禁 + + @Test + @DisplayName("顺序门禁:前面还有打卡节点没打时返回它们,在途不作为前置") + void unfinishedPrerequisitesIgnoreTransit() { + Set punched = keys("arrive_scene", "load", "dispatch"); + + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload"); + + assertEquals(keys("arrival"), unfinished, + "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); + } + + @Test + @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") + void transitIsExemptFromOrderGate() { + assertTrue( + WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(), + "在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了"); + } + +}