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> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 67aeabd7..57c96783 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -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 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("异常上报失败"); } 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 88140021..c04c46cd 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 @@ -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")); } + /** + * 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。 + *

+ * 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。 + * 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关, + * 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。 + */ public static boolean nodeNeedCargo(Map node) { - return isTruthy(node.get("uploadCargo")); + return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty(); } public static boolean nodeNeedVoucher(Map node) { @@ -327,6 +341,146 @@ public final class WaybillProcessSupport { return Func.isEmpty(name) ? nodeKey(node) : name; } + /** + * 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。 + *

+ * 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置 + * (否则第一个打卡节点永远等不到前置打卡记录)。 + *

+ * **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成; + * 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。 + * + * @param processJson 过程配置 + * @param punchedNodeKeys 该运单已打卡的节点 key + * @param nodeCode 待判定的节点 key + */ + public static Set unfinishedPrerequisiteKeys( + String processJson, Collection punchedNodeKeys, String nodeCode) { + if (Func.isEmpty(nodeCode)) { + return Collections.emptySet(); + } + Set punched = punchedNodeKeys == null + ? Collections.emptySet() : new HashSet<>(punchedNodeKeys); + Set unfinished = new LinkedHashSet<>(); + for (Map 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(); + } + + /** + * 异常处置是否已了结(已完成 / 已撤销)。 + *

+ * 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。 + */ + 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 node : listDriverPunchNodes(processJson)) { + if (isTransitNode(node)) { + return nodeKey(node); + } + } + return ""; + } + + /** + * 计算被异常门禁阻断的过程节点 key。 + *

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

+ * 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 + * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 + * + * @param processJson 过程配置 + * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 + */ + public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { + String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes); + return blockedNodeKeysFrom(processJson, earliest); + } + + /** + * 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。 + *

+ * 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。 + */ + public static String earliestBlockingNodeCode(String processJson, Collection unresolvedNodeCodes) { + List> punchNodes = listDriverPunchNodes(processJson); + if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return null; + } + Set reported = new HashSet<>(); + for (String code : unresolvedNodeCodes) { + String key = stringVal(code); + // 未指明节点的异常锁全单,优先级高于任何具体节点 + if (Func.isEmpty(key)) { + return ""; + } + reported.add(key); + } + for (Map node : punchNodes) { + String key = nodeKey(node); + if (reported.contains(key)) { + return key; + } + } + return null; + } + + /** + * 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。 + */ + public static Set blockedNodeKeysFrom(String processJson, String fromNodeCode) { + List> punchNodes = listDriverPunchNodes(processJson); + Set blocked = new LinkedHashSet<>(); + if (punchNodes.isEmpty() || fromNodeCode == null) { + return blocked; + } + // 空串代表「未指明节点」,阻断全部可打卡节点。 + // 新上报已强制要求节点,此分支只为**存量数据**保留——去掉它,那些没有节点的 + // 未了结异常会突然不再阻断任何节点,等于悄悄放开了打卡。 + if (Func.isEmpty(fromNodeCode)) { + for (Map 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> parseProcessNodes(String processJson) { if (Func.isEmpty(processJson)) { diff --git a/doc/nacos/routes/blade-gateway-dev.json b/doc/nacos/routes/blade-gateway-dev.json index d68db7a8..dce7bc48 100644 --- a/doc/nacos/routes/blade-gateway-dev.json +++ b/doc/nacos/routes/blade-gateway-dev.json @@ -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/(?.*)", + "replacement": "/$\\{segment}" + } + } + ], + "uri": "http://127.0.0.1:8010" + }, { "id": "example-route", "order": 0, diff --git a/doc/sql/transport/blade_exception_disposal_20260812.sql b/doc/sql/transport/blade_exception_disposal_20260812.sql index e1dbfd92..1592b95d 100644 --- a/doc/sql/transport/blade_exception_disposal_20260812.sql +++ b/doc/sql/transport/blade_exception_disposal_20260812.sql @@ -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 '项目', diff --git a/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql new file mode 100644 index 00000000..ca09481d --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql @@ -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 ; diff --git a/doc/sql/transport/blade_process_config_cargo_types_20261005.sql b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql new file mode 100644 index 00000000..b03a51bc --- /dev/null +++ b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql @@ -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":[]%';