✨ 异常处置支持归属过程节点并新增打卡门禁

异常上报强制选择过程节点,节点归属写入 node_code / node_name;节点存在未了结
异常时,该节点及其后节点不可打卡(异常门禁),被拦节点仍可展开查看回填内容。

另新增顺序门禁:前置打卡节点未完成时后续节点不可打卡且不可展开;在途节点不受
顺序门禁约束,但仍受异常门禁约束。

配套:
- 过程配置「上传货量为是、货量类型为空」的脏配置订正为不采集货量
- 网关补充 /blade-resource/** 路由(图片上传,小程序打卡与异常上报照片均经此)
This commit is contained in:
2026-10-05 15:10:24 +08:00
parent 7296a5af21
commit 7c30ead839
10 changed files with 517 additions and 8 deletions
@@ -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;
@@ -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;
@@ -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。
* <p>
* 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。
*/
Map<String, Object> findPunchNode(Long waybillId, String nodeCode);
}
@@ -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<String> 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<String, Object> nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
String processJson = resolveProcessJson(waybill);
Map<String, Object> 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。
* <p>
* 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。
*/
@Override
public Map<String, Object> 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(未达终态:已完成 / 已撤销)。
* <p>
* 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致:
* 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。
* <p>
* 不含空串——未指明节点的异常没有对应节点,无从挂标签;
* 它是否阻断全单由 {@link #blockingException} 另行判定。
*/
private Set<String> 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 列表,**保留空串**(表示存在未指明节点的异常)。
* <p>
* 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。
*/
private List<String> unresolvedExceptionNodeCodeList(Long waybillId) {
return unresolvedExceptions(waybillId).stream()
.map(item -> Func.toStr(item.getNodeCode(), "").trim())
.collect(Collectors.toList());
}
/** 该运单未了结(未达终态)的异常处置,按上报时间升序 */
private List<ExceptionDisposal> unresolvedExceptions(Long waybillId) {
if (waybillId == null) {
return Collections.emptyList();
}
return exceptionDisposalMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0)
.notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES)
.orderByAsc(ExceptionDisposal::getReportTime)
.orderByAsc(ExceptionDisposal::getId));
}
/**
* 取「正在阻断该运单打卡」的那条异常处置。
* <p>
* 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先),
* 与 {@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);
}
/**
* 断言指定节点未被异常门禁阻断。
* <p>
* 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。
*/
private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) {
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(
processJson, unresolvedExceptionNodeCodeList(waybillId));
if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) {
throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡");
}
}
/**
* 断言该节点的前置打卡节点都已完成(顺序门禁)。
* <p>
* 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错,
* 司机才知道该等一等(顺序)还是该找人处理(异常)。
*/
private void assertPrerequisitePunched(String processJson, Long waybillId, Map<String, Object> nodeCfg) {
// ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来
Set<String> punched = nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId))
.stream()
.map(punch -> Func.toStr(punch.getNodeCode(), "").trim())
.filter(Func::isNotEmpty)
.collect(Collectors.toSet());
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
if (!unfinished.isEmpty()) {
throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
}
}
/**
* 断言该运单没有未了结的异常。
* <p>
* 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
* 「完成运单」绕过打卡门禁。
*/
private void assertNoUnresolvedException(Long waybillId) {
if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
}
}
/**
* 组装过程配置 punch=是 的打卡节点列表。
* <p>
* 未打卡节点均可打,不做顺序/时段门禁;
* 默认展开:第一个未完成的可见节点。
* 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关);
* 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。
* 默认展开:第一个未完成、且未被异常阻断的可见节点。
* 非在途节点「已打卡」以节点打卡表为准。
* 节点字段(定位/货量/凭证)取自动态过程配置。
*/
@@ -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<String> 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<String> 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<String> 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<String> 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<String> nodeKeys) {
List<String> names = new ArrayList<>();
for (Map<String, Object> 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<String, Object> findPunchNodeConfig(String processJson, String nodeCode) {
List<Map<String, Object>> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson);
for (Map<String, Object> cfg : punchConfigs) {
@@ -41,8 +41,10 @@ import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -75,11 +77,14 @@ public class ExceptionDisposalServiceImpl
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
private final IWaybillService waybillService;
private final IDriverWaybillService driverWaybillService;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService) {
IWaybillService waybillService,
IDriverWaybillService driverWaybillService) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
this.driverWaybillService = driverWaybillService;
}
@Override
@@ -133,6 +138,19 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("请关联运单后再上报");
}
// 异常必须归属到某个过程节点:没有节点的异常无从定位阻断区间,只能整单停摆。
// 节点名取自过程配置而非请求体:上报页传什么不影响入库,避免落进一个该节点并不叫的名字。
String nodeCode = Func.toStr(request.getNodeCode(), "").trim();
if (Func.isBlank(nodeCode)) {
throw new ServiceException("请选择异常节点");
}
Map<String, Object> nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode);
if (nodeCfg == null) {
throw new ServiceException("异常节点不存在");
}
disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg));
disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg));
if (!save(disposal)) {
throw new ServiceException("异常上报失败");
}
@@ -32,10 +32,14 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 运单过程配置解析(对齐 web 端 waybill-manage / process-config)
@@ -51,6 +55,9 @@ public final class WaybillProcessSupport {
private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept";
private static final String NODE_TRANSIT = "transit";
private static final String NODE_TRANSIT_NAME = "在途";
/** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */
private static final String DISPOSAL_COMPLETED = "completed";
private static final String DISPOSAL_REVOKED = "revoked";
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
@@ -285,8 +292,15 @@ public final class WaybillProcessSupport {
return isTruthy(node.get("location"));
}
/**
* 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。
* <p>
* 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。
* 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关,
* 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。
*/
public static boolean nodeNeedCargo(Map<String, Object> node) {
return isTruthy(node.get("uploadCargo"));
return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty();
}
public static boolean nodeNeedVoucher(Map<String, Object> node) {
@@ -327,6 +341,146 @@ public final class WaybillProcessSupport {
return Func.isEmpty(name) ? nodeKey(node) : name;
}
/**
* 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。
* <p>
* 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置
* (否则第一个打卡节点永远等不到前置打卡记录)。
* <p>
* **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成;
* 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。
*
* @param processJson 过程配置
* @param punchedNodeKeys 该运单已打卡的节点 key
* @param nodeCode 待判定的节点 key
*/
public static Set<String> unfinishedPrerequisiteKeys(
String processJson, Collection<String> punchedNodeKeys, String nodeCode) {
if (Func.isEmpty(nodeCode)) {
return Collections.emptySet();
}
Set<String> punched = punchedNodeKeys == null
? Collections.emptySet() : new HashSet<>(punchedNodeKeys);
Set<String> unfinished = new LinkedHashSet<>();
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
// 在途不参与顺序:既不作为别人的前置,也不被别人拦
if (isTransitNode(node)) {
continue;
}
String key = nodeKey(node);
if (nodeCode.equals(key)) {
// 走到自己:只关心它前面那些
return unfinished;
}
if (!punched.contains(key)) {
unfinished.add(key);
}
}
// 节点不在本配置里:不做顺序判定,交给节点存在性校验去报错
return Collections.emptySet();
}
/**
* 异常处置是否已了结(已完成 / 已撤销)。
* <p>
* 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。
*/
public static boolean isSettledDisposal(String disposalStatus) {
String status = stringVal(disposalStatus);
return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status);
}
/**
* 过程配置里的在途节点 key;配置中无在途节点时返回空串。
*/
public static String transitNodeKey(String processJson) {
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
if (isTransitNode(node)) {
return nodeKey(node);
}
}
return "";
}
/**
* 计算被异常门禁阻断的过程节点 key。
* <p>
* 阻断范围:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。
* 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。
* <p>
* 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。
* 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。
*
* @param processJson 过程配置
* @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常
*/
public static Set<String> blockedNodeKeys(String processJson, Collection<String> unresolvedNodeCodes) {
String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes);
return blockedNodeKeysFrom(processJson, earliest);
}
/**
* 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。
* <p>
* 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。
*/
public static String earliestBlockingNodeCode(String processJson, Collection<String> unresolvedNodeCodes) {
List<Map<String, Object>> punchNodes = listDriverPunchNodes(processJson);
if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) {
return null;
}
Set<String> reported = new HashSet<>();
for (String code : unresolvedNodeCodes) {
String key = stringVal(code);
// 未指明节点的异常锁全单,优先级高于任何具体节点
if (Func.isEmpty(key)) {
return "";
}
reported.add(key);
}
for (Map<String, Object> node : punchNodes) {
String key = nodeKey(node);
if (reported.contains(key)) {
return key;
}
}
return null;
}
/**
* 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。
*/
public static Set<String> blockedNodeKeysFrom(String processJson, String fromNodeCode) {
List<Map<String, Object>> punchNodes = listDriverPunchNodes(processJson);
Set<String> blocked = new LinkedHashSet<>();
if (punchNodes.isEmpty() || fromNodeCode == null) {
return blocked;
}
// 空串代表「未指明节点」,阻断全部可打卡节点。
// 新上报已强制要求节点,此分支只为**存量数据**保留——去掉它,那些没有节点的
// 未了结异常会突然不再阻断任何节点,等于悄悄放开了打卡。
if (Func.isEmpty(fromNodeCode)) {
for (Map<String, Object> node : punchNodes) {
blocked.add(nodeKey(node));
}
return blocked;
}
int start = -1;
for (int i = 0; i < punchNodes.size(); i++) {
if (fromNodeCode.equals(nodeKey(punchNodes.get(i)))) {
start = i;
break;
}
}
if (start < 0) {
return blocked;
}
for (int i = start; i < punchNodes.size(); i++) {
blocked.add(nodeKey(punchNodes.get(i)));
}
return blocked;
}
@SuppressWarnings("unchecked")
public static List<Map<String, Object>> parseProcessNodes(String processJson) {
if (Func.isEmpty(processJson)) {
+22
View File
@@ -49,6 +49,28 @@
],
"uri": "lb://blade-system"
},
{
"id": "resource-route",
"order": 0,
"predicates": [
{
"name": "Path",
"args": {
"pattern": "/blade-resource/**"
}
}
],
"filters": [
{
"name": "RewritePath",
"args": {
"regexp": "/blade-resource/(?<segment>.*)",
"replacement": "/$\\{segment}"
}
}
],
"uri": "http://127.0.0.1:8010"
},
{
"id": "example-route",
"order": 0,
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` (
`is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
`waybill_id` bigint(20) DEFAULT NULL COMMENT '运单ID',
`waybill_no` varchar(100) DEFAULT NULL COMMENT '运单号',
`node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)',
`node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)',
`loading_or_master_no` varchar(100) DEFAULT NULL COMMENT '配载单号/总单号',
`project_id` bigint(20) DEFAULT NULL COMMENT '项目ID',
`project_name` varchar(100) DEFAULT NULL COMMENT '项目',
@@ -0,0 +1,37 @@
-- 异常处置增加过程节点归属
-- 日期:2026-10-05
-- 节点归属用于打卡门禁:某节点有未了结异常时,该节点及其后的节点不可打卡。
-- node_code 为空表示未指明节点(全局入口上报),此类异常阻断该运单所有尚未打卡的节点。
-- node_name 在上报时复制,此后不随过程配置变化,与车牌、项目、承运商等展示字段同一口径。
DELIMITER $$
DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_node_code_20261005`$$
CREATE PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`()
BEGIN
DECLARE db_name varchar(128);
SET db_name = DATABASE();
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = db_name
AND table_name = 'blade_exception_disposal'
AND column_name = 'node_code') THEN
ALTER TABLE `blade_exception_disposal`
ADD COLUMN `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)'
AFTER `waybill_no`;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = db_name
AND table_name = 'blade_exception_disposal'
AND column_name = 'node_name') THEN
ALTER TABLE `blade_exception_disposal`
ADD COLUMN `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)'
AFTER `node_code`;
END IF;
END$$
CALL `upgrade_blade_exception_disposal_node_code_20261005`()$$
DROP PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`$$
DELIMITER ;
@@ -0,0 +1,19 @@
-- 订正过程配置中「上传货量为是、货量类型为空」的脏配置
-- 日期:2026-10-05
--
-- 成因:过程配置页的「到场」「回单」两个节点模板缺 supportCargo 字段,货量开关
-- 在界面上不渲染,导致这两个节点被写成 uploadCargo=true 且 cargoTypes 恒为空。
-- 语义:「是否需要采集货量」以已勾选的货量类型为准——类型为空即不采集,
-- 不存在「没配类型所以三项全采集」。
-- 影响:订正后这些节点在小程序不再显示货量表单。因司机端解析过程配置时优先取
-- 启用中的活配置,本次订正会同时改变所有引用该项目的运单(含历史运单)的呈现。
-- 范围:只订正过程配置表,不修改 blade_waybill.process_json 快照列(那是历史留痕)。
-- 幂等:执行后目标子串不再存在,重复执行匹配 0 行。
UPDATE `blade_process_config`
SET `node_config_json` = REPLACE(
`node_config_json`,
'"uploadCargo":true,"cargoTypes":[]',
'"uploadCargo":false,"cargoTypes":[]')
WHERE `is_deleted` = 0
AND `node_config_json` LIKE '%"uploadCargo":true,"cargoTypes":[]%';