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

司机端打卡页的完成动作从底栏挪到末位卡上:节点下发时按与 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
@@ -70,6 +70,9 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
private Boolean visible;
@Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单")
private Boolean finishOnPunch;
@Schema(description = "是否默认展开(仅第一个可打卡节点)")
private Boolean defaultExpanded;
@@ -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"));
@@ -0,0 +1,163 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。
* <p>
* 判据必须与 {@code submitNode} 的自动完成同一份——末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认;
* 两处不同源就会出现「按钮写着完成运单、打下去这张运单没完成」。接单与回单不是司机打卡节点,不参与排位。
*/
class DriverPunchFinishFlagTest {
private static final Long WAYBILL_ID = 5601L;
private static final String NO_RETURN_JSON = """
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
""";
private static final String RETURN_NO_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
""";
private static final String RETURN_NEED_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
/** 末位可打卡节点是「到场」:不在到货/卸货/签收之列,打完不算完成 */
private static final String LAST_NODE_NOT_DELIVERY_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"arrive_scene","name":"到场","type":"location","enabled":true,"punch":true}]
""";
private IWaybillService waybillService;
private WaybillNodePunchMapper nodePunchMapper;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
exceptionDisposalMapper,
mock(ITripQueryService.class));
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
}
@Test
@DisplayName("末位是签收且没有回单节点:只有它带完成标记,装货与接单都不带")
void lastDeliveryNodeCarriesFinishFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(NO_RETURN_JSON);
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), "签收是末位到货类节点,打完即完成");
assertFalse(Boolean.TRUE.equals(nodes.get("load").getFinishOnPunch()), "装货不是末位");
assertFalse(nodes.containsKey("accept"), "接单不是司机打卡节点,不出现在卡片里");
}
@Test
@DisplayName("回单要求业务员确认:末位卡不带完成标记(司机打完不置完成,等业务员确认)")
void returnNeedingConfirmationTakesTheFlagAway() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NEED_CONFIRM_JSON);
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()),
"回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单");
}
@Test
@DisplayName("回单不要求业务员确认:末位卡仍带完成标记")
void returnWithoutConfirmationKeepsTheFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NO_CONFIRM_JSON);
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()));
}
@Test
@DisplayName("末位节点不是到货/卸货/签收:不带完成标记")
void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON);
assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()),
"到场不在到货/卸货/签收之列,打完不置完成");
}
// ------------------------------------------------------------ 夹具
/** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */
private Map<String, DriverPunchNodeVO> punchNodes(String processJson) {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610180001");
waybill.setProcessJson(processJson);
waybill.setBusinessStatus("running");
waybill.setIsDeleted(0);
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
}
}
@@ -0,0 +1,229 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
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.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.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import java.lang.reflect.Field;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。
* <p>
* 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、
* 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。
*/
class WaybillConfirmReturnTest {
private static final Long WAYBILL_ID = 5701L;
private static final String DEPT_ID = "1123598813738675201";
private static final String RETURN_NEED_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
private static final String RETURN_NO_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
""";
/** 带在途节点:在途是频次打卡节点、不是必经一步,没打不该拦住确认回单 */
private static final String WITH_TRANSIT_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
private WaybillServiceImpl service;
private WaybillMapper waybillMapper;
private WaybillNodePunchMapper nodePunchMapper;
private ExceptionDisposalMapper exceptionDisposalMapper;
private ILoadingManageService loadingManageService;
private IReceivablePayableDetailService receivablePayableDetailService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
loadingManageService = mock(ILoadingManageService.class);
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
IProcessConfigService processConfigService = mock(IProcessConfigService.class);
when(processConfigService.list(org.mockito.ArgumentMatchers.<Wrapper<ProcessConfig>>any())).thenReturn(List.of());
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
inject("waybillNodePunchMapper", nodePunchMapper);
inject("waybillEnroutePunchMapper", mock(WaybillEnroutePunchMapper.class));
inject("exceptionDisposalMapper", exceptionDisposalMapper);
inject("processConfigService", processConfigService);
inject("loadingManageService", loadingManageService);
inject("receivablePayableDetailService", receivablePayableDetailService);
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectCount(any())).thenReturn(0L);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单")
void confirmsReturnAndCompletesWaybill() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load", "sign");
boolean result = service.confirmReturnReceipt(WAYBILL_ID);
assertEquals(true, result);
verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.<Waybill>argThat(waybill ->
"completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId())));
verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID));
verify(loadingManageService).completeIfAllWaybillsCompleted(any());
}
@Test
@DisplayName("在途节点没打卡也放行:它不是必经一步")
void transitNodeDoesNotBlockConfirmation() {
stubWaybill(WITH_TRANSIT_JSON);
stubPunches("load", "sign");
assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID));
}
@Test
@DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态")
void unfinishedPunchNodeBlocksConfirmation() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load");
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单还有未打卡的节点:签收", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
@Test
@DisplayName("有未了结异常:拒绝,不改状态")
void unresolvedExceptionBlocksConfirmation() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load", "sign");
when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单存在未处理异常,处理完成后才能确认回单", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
@Test
@DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成")
void returnWithoutConfirmationIsRejected() {
stubWaybill(RETURN_NO_CONFIRM_JSON);
stubPunches("load", "sign");
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单的回单不需要业务员确认", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
// ------------------------------------------------------------ 夹具
private void stubWaybill(String processJson) {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610180002");
waybill.setDeptId(Long.valueOf(DEPT_ID));
waybill.setProjectId(7001L);
waybill.setProcessJson(processJson);
waybill.setBusinessStatus("running");
waybill.setLoadingNo("PZ-20261018-001");
waybill.setIsDeleted(0);
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
}
private void stubPunches(String... nodeCodes) {
List<WaybillNodePunch> punches = java.util.Arrays.stream(nodeCodes).map(code -> {
WaybillNodePunch punch = new WaybillNodePunch();
punch.setWaybillId(WAYBILL_ID);
punch.setNodeCode(code);
return punch;
}).toList();
when(nodePunchMapper.selectList(any())).thenReturn(punches);
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
@SuppressWarnings("unused")
private static Wrapper<Waybill> anyWaybillWrapper() {
return org.mockito.ArgumentMatchers.any();
}
}