♻️ 收紧上报门禁查询、去掉平行列表接口并统一节点编码比较

This commit is contained in:
2026-10-07 13:24:02 +08:00
parent 3e687c8519
commit 3d3ed0ca27
5 changed files with 110 additions and 120 deletions
@@ -114,12 +114,6 @@ 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 = "该运单存在未处理异常,处理完成后才能打卡";
@@ -811,17 +805,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return false;
}
// 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来
String key = nodeCode.trim();
return nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId))
.stream()
.map(punch -> Func.toStr(punch.getNodeCode(), "").trim())
.anyMatch(key::equalsIgnoreCase);
.anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode()));
}
@Override
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
// 与司机打卡、司机接单共用同一套归属判定,不另立第二套规则
// 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定
Driver driver = requireCurrentDriver();
loadDriverWaybill(waybillId, currentBoundPlates(driver));
}
@@ -864,7 +856,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return exceptionDisposalMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0)
.notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES)
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
.orderByAsc(ExceptionDisposal::getReportTime)
.orderByAsc(ExceptionDisposal::getId));
}
@@ -1107,18 +1099,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
// 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。
// 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点
// 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。
// 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64,
// 前端直收数字会丢精度。
List<ExceptionDisposal> unresolved = unresolvedExceptions(waybill.getId());
List<String> unresolvedCodes = unresolved.stream()
.map(item -> Func.toStr(item.getNodeCode(), "").trim())
.collect(Collectors.toList());
List<Long> unresolvedIds = unresolved.stream()
.map(ExceptionDisposal::getId)
.collect(Collectors.toList());
boolean hasWaybillException = WaybillProcessSupport.hasWaybillLevelException(unresolvedCodes);
boolean hasWaybillException = unresolved.stream()
.anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode()));
for (DriverPunchNodeVO vo : nodes) {
vo.setHasWaybillException(hasWaybillException);
vo.setRelatedExceptionIds(
WaybillProcessSupport.relatedExceptionIds(vo.getKey(), unresolvedCodes, unresolvedIds));
vo.setRelatedExceptionIds(unresolved.stream()
.filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode()))
.map(item -> String.valueOf(item.getId()))
.collect(Collectors.toList()));
}
for (DriverPunchNodeVO vo : nodes) {
@@ -54,6 +54,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -182,12 +183,12 @@ public class ExceptionDisposalServiceImpl
* 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。
*/
private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) {
List<ExceptionDisposal> existing = baseMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
Long count = baseMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0));
return existing.stream().anyMatch(item ->
nodeCode.equalsIgnoreCase(Func.toStr(item.getNodeCode(), "").trim())
&& !WaybillProcessSupport.isSettledDisposal(item.getDisposalStatus()));
.eq(ExceptionDisposal::getIsDeleted, 0)
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
.apply("LOWER(node_code) = {0}", nodeCode.toLowerCase(Locale.ROOT)));
return count != null && count > 0;
}
/** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */
@@ -58,6 +58,8 @@ public final class WaybillProcessSupport {
/** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */
private static final String DISPOSAL_COMPLETED = "completed";
private static final String DISPOSAL_REVOKED = "revoked";
/** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */
public static final List<String> SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED);
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
@@ -391,42 +393,31 @@ public final class WaybillProcessSupport {
}
/**
* 是否存在未了结的整单异常。
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
* <p>
* 整单异常不归属任何过程节点,它的节点编码为空;它作用于整张运单,
* 所以该运单每个可打卡节点都要挂上「整单异常」标记。
* 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。
*/
public static boolean hasWaybillLevelException(List<String> unresolvedNodeCodes) {
if (unresolvedNodeCodes == null) {
return false;
}
return unresolvedNodeCodes.stream().anyMatch(code -> stringVal(code).isEmpty());
public static boolean sameNodeCode(String left, String right) {
String leftCode = stringVal(left);
String rightCode = stringVal(right);
return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode);
}
/**
* 与某节点相关的未了结异常 id,一律以字符串返回。
* 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。
*/
public static boolean isWaybillLevelException(String exceptionNodeCode) {
return stringVal(exceptionNodeCode).isEmpty();
}
/**
* 该异常是否与某节点相关。
* <p>
* 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。
* 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。
* <p>
* 顺序保持入参顺序,调用方按上报时间升序取出未了结异常即可。
* id 转成字符串是因为主键是 int64,前端直收数字会丢精度。
*/
public static List<String> relatedExceptionIds(String nodeKey, List<String> unresolvedNodeCodes,
List<Long> unresolvedIds) {
if (unresolvedNodeCodes == null || unresolvedIds == null) {
return Collections.emptyList();
}
String key = stringVal(nodeKey);
List<String> related = new ArrayList<>();
int size = Math.min(unresolvedNodeCodes.size(), unresolvedIds.size());
for (int i = 0; i < size; i++) {
String code = stringVal(unresolvedNodeCodes.get(i));
if (code.isEmpty() || code.equalsIgnoreCase(key)) {
related.add(String.valueOf(unresolvedIds.get(i)));
}
}
return related;
public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) {
return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode);
}
/**
@@ -21,7 +21,8 @@ import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IWaybillService;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -74,7 +75,7 @@ class ExceptionDisposalReportGateTest {
inject("baseMapper", disposalMapper);
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
when(disposalMapper.selectList(any())).thenReturn(List.of());
when(disposalMapper.selectCount(any())).thenReturn(0L);
when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1);
}
@@ -120,7 +121,7 @@ class ExceptionDisposalReportGateTest {
@DisplayName("节点已有待处理异常时拒绝再报")
void pendingNodeExceptionRejectsReport() {
stubNode("load", "装货", "cargo");
stubUnresolvedNodeException("load", "pending");
stubUnresolvedCount(1L);
ServiceException exception = submitExpectRejected("load");
@@ -131,46 +132,55 @@ class ExceptionDisposalReportGateTest {
@DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁")
void processingNodeExceptionRejectsReport() {
stubNode("load", "装货", "cargo");
stubUnresolvedNodeException("load", "processing");
stubUnresolvedCount(1L);
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
List<Object> params = capturedUnresolvedParams();
assertTrue(params.contains("completed") && params.contains("revoked"),
"未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params);
}
@Test
@DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报")
void settledNodeExceptionAllowsReportAgain() {
stubNode("load", "装货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(
nodeException("load", "completed"),
nodeException("load", "revoked")));
stubUnresolvedCount(0L);
submitOk("load");
assertEquals("load", savedDisposal().getNodeCode());
assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")),
"只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外");
}
@Test
@DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上")
void otherNodesExceptionDoesNotBlockReport() {
stubNode("unload", "卸货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("load", "pending")));
stubUnresolvedCount(0L);
submitOk("unload");
assertEquals("unload", savedDisposal().getNodeCode());
assertTrue(capturedUnresolvedParams().contains("unload"),
"节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams());
}
@Test
@DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点")
void nodeCodeComparisonIgnoresCase() {
stubNode("load", "装货", "cargo");
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("LOAD", "pending")));
stubUnresolvedCount(1L);
ServiceException exception = submitExpectRejected("load");
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(node_code) ="),
"忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql());
assertTrue(capturedUnresolvedParams().contains("load"),
"参数取节点 key 的小写形式:" + capturedUnresolvedParams());
}
@Test
@@ -193,15 +203,14 @@ class ExceptionDisposalReportGateTest {
submitOk("load");
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(disposalMapper).selectList(captor.capture());
String sql = captor.getValue().getTargetSql();
String sql = capturedUnresolvedQuery().getTargetSql();
assertTrue(sql.contains("waybill_id"),
"未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql);
assertTrue(sql.contains("is_deleted"),
"已删除的异常不算未了结:" + sql);
assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID),
"运单条件取本次上报的运单:" + capturedUnresolvedParams());
}
// ------------------------------------------------------------ 整单异常不受门禁
@@ -209,7 +218,7 @@ class ExceptionDisposalReportGateTest {
@Test
@DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报")
void waybillLevelReportIsNotGated() {
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(null, "pending")));
stubUnresolvedCount(1L);
submitOk(null);
@@ -218,6 +227,7 @@ class ExceptionDisposalReportGateTest {
assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个");
verify(driverWaybillService, never()).findPunchNode(any(), any());
verify(driverWaybillService, never()).isNodePunched(any(), any());
verify(disposalMapper, never()).selectCount(any());
}
@Test
@@ -268,8 +278,33 @@ class ExceptionDisposalReportGateTest {
when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false);
}
private void stubUnresolvedNodeException(String nodeCode, String status) {
when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(nodeCode, status)));
private void stubUnresolvedCount(long count) {
when(disposalMapper.selectCount(any())).thenReturn(count);
}
/** 上报门禁查未了结异常的那条 count 查询 */
private LambdaQueryWrapper<ExceptionDisposal> capturedUnresolvedQuery() {
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(disposalMapper).selectCount(captor.capture());
return captor.getValue();
}
/** 上述查询的参数值,集合型参数摊平成元素 */
private List<Object> capturedUnresolvedParams() {
LambdaQueryWrapper<ExceptionDisposal> query = capturedUnresolvedQuery();
// MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值
query.getTargetSql();
Collection<Object> values = query.getParamNameValuePairs().values();
List<Object> params = new ArrayList<>();
for (Object value : values) {
if (value instanceof Collection<?> nested) {
params.addAll(nested);
} else {
params.add(value);
}
}
return params;
}
private ServiceException submitExpectRejected(String nodeCode) {
@@ -307,17 +342,6 @@ class ExceptionDisposalReportGateTest {
return request;
}
private ExceptionDisposal nodeException(String nodeCode, String status) {
ExceptionDisposal disposal = new ExceptionDisposal();
disposal.setId(6101L);
disposal.setWaybillId(WAYBILL_ID);
disposal.setNodeCode(nodeCode);
disposal.setDisposalStatus(status);
disposal.setIsDeleted(0);
disposal.setReportTime(LocalDateTime.of(2026, 10, 6, 9, 30));
return disposal;
}
private Map<String, Object> node(String key, String name, String type) {
Map<String, Object> cfg = new HashMap<>();
cfg.put("key", key);
@@ -135,58 +135,41 @@ class WaybillProcessSupportTest {
@Test
@DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点")
void relatedExceptionsCoverWaybillLevelForEveryNode() {
List<String> nodeCodes = Arrays.asList("load", "");
List<Long> ids = Arrays.asList(11L, 12L);
assertEquals(Arrays.asList("11", "12"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids));
assertEquals(Collections.singletonList("12"), WaybillProcessSupport.relatedExceptionIds("sign", nodeCodes, ids),
void relatesToNodeCoversWaybillLevelForEveryNode() {
assertTrue(WaybillProcessSupport.relatesToNode("load", null),
"整单异常不归属任何节点,对每个节点都算相关");
assertTrue(WaybillProcessSupport.relatesToNode("load", ""));
assertTrue(WaybillProcessSupport.relatesToNode("load", "load"));
assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"),
"装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事");
}
@Test
@DisplayName("相关异常 id 保持入参顺序,即上报时间升序")
void relatedExceptionIdsKeepReportOrder() {
List<String> nodeCodes = Arrays.asList("", "load");
List<Long> ids = Arrays.asList(21L, 22L);
assertEquals(Arrays.asList("21", "22"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids));
@DisplayName("节点编码忽略大小写,两侧空白不影响判定")
void relatesToNodeIgnoresCaseAndBlank() {
assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD"));
assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD "));
assertFalse(WaybillProcessSupport.relatesToNode("load", "unload"));
}
@Test
@DisplayName("节点编码忽略大小写")
void relatedExceptionIdsIgnoreCase() {
assertEquals(Collections.singletonList("31"),
WaybillProcessSupport.relatedExceptionIds(
"load", Collections.singletonList("LOAD"), Collections.singletonList(31L)));
}
@Test
@DisplayName("id 一律下发字符串:主键是 int64,前端直收数字会丢精度")
void relatedExceptionIdsAreStrings() {
List<String> ids = WaybillProcessSupport.relatedExceptionIds(
"load", Collections.singletonList("load"), Collections.singletonList(9007199254740993L));
assertEquals(Collections.singletonList("9007199254740993"), ids);
}
@Test
@DisplayName("整单异常的判定只看有没有不归属节点的异常")
@DisplayName("整单异常的判定只看节点编码是否为空")
void waybillLevelExceptionFlag() {
assertTrue(WaybillProcessSupport.hasWaybillLevelException(Arrays.asList("load", "")));
assertTrue(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList(null)));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList("load")));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList()));
assertFalse(WaybillProcessSupport.hasWaybillLevelException(null));
assertTrue(WaybillProcessSupport.isWaybillLevelException(null));
assertTrue(WaybillProcessSupport.isWaybillLevelException(""));
assertTrue(WaybillProcessSupport.isWaybillLevelException(" "));
assertFalse(WaybillProcessSupport.isWaybillLevelException("load"));
}
@Test
@DisplayName("异常全部了结后,整单标记与相关 id 都回到「无」")
void withoutUnresolvedExceptionsNothingIsTagged() {
assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList()));
assertTrue(WaybillProcessSupport
.relatedExceptionIds("load", Collections.emptyList(), Collections.emptyList())
.isEmpty());
@DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等")
void sameNodeCodeIgnoresCaseAndBlank() {
assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD"));
assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load"));
assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload"));
assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点");
assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等");
assertFalse(WaybillProcessSupport.sameNodeCode(null, null));
}
}