✨ 运单结束后拒绝司机打卡

已完成/已取消的运单不再接受在途与节点打卡,卡片仍下发但不可提交。
This commit is contained in:
2026-10-20 14:37:12 +08:00
parent 02d75e7e95
commit aa0226e77b
3 changed files with 110 additions and 0 deletions
@@ -407,6 +407,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
// 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明)
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true);
waybill = normalizeAcceptStatus(waybill);
assertPunchNotEnded(waybill.getBusinessStatus());
String processJson = resolveProcessJson(waybill);
Date lastPunchAt = findLastPunchTime(waybill.getId());
@@ -456,6 +457,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Driver driver = requireCurrentDriver();
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
waybill = normalizeAcceptStatus(waybill);
assertPunchNotEnded(waybill.getBusinessStatus());
String processJson = resolveProcessJson(waybill);
Map<String, Object> nodeCfg = findPunchNodeConfig(processJson, nodeCode);
@@ -1318,6 +1320,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
.orElse(null);
}
/**
* 断言运单未结束。
* <p>
* 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。
* 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。
*/
private void assertPunchNotEnded(String businessStatus) {
String reason = WaybillProcessSupport.punchEndedReason(businessStatus);
if (reason != null) {
throw new ServiceException(reason);
}
}
/**
* 断言指定节点未被异常门禁阻断。
* <p>
@@ -133,6 +133,31 @@ public final class WaybillProcessSupport {
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
}
/**
* 运单已结束:已完成或已取消。此后不再接受司机打卡。
* <p>
* 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
*/
public static boolean isPunchEnded(String businessStatus) {
String status = stringVal(businessStatus);
return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status);
}
/**
* 结束态下的打卡拒绝文案;非结束态返回 null。
*/
public static String punchEndedReason(String businessStatus) {
String status = stringVal(businessStatus);
if ("completed".equalsIgnoreCase(status)) {
return "运单已完成";
}
if ("cancelled".equalsIgnoreCase(status)) {
return "运单已取消";
}
return null;
}
public static boolean isTerminalBusinessStatus(String businessStatus) {
return "draft".equals(businessStatus)
|| "completed".equals(businessStatus)
@@ -198,6 +223,10 @@ public final class WaybillProcessSupport {
int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1);
String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00");
String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59");
// 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打
if (isPunchEnded(businessStatus)) {
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
}
// punch=是即展示卡片;非进行中仅不可作为「今日待打」
if (!STATUS_RUNNING.equals(businessStatus)) {
return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd);
@@ -3,14 +3,18 @@ package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -330,4 +334,66 @@ class WaybillProcessSupportTest {
"TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单");
}
// ------------------------------------------------------------ 结束门禁
@Test
@DisplayName("结束门禁:只认已完成与已取消,大小写不敏感")
void punchEndedOnlyForCompletedAndCancelled() {
assertTrue(WaybillProcessSupport.isPunchEnded("completed"));
assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED"));
assertTrue(WaybillProcessSupport.isPunchEnded("cancelled"));
assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled"));
}
@Test
@DisplayName("结束门禁:草稿、待执行、待调度、调度中都还能打卡,不算结束")
void punchEndedExcludesStatusesThatStillAllowPunching() {
for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "running")) {
assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 的司机仍要能打卡");
assertNull(WaybillProcessSupport.punchEndedReason(status), status + " 不该有结束文案");
}
assertFalse(WaybillProcessSupport.isPunchEnded(null));
assertFalse(WaybillProcessSupport.isPunchEnded(""));
assertFalse(WaybillProcessSupport.isPunchEnded(" "));
}
@Test
@DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案")
void punchEndedReasonOnlyForEndedStatuses() {
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed"));
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED "));
assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled"));
assertNull(WaybillProcessSupport.punchEndedReason(null));
}
@Test
@DisplayName("在途卡:已完成的运单今天打过卡也不可再打,且不算今日已打")
void transitCardOnEndedWaybillIsVisibleButNotPunchable() {
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
// 打卡时间就落在 now 所在的那一天
Date lastPunchAt = Date.from(now.toLocalDate().atStartOfDay(ZoneId.systemDefault()).plusHours(8).toInstant());
WaybillProcessSupport.TransitCheckinDecision decision =
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "completed", lastPunchAt, now);
assertTrue(decision.visible(), "卡片仍要下发,司机能回看今天打过没有");
assertFalse(decision.punchEnabled(), "运单已结束,不能提交打卡");
assertFalse(decision.doneToday(), "结束不是「今日已打卡」,两者的后续动作不同");
assertFalse(decision.dueToday());
}
@Test
@DisplayName("在途卡回归:进行中且今天没打卡时仍可打")
void transitCardOnRunningWaybillStaysPunchable() {
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
WaybillProcessSupport.TransitCheckinDecision decision =
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "running", null, now);
assertTrue(decision.visible());
assertTrue(decision.punchEnabled());
assertTrue(decision.dueToday(), "frequencyDays 默认为 1、无历史打卡,且 noon 落在默认时段内");
assertFalse(decision.doneToday());
}
}