🐛 运单打卡收紧为仅运输中,修复同日重复在途打卡
This commit is contained in:
+59
-8
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+38
-4
@@ -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;
|
||||
|
||||
+20
@@ -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) {
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
+3
-1
@@ -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"));
|
||||
|
||||
// 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」,
|
||||
// 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分
|
||||
|
||||
+53
-2
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user