🐛 修复在途打卡绕过异常门禁:未了结异常落在在途之后时仍可打卡

在途的异常阻断此前按过程配置里节点的位置区间计算:异常节点及其后的节点不可打卡。
异常报在「卸货」这类排在在途之后的节点时,区间只有卸货、签收,在途被放行,
等于车越接近目的地越能报在途;而异常报在「装货」(在途之前)反而会锁住在途。

改为运单级判定:该运单只要存在未了结异常,在途就不可打卡,与异常归属哪个节点无关。
普通打卡节点仍按区间(异常所在节点及其后)阻断。判定集中在
WaybillProcessSupport.blockedNodeKeys,卡片禁用态与 /enroute/submit 的强制校验共用它。

异常落在非打卡节点上(接单/回单、或已从过程配置删掉的节点)时没有可跳转的具体异常,
故 blockedExceptionId 可能为空,卡片只显示不可打卡原因。
This commit is contained in:
2026-10-06 03:17:25 +08:00
parent ffed7d328c
commit 19ddc445da
4 changed files with 162 additions and 12 deletions
@@ -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);
}
}
@@ -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;
}
/**