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 f064807e..c7331afd 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 @@ -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 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 { } } + /** + * 断言运单已开始运输。 + *

+ * 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。 + * 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。 + */ + private void assertPunchStarted(String businessStatus) { + String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus); + if (reason != null) { + throw new ServiceException(reason); + } + } + /** * 断言指定节点未被异常门禁阻断。 *

@@ -1527,6 +1555,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { latestEnroute = enroutes.get(enroutes.size() - 1); } Map latestNodePunchByCode = latestNodePunchMap(waybill.getId()); + // 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打 + String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus()); for (Map 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.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(); } 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 76c0f0bf..2cf47ca4 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 @@ -158,6 +158,39 @@ public final class WaybillProcessSupport { return null; } + /** + * 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。 + *

+ * 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。 + * 判据是校正后落库的状态:无需接单的待执行运单在 {@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 "运单尚未开始运输,暂不可打卡"; + } + + /** + * 指定自然日是否已打过在途卡。 + *

+ * 与运单状态无关,只回答「这一天打过没有」。{@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; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index 9cf28c0a..2b35117f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -203,6 +203,26 @@ class DriverPunchNodeCardTest { "未打卡节点展开没有内容,也不可展开"); } + @Test + @DisplayName("运单未开始运输:未打卡的在途与普通节点都不可打、不可展开,文案说明是状态原因") + void notStartedWaybillLocksEveryUnpunchedCard() { + Waybill dispatching = waybill(); + dispatching.setBusinessStatus("dispatching"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(dispatching); + + Map 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) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index e83ef7ba..f2eeac76 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -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 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 6fb9e1c5..b11451aa 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -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")); // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index dde05e1f..28712838 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -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)); + } + }