✨ 异常处置支持归属过程节点并新增打卡门禁
异常上报强制选择过程节点,节点归属写入 node_code / node_name;节点存在未了结 异常时,该节点及其后节点不可打卡(异常门禁),被拦节点仍可展开查看回填内容。 另新增顺序门禁:前置打卡节点未完成时后续节点不可打卡且不可展开;在途节点不受 顺序门禁约束,但仍受异常门禁约束。 配套: - 过程配置「上传货量为是、货量类型为空」的脏配置订正为不采集货量 - 网关补充 /blade-resource/** 路由(图片上传,小程序打卡与异常上报照片均经此)
This commit is contained in:
+6
@@ -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;
|
||||
|
||||
|
||||
+25
-1
@@ -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;
|
||||
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+223
-5
@@ -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) {
|
||||
|
||||
+19
-1
@@ -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("异常上报失败");
|
||||
}
|
||||
|
||||
+155
-1
@@ -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)) {
|
||||
|
||||
@@ -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":[]%';
|
||||
Reference in New Issue
Block a user