🐛 修复在途打卡绕过异常门禁:未了结异常落在在途之后时仍可打卡
在途的异常阻断此前按过程配置里节点的位置区间计算:异常节点及其后的节点不可打卡。 异常报在「卸货」这类排在在途之后的节点时,区间只有卸货、签收,在途被放行, 等于车越接近目的地越能报在途;而异常报在「装货」(在途之前)反而会锁住在途。 改为运单级判定:该运单只要存在未了结异常,在途就不可打卡,与异常归属哪个节点无关。 普通打卡节点仍按区间(异常所在节点及其后)阻断。判定集中在 WaybillProcessSupport.blockedNodeKeys,卡片禁用态与 /enroute/submit 的强制校验共用它。 异常落在非打卡节点上(接单/回单、或已从过程配置删掉的节点)时没有可跳转的具体异常, 故 blockedExceptionId 可能为空,卡片只显示不可打卡原因。
This commit is contained in:
+1
-1
@@ -39,7 +39,7 @@ public class DriverPunchNodeVO implements Serializable {
|
||||
@Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)")
|
||||
private Boolean actionable;
|
||||
|
||||
@Schema(description = "是否被未了结的异常阻断:该节点及其后的节点不可打卡")
|
||||
@Schema(description = "是否被未了结的异常阻断:异常所在节点及其后的节点不可打卡;在途只要有未了结异常即不可打卡")
|
||||
private Boolean blocked;
|
||||
|
||||
@Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)")
|
||||
|
||||
+13
-8
@@ -869,7 +869,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
/**
|
||||
* 断言指定节点未被异常门禁阻断。
|
||||
* <p>
|
||||
* 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。
|
||||
* 普通节点:阻断范围是未了结异常所在节点及其后的节点。
|
||||
* 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。
|
||||
* 前端禁用只是体验层,这里才是强制点。
|
||||
*/
|
||||
private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(
|
||||
@@ -931,8 +933,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
/**
|
||||
* 组装过程配置 punch=是 的打卡节点列表。
|
||||
* <p>
|
||||
* 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关);
|
||||
* 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。
|
||||
* 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级——
|
||||
* 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。
|
||||
* 默认展开:第一个未完成、且未被异常阻断的可见节点。
|
||||
* 非在途节点「已打卡」以节点打卡表为准。
|
||||
* 节点字段(定位/货量/凭证)取自动态过程配置。
|
||||
@@ -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<String> blockedKeys = WaybillProcessSupport.blockedNodeKeysFrom(processJson, blockerNodeCode);
|
||||
Set<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -405,9 +405,14 @@ public final class WaybillProcessSupport {
|
||||
/**
|
||||
* 计算被异常门禁阻断的过程节点 key。
|
||||
* <p>
|
||||
* 阻断范围:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。
|
||||
* 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。
|
||||
* 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。
|
||||
* <p>
|
||||
* **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡,
|
||||
* 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成,
|
||||
* 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途,
|
||||
* 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。
|
||||
* <p>
|
||||
* 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。
|
||||
* 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。
|
||||
*
|
||||
@@ -415,8 +420,16 @@ public final class WaybillProcessSupport {
|
||||
* @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常
|
||||
*/
|
||||
public static Set<String> blockedNodeKeys(String processJson, Collection<String> unresolvedNodeCodes) {
|
||||
String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes);
|
||||
return blockedNodeKeysFrom(processJson, earliest);
|
||||
Set<String> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+132
@@ -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;
|
||||
|
||||
/**
|
||||
* 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。
|
||||
* <p>
|
||||
* 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验,
|
||||
* 两边都调 {@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<String> 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<String> 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<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload"));
|
||||
|
||||
assertTrue(blocked.contains("transit"),
|
||||
"运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形");
|
||||
assertEquals(keys("transit", "unload", "sign"), blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途:异常落在它之前的「装货」时也阻断")
|
||||
void transitIsBlockedByExceptionOnEarlierNode() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load"));
|
||||
|
||||
assertTrue(blocked.contains("transit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未指明节点的异常(存量数据)阻断全部打卡节点,含在途")
|
||||
void exceptionWithoutNodeBlocksEverything() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList(""));
|
||||
|
||||
assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断")
|
||||
void earliestExceptionDecidesTheRange() {
|
||||
Set<String> 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<String> punched = keys("arrive_scene", "load", "dispatch");
|
||||
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload");
|
||||
|
||||
assertEquals(keys("arrival"), unfinished,
|
||||
"「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集")
|
||||
void transitIsExemptFromOrderGate() {
|
||||
assertTrue(
|
||||
WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(),
|
||||
"在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user