diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java
index 96f5647e..67588e27 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java
@@ -51,6 +51,12 @@ public class ExceptionDisposal extends TenantEntity {
@Schema(description = "运单号")
private String waybillNo;
+ @Schema(description = "过程节点 key(为空表示未指明节点)")
+ private String nodeCode;
+
+ @Schema(description = "过程节点名称(上报时复制,此后不随过程配置变化)")
+ private String nodeName;
+
@Schema(description = "配载单号/总单号")
private String loadingOrMasterNo;
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 f7e88823..5806b545 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
@@ -4,6 +4,8 @@
*/
package org.springblade.transport.pojo.vo;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -34,9 +36,31 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "是否已打卡(在途=今日已打)")
private Boolean done;
- @Schema(description = "当前是否可打卡(顺序门禁 + 在途频次时段)")
+ @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)")
private Boolean actionable;
+ @Schema(description = "是否被未了结的异常阻断:该节点及其后的节点不可打卡")
+ private Boolean blocked;
+
+ @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)")
+ private Boolean blockedByOrder;
+
+ @Schema(description = "顺序门禁拦住的原因,如「请先完成装货打卡」")
+ private String orderLockReason;
+
+ @Schema(description = "是否允许展开:顺序门禁不允许,异常门禁允许(可查看回填内容)")
+ private Boolean expandable;
+
+ @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)")
+ private Boolean hasException;
+
+ @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空")
+ @JsonSerialize(using = ToStringSerializer.class)
+ private Long blockedExceptionId;
+
+ @Schema(description = "不可打卡的原因,供司机端展示")
+ private String lockReason;
+
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
private Boolean visible;
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 be434257..30d7b5b8 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
@@ -31,6 +31,8 @@ import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
+import java.util.Map;
+
/**
* 司机端运单服务(小程序首页 / 列表)
*/
@@ -99,4 +101,11 @@ public interface IDriverWaybillService {
*/
boolean complete(Long id);
+ /**
+ * 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。
+ *
+ * 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。
+ */
+ Map findPunchNode(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 d38413bd..0ee9d7ab 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
@@ -33,11 +33,13 @@ import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.Func;
+import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.entity.Driver;
+import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
@@ -72,11 +74,13 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -110,6 +114,15 @@ 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 = "该运单存在未处理异常,处理完成后才能打卡";
+
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IProcessConfigService processConfigService;
@@ -117,6 +130,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final CertificationGate certificationGate;
private final WaybillEnroutePunchMapper enroutePunchMapper;
private final WaybillNodePunchMapper nodePunchMapper;
+ private final ExceptionDisposalMapper exceptionDisposalMapper;
@Override
public DriverWaybillCardVO currentTask() {
@@ -252,15 +266,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
waybill = normalizeAcceptStatus(waybill);
+ String processJson = resolveProcessJson(waybill);
Date lastPunchAt = findLastPunchTime(waybill.getId());
WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin(
- resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
+ processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
if (!decision.punchEnabled()) {
throw new ServiceException("该运单未启用在途打卡");
}
if (decision.doneToday()) {
throw new ServiceException("今日已完成在途打卡,不可重复打卡");
}
+ // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束
+ assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson));
// 不做频次/时段门禁;定位按前端是否传参落库
Date now = new Date();
@@ -297,7 +314,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
waybill = normalizeAcceptStatus(waybill);
- Map nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
+ String processJson = resolveProcessJson(waybill);
+ Map nodeCfg = findPunchNodeConfig(processJson, nodeCode);
if (nodeCfg == null) {
throw new ServiceException("该节点未启用打卡或不存在");
}
@@ -308,6 +326,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (existed != null) {
throw new ServiceException("该节点已打卡,不可重复打卡");
}
+ assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg));
+ assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg);
Date now = new Date();
WaybillNodePunch punch = new WaybillNodePunch();
@@ -441,6 +461,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
&& !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) {
throw new ServiceException("当前运单状态不允许完成");
}
+ // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁
+ assertNoUnresolvedException(waybill.getId());
// 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单
return waybillService.completeWithoutDeptCheck(waybill.getId());
}
@@ -764,11 +786,136 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
.anyMatch(projectId::equals);
}
+ /**
+ * 该运单未了结(未达终态)的异常处置所属节点 key。
+ *
+ * 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。
+ */
+ @Override
+ public Map findPunchNode(Long waybillId, String nodeCode) {
+ if (waybillId == null || Func.isEmpty(nodeCode)) {
+ return null;
+ }
+ Waybill waybill = waybillService.getById(waybillId);
+ if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
+ return null;
+ }
+ return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
+ }
+
+ /**
+ * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。
+ *
+ * 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致:
+ * 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。
+ *
+ * 不含空串——未指明节点的异常没有对应节点,无从挂标签;
+ * 它是否阻断全单由 {@link #blockingException} 另行判定。
+ */
+ private Set unresolvedExceptionNodeCodes(Long waybillId) {
+ if (waybillId == null) {
+ return Collections.emptySet();
+ }
+ return unresolvedExceptions(waybillId).stream()
+ .map(item -> Func.toStr(item.getNodeCode(), "").trim())
+ .filter(Func::isNotEmpty)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * 该运单未处理完成异常的节点 key 列表,**保留空串**(表示存在未指明节点的异常)。
+ *
+ * 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。
+ */
+ private List unresolvedExceptionNodeCodeList(Long waybillId) {
+ return unresolvedExceptions(waybillId).stream()
+ .map(item -> Func.toStr(item.getNodeCode(), "").trim())
+ .collect(Collectors.toList());
+ }
+
+ /** 该运单未了结(未达终态)的异常处置,按上报时间升序 */
+ private List unresolvedExceptions(Long waybillId) {
+ if (waybillId == null) {
+ return Collections.emptyList();
+ }
+ return exceptionDisposalMapper.selectList(Wrappers.lambdaQuery()
+ .eq(ExceptionDisposal::getWaybillId, waybillId)
+ .eq(ExceptionDisposal::getIsDeleted, 0)
+ .notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES)
+ .orderByAsc(ExceptionDisposal::getReportTime)
+ .orderByAsc(ExceptionDisposal::getId));
+ }
+
+ /**
+ * 取「正在阻断该运单打卡」的那条异常处置。
+ *
+ * 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先),
+ * 与 {@link WaybillProcessSupport#blockedNodeKeys} 的判定口径一致。
+ */
+ private ExceptionDisposal blockingException(Waybill waybill, String processJson) {
+ String earliestCode = WaybillProcessSupport.earliestBlockingNodeCode(
+ processJson, unresolvedExceptionNodeCodeList(waybill.getId()));
+ if (earliestCode == null) {
+ return null;
+ }
+ return unresolvedExceptions(waybill.getId()).stream()
+ .filter(item -> earliestCode.equals(Func.toStr(item.getNodeCode(), "").trim()))
+ .findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * 断言指定节点未被异常门禁阻断。
+ *
+ * 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。
+ */
+ private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) {
+ Set blocked = WaybillProcessSupport.blockedNodeKeys(
+ processJson, unresolvedExceptionNodeCodeList(waybillId));
+ if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) {
+ throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡");
+ }
+ }
+
+ /**
+ * 断言该节点的前置打卡节点都已完成(顺序门禁)。
+ *
+ * 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错,
+ * 司机才知道该等一等(顺序)还是该找人处理(异常)。
+ */
+ private void assertPrerequisitePunched(String processJson, Long waybillId, Map nodeCfg) {
+ // ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来
+ Set punched = nodePunchMapper.selectList(Wrappers.lambdaQuery()
+ .eq(WaybillNodePunch::getWaybillId, waybillId))
+ .stream()
+ .map(punch -> Func.toStr(punch.getNodeCode(), "").trim())
+ .filter(Func::isNotEmpty)
+ .collect(Collectors.toSet());
+ Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
+ processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
+ if (!unfinished.isEmpty()) {
+ throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
+ }
+ }
+
+ /**
+ * 断言该运单没有未了结的异常。
+ *
+ * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
+ * 「完成运单」绕过打卡门禁。
+ */
+ private void assertNoUnresolvedException(Long waybillId) {
+ if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
+ throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
+ }
+ }
+
/**
* 组装过程配置 punch=是 的打卡节点列表。
*
- * 未打卡节点均可打,不做顺序/时段门禁;
- * 默认展开:第一个未完成的可见节点。
+ * 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关);
+ * 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。
+ * 默认展开:第一个未完成、且未被异常阻断的可见节点。
* 非在途节点「已打卡」以节点打卡表为准。
* 节点字段(定位/货量/凭证)取自动态过程配置。
*/
@@ -855,8 +1002,67 @@ 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);
+ 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());
+ vo.setLockReason(LOCK_REASON_EXCEPTION);
+ }
+ }
+
+ // 顺序门禁:前面还有打卡节点没打,本节点就不能打(在途豁免)。
+ // 与异常门禁独立:两者可同时成立,也可只成立一个。
+ Set punchedCodes = new HashSet<>();
for (DriverPunchNodeVO vo : nodes) {
- if (!Boolean.TRUE.equals(vo.getDone())) {
+ if (Boolean.TRUE.equals(vo.getDone())) {
+ punchedCodes.add(vo.getKey());
+ }
+ }
+ for (DriverPunchNodeVO vo : nodes) {
+ if (Boolean.TRUE.equals(vo.getDone())) {
+ continue;
+ }
+ Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
+ processJson, punchedCodes, vo.getKey());
+ if (unfinished.isEmpty()) {
+ continue;
+ }
+ vo.setBlockedByOrder(true);
+ vo.setActionable(false);
+ vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
+ }
+
+ // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用);
+ // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。
+ for (DriverPunchNodeVO vo : nodes) {
+ boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder());
+ vo.setExpandable(!blockedByOrder);
+ }
+
+ // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。
+ // 被牵连阻断的后续节点没有异常上报,不挂标签——否则司机以为每个节点都出了事。
+ Set exceptionNodeCodes = unresolvedExceptionNodeCodes(waybill.getId());
+ if (!exceptionNodeCodes.isEmpty()) {
+ for (DriverPunchNodeVO vo : nodes) {
+ if (exceptionNodeCodes.contains(vo.getKey())) {
+ vo.setHasException(true);
+ }
+ }
+ }
+
+ for (DriverPunchNodeVO vo : nodes) {
+ if (!Boolean.TRUE.equals(vo.getDone())
+ && !Boolean.TRUE.equals(vo.getBlocked())
+ && !Boolean.TRUE.equals(vo.getBlockedByOrder())) {
vo.setDefaultExpanded(true);
break;
}
@@ -864,6 +1070,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return nodes;
}
+ /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */
+ private String prerequisiteNames(String processJson, Set nodeKeys) {
+ List names = new ArrayList<>();
+ for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
+ String key = WaybillProcessSupport.nodeKey(node);
+ if (nodeKeys.contains(key)) {
+ names.add(WaybillProcessSupport.nodeName(node));
+ }
+ }
+ return names.isEmpty() ? "" : String.join("、", names);
+ }
+
private Map findPunchNodeConfig(String processJson, String nodeCode) {
List