🐛 运单打卡收紧为仅运输中,修复同日重复在途打卡

This commit is contained in:
2026-10-20 21:04:20 +08:00
parent a1a741481c
commit e39251eba5
6 changed files with 187 additions and 25 deletions
@@ -405,18 +405,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
Driver driver = requireCurrentDriver();
// 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明)
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true);
// 加行锁:重复打卡是先查后插,并发请求读不到对方未提交的流水,不锁会各插一条
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true, true);
waybill = normalizeAcceptStatus(waybill);
assertPunchNotEnded(waybill.getBusinessStatus());
assertPunchStarted(waybill.getBusinessStatus());
String processJson = resolveProcessJson(waybill);
Date lastPunchAt = findLastPunchTime(waybill.getId());
Date lastPunchAt = findLastPunchTime(waybill.getId(), true);
WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin(
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
if (!decision.punchEnabled()) {
throw new ServiceException("该运单未启用在途打卡");
}
if (decision.doneToday()) {
// 重复校验按「自然日是否打过」独立判:decision.doneToday() 只在进行中分支计算
if (WaybillProcessSupport.isPunchedOn(lastPunchAt, null)) {
throw new ServiceException("今日已完成在途打卡,不可重复打卡");
}
// 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束
@@ -458,6 +461,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
waybill = normalizeAcceptStatus(waybill);
assertPunchNotEnded(waybill.getBusinessStatus());
assertPunchStarted(waybill.getBusinessStatus());
String processJson = resolveProcessJson(waybill);
Map<String, Object> nodeCfg = findPunchNodeConfig(processJson, nodeCode);
@@ -781,6 +785,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
/** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */
private Waybill loadDriverWaybill(Long id, boolean includeRejected) {
return loadDriverWaybill(id, includeRejected, false);
}
/**
* @param forUpdate 加行锁,仅 submitEnroute 使用:「同一天是否打过」是查完再插,
* 不加锁时并发的两个请求都读不到对方未提交的流水,会各插一条。
* 锁的是运单行,同一运单的打卡提交因此串行。
*/
private Waybill loadDriverWaybill(Long id, boolean includeRejected, boolean forUpdate) {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
@@ -788,8 +801,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (Func.isEmpty(phone)) {
throw new ServiceException("当前用户手机号为空,无法查找运单");
}
// MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后
String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected)
.eq(Waybill::getId, id).last("LIMIT 1"));
.eq(Waybill::getId, id).last(tail));
if (waybill == null) {
throw new ServiceException("运单不存在或无权操作");
}
@@ -1333,6 +1348,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
}
/**
* 断言运单已开始运输。
* <p>
* 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。
* 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。
*/
private void assertPunchStarted(String businessStatus) {
String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus);
if (reason != null) {
throw new ServiceException(reason);
}
}
/**
* 断言指定节点未被异常门禁阻断。
* <p>
@@ -1527,6 +1555,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
latestEnroute = enroutes.get(enroutes.size() - 1);
}
Map<String, WaybillNodePunch> latestNodePunchByCode = latestNodePunchMap(waybill.getId());
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
for (Map<String, Object> cfg : punchConfigs) {
boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg);
@@ -1554,7 +1584,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
vo.setCargoTypes(Collections.emptyList());
vo.setVisible(true);
vo.setDone(done);
vo.setActionable(!done);
vo.setActionable(!done && notStartedReason == null);
if (notStartedReason != null) {
vo.setLockReason(notStartedReason);
}
if (done && latestEnroute != null) {
vo.setCheckinTime(latestEnroute.getTime());
vo.setCheckinPlace(latestEnroute.getAddress());
@@ -1575,7 +1608,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
boolean done = punched != null;
vo.setVisible(true);
vo.setDone(done);
vo.setActionable(!done);
vo.setActionable(!done && notStartedReason == null);
if (notStartedReason != null && !done) {
vo.setLockReason(notStartedReason);
}
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
@@ -1610,6 +1646,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) {
continue;
}
// 未开始运输是更根本的拒绝:运单状态不变,处理完异常也打不了。
// 这里不能再改写成异常文案,否则会暗示「处理完就能打」。
if (notStartedReason != null) {
continue;
}
vo.setBlocked(true);
vo.setActionable(false);
if (blocker != null) {
@@ -1665,7 +1706,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
boolean ended = Boolean.TRUE.equals(vo.getEnded());
boolean done = Boolean.TRUE.equals(vo.getDone());
boolean transitCard = Boolean.TRUE.equals(vo.getTransit());
vo.setExpandable(!blockedByOrder && (!ended || (done && !transitCard)));
// 未开始运输的未打卡卡片:打不了也展不开——展开是空表单,状态由锁文案说明
boolean notStartedLocked = !done && notStartedReason != null;
vo.setExpandable(!blockedByOrder && !notStartedLocked && (!ended || (done && !transitCard)));
}
// 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。
@@ -1899,13 +1942,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
private Date findLastPunchTime(Long waybillId) {
return findLastPunchTime(waybillId, false);
}
/**
* @param forUpdate 仅 submitEnroute:与运单行锁配对。不加锁读走的是事务快照,
* 并发提交的第一笔已提交后,第二笔可能仍读到旧快照而看不到它。
*/
private Date findLastPunchTime(Long waybillId, boolean forUpdate) {
if (waybillId == null) {
return null;
}
WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.<WaybillEnroutePunch>lambdaQuery()
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
.orderByDesc(WaybillEnroutePunch::getPunchTime)
.last("LIMIT 1"));
.last(forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"));
return latest == null ? null : latest.getPunchTime();
}
@@ -158,6 +158,39 @@ public final class WaybillProcessSupport {
return null;
}
/**
* 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。
* <p>
* 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。
* 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus}
* 里已被提升为进行中,未提升的待执行就是真的还没接单。
*/
public static boolean isTransitPunchStarted(String businessStatus) {
return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus));
}
/**
* 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。
*/
public static String punchNotStartedReason(String businessStatus) {
if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) {
return null;
}
return "运单尚未开始运输,暂不可打卡";
}
/**
* 指定自然日是否已打过在途卡。
* <p>
* 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()}
* 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。
*/
public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) {
LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate();
LocalDate last = toLocalDate(lastPunchAt);
return last != null && last.equals(day);
}
public static boolean isTerminalBusinessStatus(String businessStatus) {
return "draft".equals(businessStatus)
|| "completed".equals(businessStatus)
@@ -227,15 +260,16 @@ public final class WaybillProcessSupport {
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);
// punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发,
// 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。
if (!isTransitPunchStarted(businessStatus)) {
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
}
LocalDateTime current = now == null ? LocalDateTime.now() : now;
LocalDate today = current.toLocalDate();
LocalDate lastDate = toLocalDate(lastPunchAt);
boolean doneToday = lastDate != null && lastDate.equals(today);
boolean doneToday = isPunchedOn(lastPunchAt, current);
boolean dueByFrequency;
if (lastDate == null) {
dueByFrequency = true;
@@ -203,6 +203,26 @@ class DriverPunchNodeCardTest {
"未打卡节点展开没有内容,也不可展开");
}
@Test
@DisplayName("运单未开始运输:未打卡的在途与普通节点都不可打、不可展开,文案说明是状态原因")
void notStartedWaybillLocksEveryUnpunchedCard() {
Waybill dispatching = waybill();
dispatching.setBusinessStatus("dispatching");
when(waybillService.getById(WAYBILL_ID)).thenReturn(dispatching);
Map<String, DriverPunchNodeVO> nodes = punchNodes();
assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()),
"已打卡的装货仍可展开回看,未开始运输不影响历史记录");
for (String key : List.of("sign", "transit")) {
DriverPunchNodeVO node = nodes.get(key);
assertFalse(Boolean.TRUE.equals(node.getDone()), key + " 未打卡");
assertFalse(Boolean.TRUE.equals(node.getActionable()), key + " 未开始运输不可打");
assertEquals("运单尚未开始运输,暂不可打卡", node.getLockReason(), key);
assertFalse(Boolean.TRUE.equals(node.getExpandable()), key + " 不可展开");
}
}
// ------------------------------------------------------------ 夹具
private void stubUnresolved(ExceptionDisposal... disposals) {
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
@@ -29,7 +30,9 @@ import org.springblade.transport.support.CertificationGate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -167,28 +170,29 @@ class DriverWaybillAutoCompleteTest {
// ------------------------------------------------------------ 前置:运单本身能否完成
@Test
@DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功")
void draftWaybillIsPunchedButNotCompleted() {
@DisplayName("草稿运单未开始运输:打卡被拒,不落流水也不自动完成")
void draftWaybillCannotBePunched() {
givenWaybill(SIGN_LAST_JSON, "draft");
DriverNodePunchVO vo = submit("sign");
ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"));
assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage());
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
verify(waybillService, never()).completeWithoutDeptCheck(any());
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
assertFalse(vo.getWaybillCompleted());
}
@Test
@DisplayName("待执行与调度中间态同样允许自动完成")
void dispatchableStatusesStillComplete() {
@DisplayName("待执行、待调度、调度中都未开始运输:节点打卡一律被拒")
void dispatchableStatusesCannotPunch() {
for (String status : List.of("pending", "waiting_dispatch", "dispatching")) {
givenWaybill(SIGN_LAST_JSON, status);
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
DriverNodePunchVO vo = submit("sign");
ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"), status);
assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态");
assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage(), status);
}
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
verify(waybillService, never()).completeWithoutDeptCheck(any());
}
@Test
@@ -182,7 +182,9 @@ class DriverWaybillRejectDetachTest {
void enrouteSubmitKeepsUnfilteredScope() {
EnrouteSubmitDTO dto = new EnrouteSubmitDTO();
dto.setWaybillId(REJECTED_ID);
when(waybillService.getOne(any())).thenReturn(rejectedWaybill());
// 已拒单 + 进行中:历史脏数据,取单不能被拒单条件挡住,收口交给后面的过程配置检查
when(waybillService.getOne(any()))
.thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "running", "rejected"));
// 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」,
// 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分
@@ -346,10 +346,10 @@ class WaybillProcessSupportTest {
}
@Test
@DisplayName("结束门禁:草稿、待执行、待调度、调度中都还能打卡,不算结束")
@DisplayName("结束门禁:草稿、待执行、待调度、调度中都不算结束")
void punchEndedExcludesStatusesThatStillAllowPunching() {
for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "running")) {
assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 的司机仍要能打卡");
assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 不是结束态");
assertNull(WaybillProcessSupport.punchEndedReason(status), status + " 不该有结束文案");
}
assertFalse(WaybillProcessSupport.isPunchEnded(null));
@@ -398,4 +398,55 @@ class WaybillProcessSupportTest {
assertFalse(decision.doneToday());
}
// ------------------------------------------------------------ 开始运输门禁
@Test
@DisplayName("开始门禁:只有进行中算已开始运输")
void transitPunchStartedOnlyRunning() {
assertTrue(WaybillProcessSupport.isTransitPunchStarted("running"));
assertTrue(WaybillProcessSupport.isTransitPunchStarted(" RUNNING "));
for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "completed", "cancelled")) {
assertFalse(WaybillProcessSupport.isTransitPunchStarted(status), status + " 还没开始运输");
}
assertFalse(WaybillProcessSupport.isTransitPunchStarted(null));
assertFalse(WaybillProcessSupport.isTransitPunchStarted(""));
}
@Test
@DisplayName("开始门禁文案:未开始一句话,进行中与已结束都交给别的门禁")
void punchNotStartedReasonOnlyForNotStartedStatuses() {
assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("pending"));
assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("dispatching"));
assertNull(WaybillProcessSupport.punchNotStartedReason("running"));
// 结束态由结束文案负责,两句话不能抢
assertNull(WaybillProcessSupport.punchNotStartedReason("completed"));
assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled"));
}
@Test
@DisplayName("在途卡:未开始运输的运单仍下发卡片,但不可打卡")
void transitCardOnNotStartedWaybillIsVisibleButNotPunchable() {
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
WaybillProcessSupport.TransitCheckinDecision decision =
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "dispatching", null, now);
assertTrue(decision.visible(), "卡片仍要下发,司机要看得到这张卡为什么不能打");
assertFalse(decision.punchEnabled(), "未开始运输不能提交打卡");
assertFalse(decision.doneToday());
assertFalse(decision.dueToday());
}
@Test
@DisplayName("同日判重:只认同一个自然日,跨日与空值都不算")
void isPunchedOnComparesByNaturalDay() {
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
Date today = Date.from(LocalDateTime.of(2026, 10, 20, 8, 0).atZone(ZoneId.systemDefault()).toInstant());
Date yesterday = Date.from(LocalDateTime.of(2026, 10, 19, 23, 0).atZone(ZoneId.systemDefault()).toInstant());
assertTrue(WaybillProcessSupport.isPunchedOn(today, now));
assertFalse(WaybillProcessSupport.isPunchedOn(yesterday, now));
assertFalse(WaybillProcessSupport.isPunchedOn(null, now));
}
}