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

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;