From 4011577491aa0d703fa23c626240e5d734795192 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 12:14:54 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=9C=AB=E4=BD=8D=E6=89=93?= =?UTF-8?q?=E5=8D=A1=E5=8D=A1=E4=B8=8A=E5=AE=8C=E6=88=90=E8=BF=90=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E5=8F=B0=E8=A1=A5=E3=80=8C=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=9B=9E=E5=8D=95=E3=80=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 司机端打卡页的完成动作从底栏挪到末位卡上:节点下发时按与 submitNode 自动完成同一份判据 (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认)给出「打这张卡即完成运单」, 客户端据此把按钮文案改成「完成运单」。两处判据同源,否则会出现「按钮写着完成运单、 打下去这张运单没完成」。 回单节点配成「需业务员确认」时,司机打完末位卡不置完成,此前全仓没有任何入口让业务员确认后置完成, 运单只能人工去后台点完成。新增 POST /waybill-manage/confirm-return:校验该运单的回单确实要求确认、 司机打卡节点(在途除外——它是频次打卡节点、不是必经一步)全部已打卡、无未了结异常,再走既有的 完成逻辑(改状态 + 生成应收应付 + 尝试完成配载单)。完成接口本身不要求节点打卡,不校验这两件事 就等于借这个入口绕过打卡门禁与异常门禁。本轮不做撤销。 新增 DriverPunchFinishFlagTest(4 项)与 WaybillConfirmReturnTest(5 项); blade-transport 全量 385 项用例通过。 --- .../transport/pojo/vo/DriverPunchNodeVO.java | 3 + .../controller/WaybillController.java | 7 + .../transport/service/IWaybillService.java | 8 + .../impl/DriverWaybillServiceImpl.java | 6 + .../service/impl/WaybillServiceImpl.java | 61 +++++ .../support/WaybillProcessSupport.java | 29 +++ .../impl/DriverPunchFinishFlagTest.java | 163 +++++++++++++ .../impl/WaybillConfirmReturnTest.java | 229 ++++++++++++++++++ 8 files changed, 506 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index 03f85510..d7860f6e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -70,6 +70,9 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; + @Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单") + private Boolean finishOnPunch; + @Schema(description = "是否默认展开(仅第一个可打卡节点)") private Boolean defaultExpanded; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java index 82d39780..30af8573 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java @@ -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") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index 8a5abe66..d43242fd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService { */ boolean completeWithoutDeptCheck(Long id); + /** + * 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。 + *

+ * 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡, + * 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。 + */ + boolean confirmReturnReceipt(Long id); + BusinessRemoveResultVO batchComplete(String ids); LoadingManageVO roadLoading(String ids); 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 6a04ed30..4abe0be3 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 @@ -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(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index f78a0512..3b242698 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -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 @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 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 missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId)); + if (!missing.isEmpty()) { + throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing)); + } + } + + /** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */ + private Set punchedNodeKeys(Long waybillId) { + Set keys = new LinkedHashSet<>(); + List punches = waybillNodePunchMapper.selectList(Wrappers.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.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,并检查生成应收应付明细。 */ 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 8de4c8b9..bb508f05 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 @@ -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; } + /** + * 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则 + * 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。 + *

+ * 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。 + * + * @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点) + */ + public static List missingPunchNodeNames(String processJson, Collection punchedKeys) { + List> punchConfigs = listDriverPunchNodes(processJson); + if (punchConfigs.isEmpty()) { + return Collections.emptyList(); + } + Collection punched = punchedKeys == null ? Collections.emptySet() : punchedKeys; + List missing = new ArrayList<>(); + for (Map 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 node) { String key = stringVal(node.get("key")); String name = stringVal(node.get("name")); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java new file mode 100644 index 00000000..daecf3cf --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java @@ -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; + +/** + * 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。 + *

+ * 判据必须与 {@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 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 nodes = punchNodes(RETURN_NEED_CONFIRM_JSON); + + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), + "回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单"); + } + + @Test + @DisplayName("回单不要求业务员确认:末位卡仍带完成标记") + void returnWithoutConfirmationKeepsTheFlag() { + Map nodes = punchNodes(RETURN_NO_CONFIRM_JSON); + + assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch())); + } + + @Test + @DisplayName("末位节点不是到货/卸货/签收:不带完成标记") + void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() { + Map nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON); + + assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()), + "到场不在到货/卸货/签收之列,打完不置完成"); + } + + // ------------------------------------------------------------ 夹具 + + /** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */ + private Map 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())); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java new file mode 100644 index 00000000..3cfe106f --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java @@ -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; + +/** + * 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。 + *

+ * 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、 + * 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。 + */ +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; + + @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.>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.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 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 anyWaybillWrapper() { + return org.mockito.ArgumentMatchers.any(); + } + +}