✨ 末位打卡卡上完成运单,后台补「确认回单」入口

司机端打卡页的完成动作从底栏挪到末位卡上:节点下发时按与 submitNode 自动完成同一份判据
(末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认)给出「打这张卡即完成运单」,
客户端据此把按钮文案改成「完成运单」。两处判据同源,否则会出现「按钮写着完成运单、
打下去这张运单没完成」。

回单节点配成「需业务员确认」时,司机打完末位卡不置完成,此前全仓没有任何入口让业务员确认后置完成,
运单只能人工去后台点完成。新增 POST /waybill-manage/confirm-return:校验该运单的回单确实要求确认、
司机打卡节点(在途除外——它是频次打卡节点、不是必经一步)全部已打卡、无未了结异常,再走既有的
完成逻辑(改状态 + 生成应收应付 + 尝试完成配载单)。完成接口本身不要求节点打卡,不校验这两件事
就等于借这个入口绕过打卡门禁与异常门禁。本轮不做撤销。

新增 DriverPunchFinishFlagTest(4 项)与 WaybillConfirmReturnTest(5 项);
blade-transport 全量 385 项用例通过。
This commit is contained in:
2026-10-18 12:14:54 +08:00
parent 7d951d5b04
commit 4011577491
8 changed files with 506 additions and 0 deletions
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
return R.data(waybillService.batchComplete(ids));
}
@PostMapping("/confirm-return")
@ApiOperationSupport(order = 24)
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.status(waybillService.confirmReturnReceipt(id));
}
@PostMapping("/road-loading")
@ApiOperationSupport(order = 23)
@Operation(summary = "公路配载", description = "传入ids")
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
*/
boolean completeWithoutDeptCheck(Long id);
/**
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
* <p>
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
*/
boolean confirmReturnReceipt(Long id);
BusinessRemoveResultVO batchComplete(String ids);
LoadingManageVO roadLoading(String ids);
@@ -1536,6 +1536,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
vo.setVisible(true);
vo.setDone(done);
vo.setActionable(!done);
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey())
&& WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName())
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson));
if (done) {
if (punched.getPunchTime() != null) {
LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
@@ -40,11 +40,13 @@ import org.springblade.thirdparty.lbs.feign.ILbsClient;
import org.springblade.thirdparty.lbs.pojo.dto.LbsLocateRequest;
import org.springblade.thirdparty.lbs.pojo.vo.LbsLocateResponse;
import org.springblade.transport.excel.WaybillExcel;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
@@ -82,6 +84,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -125,6 +128,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@jakarta.annotation.Resource
private WaybillEnroutePunchMapper waybillEnroutePunchMapper;
@jakarta.annotation.Resource
private ExceptionDisposalMapper exceptionDisposalMapper;
@jakarta.annotation.Resource
private ILbsClient lbsClient;
@@ -1495,6 +1501,61 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return doComplete(waybill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean confirmReturnReceipt(Long id) {
Waybill waybill = loadEditable(id, true);
String processJson = LiveProcessConfigSupport.resolve(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
// 否则用户会以为是确认没生效
throw new ServiceException("该运单的回单不需要业务员确认");
}
assertPunchNodesFinished(waybill.getId(), processJson);
assertNoUnresolvedException(waybill.getId());
return doComplete(waybill);
}
/**
* 司机打卡节点(在途除外)必须全部已打卡:完成接口本身不要求节点打卡,
* 不挡住就能从「确认回单」绕过打卡门禁。
*/
private void assertPunchNodesFinished(Long waybillId, String processJson) {
List<String> missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId));
if (!missing.isEmpty()) {
throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing));
}
}
/** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */
private Set<String> punchedNodeKeys(Long waybillId) {
Set<String> keys = new LinkedHashSet<>();
List<WaybillNodePunch> punches = waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId));
for (WaybillNodePunch punch : punches) {
if (Func.isNotEmpty(punch.getNodeCode())) {
keys.add(punch.getNodeCode().trim().toLowerCase(Locale.ROOT));
}
if (Func.isNotEmpty(punch.getNodeName())) {
keys.add(punch.getNodeName().trim().toLowerCase(Locale.ROOT));
}
}
return keys;
}
/** 存在未了结异常时不允许置完成:判据与司机端完成同一道门(处置单达终态才算解除) */
private void assertNoUnresolvedException(Long waybillId) {
Long unresolved = exceptionDisposalMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0)
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES));
if (unresolved != null && unresolved > 0) {
throw new ServiceException("该运单存在未处理异常,处理完成后才能确认回单");
}
}
/**
* 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。
*/
@@ -38,6 +38,7 @@ import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -637,6 +638,34 @@ public final class WaybillProcessSupport {
return true;
}
/**
* 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则
* 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。
* <p>
* 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。
*
* @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点)
*/
public static List<String> missingPunchNodeNames(String processJson, Collection<String> punchedKeys) {
List<Map<String, Object>> punchConfigs = listDriverPunchNodes(processJson);
if (punchConfigs.isEmpty()) {
return Collections.emptyList();
}
Collection<String> punched = punchedKeys == null ? Collections.emptySet() : punchedKeys;
List<String> missing = new ArrayList<>();
for (Map<String, Object> node : punchConfigs) {
if (isTransitNode(node)) {
continue;
}
String code = nodeKey(node).toLowerCase(Locale.ROOT);
String name = nodeName(node).toLowerCase(Locale.ROOT);
if (!punched.contains(code) && !punched.contains(name)) {
missing.add(nodeName(node));
}
}
return missing;
}
private static boolean isTransitNode(Map<String, Object> node) {
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));