diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 691cb573..d5d10b60 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -52,4 +52,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "本次打卡是否使运单自动完成") private Boolean waybillCompleted; + @Schema(description = "本次打卡后的运单业务状态:running / pending_return_confirm / completed") + private String waybillBusinessStatus; + } 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 cec6c927..16466128 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 @@ -520,19 +520,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // 末位打卡节点是到货/卸货/签收、且运单本身允许完成时,本次打卡就是运单的去向决定点: + // 回单无需确认 → 一并完成;回单需业务员确认 → 停在待回单确认等业务员确认。 // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + String waybillBusinessStatus = waybill.getBusinessStatus(); boolean completed = false; if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) - && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) && canAutoCompleteWaybill(waybill)) { - completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + if (completed) { + waybillBusinessStatus = STATUS_COMPLETED; + } + } else if (WaybillProcessSupport.STATUS_RUNNING.equals(waybill.getBusinessStatus())) { + // 只有进行中能进入:canAutoCompleteWaybill 的状态口径含待执行/待调度等,不能替代这一条; + // 待回单确认在 isPunchEnded 里算已结束,不能从结束态重进。 + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + waybillService.updateById(waybill); + waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; + } } DriverNodePunchVO vo = toNodePunchVO(punch); vo.setWaybillCompleted(completed); + vo.setWaybillBusinessStatus(waybillBusinessStatus); return vo; } 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 9f40356f..b934d41a 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 @@ -531,6 +531,12 @@ public class WaybillServiceImpl extends BaseServiceImpl } // 只有显式传 false 才是取消打卡,未传按已打卡处理 boolean punched = !Boolean.FALSE.equals(request.getPunched()); + // 待回单确认的运单司机已无待打卡节点,再补一条打卡流水与状态含义矛盾; + // 漏打的出路是先取消该节点的打卡退回进行中(punched=false),再重新补录。 + if (punched && WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM + .equalsIgnoreCase(waybill.getBusinessStatus())) { + throw new ServiceException("运单待业务员确认回单,不能补录打卡"); + } if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { return saveEnroutePunchRecord(waybill, request, punched); } @@ -1062,10 +1068,11 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setMasterNo(null); waybill.setMileageRemark(null); } - // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致 + // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致; + // 编辑保存只改资料,business_status 以库里的值为准,请求体里的值不回写 String preservedBusinessStatus = created ? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()) - : null; + : editPreservedBusinessStatus(oldRecord); if (isCompletedBusinessStatus(preservedBusinessStatus)) { preservedBusinessStatus = "completed"; } @@ -1087,6 +1094,17 @@ public class WaybillServiceImpl extends BaseServiceImpl } } + /** + * 编辑保存要回写的业务状态:取库里的值。 + * 草稿返回 null —— submit 即正式提交,草稿须由过程校正提升为待执行 / 进行中。 + */ + private String editPreservedBusinessStatus(Waybill oldRecord) { + String currentStatus = oldRecord == null + ? null + : TransportBusinessSupport.trimToNull(oldRecord.getBusinessStatus()); + return STATUS_DRAFT.equals(currentStatus) ? null : currentStatus; + } + private void fillCustomerName(Waybill waybill) { if (Func.isNotEmpty(waybill.getContractId())) { ContractManage contract = contractManageService.getById(waybill.getContractId()); 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 4462c3a3..3b9d920e 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 @@ -49,6 +49,8 @@ public final class WaybillProcessSupport { public static final String STATUS_PENDING = "pending"; public static final String STATUS_RUNNING = "running"; + /** 司机已打完末位卡、回单需业务员确认,尚未生成应收应付 */ + public static final String STATUS_PENDING_RETURN_CONFIRM = "pending_return_confirm"; public static final String ACCEPT_PENDING = "pending"; public static final String ACCEPT_ACCEPTED = "accepted"; public static final String ACCEPT_REJECTED = "rejected"; @@ -134,14 +136,16 @@ public final class WaybillProcessSupport { } /** - * 运单已结束:已完成或已取消。此后不再接受司机打卡。 + * 运单已结束:已完成、已取消,或已打完末位卡停在待回单确认。此后不再接受司机打卡。 *

- * 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, + * 只认这三个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, * 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。 */ public static boolean isPunchEnded(String businessStatus) { String status = stringVal(businessStatus); - return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status); + return "completed".equalsIgnoreCase(status) + || "cancelled".equalsIgnoreCase(status) + || STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status); } /** @@ -155,6 +159,9 @@ public final class WaybillProcessSupport { if ("cancelled".equalsIgnoreCase(status)) { return "运单已取消"; } + if (STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status)) { + return "运单待业务员确认回单"; + } return null; } @@ -195,7 +202,7 @@ public final class WaybillProcessSupport { * 上报异常前的运单状态门禁原因;可上报时返回 null。 *

* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。 - * 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。 + * 结束优先于未开始:已结束的运单先说结束,不再提「等你上路」。 */ public static String reportBlockedReason(String businessStatus) { String endedReason = punchEndedReason(businessStatus); @@ -213,7 +220,8 @@ public final class WaybillProcessSupport { || "completed".equals(businessStatus) || "cancelled".equals(businessStatus) || "waiting_dispatch".equals(businessStatus) - || "dispatching".equals(businessStatus); + || "dispatching".equals(businessStatus) + || STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus); } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index f2eeac76..3c1f83fe 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -7,6 +7,7 @@ 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.ArgumentCaptor; import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; @@ -28,6 +29,7 @@ import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -35,6 +37,7 @@ 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.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -42,11 +45,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 司机打完末位打卡节点后运单自动完成。 + * 司机打完末位打卡节点后运单的去向。 *

- * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 - * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, - * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + * 末位打卡是到货/卸货/签收、运单本身允许完成时,回单是否需业务员确认决定去向: + * 无需确认则本次一并完成运单,需确认则把运单置为待回单确认等业务员动手。 + * 任一条件不成立都只落打卡流水。 + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」; + * 待回单确认不是已完成,只能靠 waybillBusinessStatus 分辨。 */ class DriverWaybillAutoCompleteTest { @@ -72,6 +77,12 @@ class DriverWaybillAutoCompleteTest { [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] """; + /** 末位司机打卡节点是「发货」,回单启用且需确认完成 */ + private static final String DISPATCH_LAST_RETURN_CONFIRM_JSON = """ + [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + private IWaybillService waybillService; private IDriverService driverService; private WaybillNodePunchMapper nodePunchMapper; @@ -118,7 +129,7 @@ class DriverWaybillAutoCompleteTest { // ------------------------------------------------------------ 命中 @Test - @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted 与 completed") void lastDeliveryPunchCompletesWaybill() { givenWaybill(SIGN_LAST_JSON, "running"); when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); @@ -127,6 +138,7 @@ class DriverWaybillAutoCompleteTest { verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + assertEquals("completed", vo.getWaybillBusinessStatus(), "完成后运单业务状态要如实回报"); } @Test @@ -139,6 +151,25 @@ class DriverWaybillAutoCompleteTest { verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + assertEquals("running", vo.getWaybillBusinessStatus(), "完成没落库,运单仍在进行中"); + } + + // ------------------------------------------------------------ 待回单确认 + + @Test + @DisplayName("回单需业务员确认时末位打卡进入待回单确认,落库并回报,且不调完成接口") + void returnNeedingConfirmationEntersPendingReturnConfirm() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted(), "待回单确认不是已完成"); + assertEquals("pending_return_confirm", vo.getWaybillBusinessStatus(), + "客户端要知道打卡已提交、等业务员确认回单"); + assertEquals("pending_return_confirm", writtenBusinessStatus(), + "状态要落库,否则下次查询又回到进行中"); } // ------------------------------------------------------------ 不命中 @@ -153,18 +184,31 @@ class DriverWaybillAutoCompleteTest { verify(waybillService, never()).completeWithoutDeptCheck(any()); verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); assertFalse(vo.getWaybillCompleted()); + assertEquals("running", vo.getWaybillBusinessStatus()); } @Test - @DisplayName("回单启用且需确认完成时只打卡不完成") - void returnNeedingConfirmationDoesNotComplete() { + @DisplayName("回单需确认但不是末位打卡:运单留在进行中") + void nonLastPunchDoesNotEnterReturnConfirmation() { givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); - DriverNodePunchVO vo = submit("sign"); + DriverNodePunchVO vo = submit("load"); + + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertEquals("running", vo.getWaybillBusinessStatus(), "后面还有签收要打,不能提前停在待回单确认"); + assertEquals("running", writtenBusinessStatus()); + } + + @Test + @DisplayName("回单需确认但末位不是到货/卸货/签收:运单留在进行中") + void nonDeliveryLastNodeDoesNotEnterReturnConfirmation() { + givenWaybill(DISPATCH_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); verify(waybillService, never()).completeWithoutDeptCheck(any()); - verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); - assertFalse(vo.getWaybillCompleted()); + assertEquals("running", vo.getWaybillBusinessStatus(), "发货不是到货类节点,打完不该停在等待回单"); + assertEquals("running", writtenBusinessStatus()); } // ------------------------------------------------------------ 前置:运单本身能否完成 @@ -208,8 +252,50 @@ class DriverWaybillAutoCompleteTest { assertFalse(vo.getWaybillCompleted()); } + @Test + @DisplayName("有未了结异常时回单需确认也不进待回单确认——四条进入条件共同成立才进") + void unresolvedExceptionBlocksEnteringReturnConfirmation() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + assertEquals("running", vo.getWaybillBusinessStatus(), "异常未了结,不能把运单停在等待回单"); + assertEquals("running", writtenBusinessStatus()); + } + + // ------------------------------------------------------------ 并发:只从进行中进入 + + @Test + @DisplayName("打卡期间运单已被推到终态时不再写待回单确认——只从进行中进入") + void concurrentTerminalStatusPreventsEnteringReturnConfirmation() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + List statusesWritten = new ArrayList<>(); + // 模拟另一事务在这次打卡推进过程节点时把运单置为已完成(管理端强制完成) + when(waybillService.updateById(any())).thenAnswer(invocation -> { + Waybill entity = invocation.getArgument(0); + statusesWritten.add(entity.getBusinessStatus()); + entity.setBusinessStatus("completed"); + return true; + }); + + DriverNodePunchVO vo = submit("sign"); + + assertFalse(statusesWritten.contains("pending_return_confirm"), + "运单已被完成,不能再改写回待回单确认"); + assertEquals("completed", vo.getWaybillBusinessStatus()); + } + // ------------------------------------------------------------ 夹具 + /** 打卡流程最后一次落库时运单的业务状态;状态写库走 updateById,各次传的是同一个实体引用 */ + private String writtenBusinessStatus() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillService, atLeastOnce()).updateById(captor.capture()); + List written = captor.getAllValues(); + return written.get(written.size() - 1).getBusinessStatus(); + } + private DriverNodePunchVO submit(String nodeCode) { NodeSubmitDTO dto = new NodeSubmitDTO(); dto.setWaybillId(WAYBILL_ID); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java new file mode 100644 index 00000000..626d8886 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillEditSaveBusinessStatusTest.java @@ -0,0 +1,198 @@ +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.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.support.WaybillProcessSupport; + +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 运单编辑保存的业务状态来源测试。 + *

+ * 编辑保存只改运单资料,business_status 一律以库里的值为准:请求体不回传(null / 空串) + * 不得把库里的待执行、进行中、已完成改写成别的状态,回传陈旧值也不得覆盖。 + * 唯一例外是草稿——submit 即正式提交,草稿须按过程配置提升为待执行 / 进行中。 + *

+ * 过程配置用「无需确认接单」的快照({@code []}):无过程校正时待执行会被提升为进行中, + * 正是编辑保存最容易把库里状态写坏的场景。 + */ +class WaybillEditSaveBusinessStatusTest { + + private static final Long WAYBILL_ID = 7001L; + private static final Long DEPT_ID = 100L; + /** 无接单节点的过程配置:normalizeBusinessStatus 会把空 / pending 提升为 running */ + private static final String PROCESS_JSON_NO_ACCEPT = "[]"; + + private WaybillMapper waybillMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate 判主键需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 编辑保存:以库里的状态为准 + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的待执行不得被提升为进行中") + void editSaveKeepsPendingWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "pending"); + + assertEquals("pending", request.getBusinessStatus(), + "编辑保存时请求体没有 business_status,不得按过程配置把库里的待执行改写成进行中"); + } + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的进行中不得被改写") + void editSaveKeepsRunningWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "running"); + + assertEquals("running", request.getBusinessStatus()); + } + + @Test + @DisplayName("编辑保存:请求体不回传业务状态,库里的已完成不得被改写") + void editSaveKeepsCompletedWhenRequestBodyOmitsStatus() { + Waybill request = editableRequest(); + + submit(request, "completed"); + + assertEquals("completed", request.getBusinessStatus()); + } + + @Test + @DisplayName("编辑保存:请求体回传陈旧状态,仍以库里的值为准") + void editSaveKeepsDatabaseStatusWhenRequestBodyCarriesStaleValue() { + Waybill request = editableRequest(); + request.setBusinessStatus("running"); + + submit(request, "completed"); + + assertEquals("completed", request.getBusinessStatus(), + "库里已完成,请求体带的是打开表单时的旧值,不得据此回退状态"); + } + + @Test + @DisplayName("编辑保存:请求体回传空串,库里的状态不得被清空") + void editSaveKeepsDatabaseStatusWhenRequestBodyIsBlank() { + Waybill request = editableRequest(); + request.setBusinessStatus(""); + + submit(request, "running"); + + assertEquals("running", request.getBusinessStatus()); + } + + // ------------------------------------------------------------ 新增状态 + + @Test + @DisplayName("编辑保存:待回单确认的运单不得被编辑保存改成进行中") + void editSaveKeepsPendingReturnConfirm() { + Waybill request = editableRequest(); + + submit(request, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, request.getBusinessStatus(), + "司机已打完末位卡,编辑一次运单资料不得把它退回进行中"); + } + + // ------------------------------------------------------------ 草稿例外 + + @Test + @DisplayName("提交草稿:草稿仍按过程配置提升为进行中") + void submitDraftStillPromotesToRunning() { + Waybill request = editableRequest(); + + submit(request, "draft"); + + assertEquals("running", request.getBusinessStatus(), + "submit 即正式提交,草稿必须脱离 draft,不能被「以库里的值为准」锁死"); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill editableRequest() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setProjectName("项目A"); + waybill.setContractName("客户合同A"); + waybill.setCustomerName("客户A"); + waybill.setTransportType("公路运输"); + waybill.setCargoName("钢材"); + waybill.setCargoType("普货"); + waybill.setQuantityUnit("吨"); + waybill.setCarrierType("自运"); + waybill.setVehicleNo("桂A12345"); + waybill.setDriverName("张三"); + waybill.setCarrierJson("{}"); + waybill.setQuantity(BigDecimal.TEN); + waybill.setProcessJson(PROCESS_JSON_NO_ACCEPT); + return waybill; + } + + /** + * 让库里的同一条运单处于 {@code databaseStatus},再走一次编辑保存(submit)。 + * 司机与车牌保持一致,避免接单记录被判定为「换了司机」而清空。 + */ + private void submit(Waybill request, String databaseStatus) { + Waybill oldRecord = editableRequest(); + oldRecord.setBusinessStatus(databaseStatus); + oldRecord.setDeptId(DEPT_ID); + oldRecord.setDeptName("测试组织"); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + assertTrue(service.submit(request)); + } + } + + 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index 6b41a1ef..1778a850 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -18,6 +18,7 @@ import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; 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.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.util.ArrayList; @@ -165,6 +166,59 @@ class WaybillPunchRecordSaveTest { verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); } + @Test + @DisplayName("待回单确认:补录打卡被拒——司机已无待打卡,漏打应先取消打卡退回进行中") + void rejectBackfillOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.savePunchRecord(request)); + + assertEquals("运单待业务员确认回单,不能补录打卡", exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("待回单确认:取消打卡仍放行——那是退回进行中的唯一路径") + void keepCancelPunchOnPendingReturnConfirm() { + Waybill waybill = waybill(); + waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(nodePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(7001L), new HashSet<>(captor.getValue())); + } + + @Test + @DisplayName("已完成运单仍可补录——门禁只对待回单确认生效") + void keepBackfillOnCompletedWaybill() { + Waybill waybill = waybill(); + waybill.setBusinessStatus("completed"); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 09:30:00"); + + assertTrue(service.savePunchRecord(request)); + + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + } + @Test @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") void rejectAcceptNodePunchEdit() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index b09c82c4..9408bfd6 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -334,15 +334,31 @@ class WaybillProcessSupportTest { "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); } + // ------------------------------------------------------------ 待回单确认 + + @Test + @DisplayName("待回单确认落库字面量为 pending_return_confirm,且接单归一不得改写它") + void pendingReturnConfirmIsTerminalStatus() { + assertEquals("pending_return_confirm", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + "库里存的是这个字面量,web 与小程序也按它判断,不能改名"); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON), + "TRANSPORT_JSON 要求确认接单,未接单时归一结果本是 pending;运单已跑完全程,不能被改回待执行"); + assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, + WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON, "accepted")); + } + // ------------------------------------------------------------ 结束门禁 @Test - @DisplayName("结束门禁:只认已完成与已取消,大小写不敏感") - void punchEndedOnlyForCompletedAndCancelled() { + @DisplayName("结束门禁:已完成、已取消与待回单确认都算结束,大小写不敏感") + void punchEndedForCompletedCancelledAndPendingReturnConfirm() { assertTrue(WaybillProcessSupport.isPunchEnded("completed")); assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED")); assertTrue(WaybillProcessSupport.isPunchEnded("cancelled")); assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled")); + assertTrue(WaybillProcessSupport.isPunchEnded("pending_return_confirm"), + "司机已打完末位卡,不能再打卡"); } @Test @@ -358,11 +374,12 @@ class WaybillProcessSupportTest { } @Test - @DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案") + @DisplayName("结束门禁文案:完成、取消与待回单确认各一句,非结束态没有文案") void punchEndedReasonOnlyForEndedStatuses() { assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed")); assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED ")); assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled")); + assertEquals("运单待业务员确认回单", WaybillProcessSupport.punchEndedReason("pending_return_confirm")); assertNull(WaybillProcessSupport.punchEndedReason(null)); assertNull(WaybillProcessSupport.punchEndedReason("")); assertNull(WaybillProcessSupport.punchEndedReason(" ")); @@ -421,6 +438,22 @@ class WaybillProcessSupportTest { // 结束态由结束文案负责,两句话不能抢 assertNull(WaybillProcessSupport.punchNotStartedReason("completed")); assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled")); + assertNull(WaybillProcessSupport.punchNotStartedReason("pending_return_confirm"), + "司机已打完末位卡,不能再被说成「尚未开始运输」"); + } + + @Test + @DisplayName("在途卡:待回单确认的运单与已完成同样不可打卡") + void transitCardOnPendingReturnConfirmWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "pending_return_confirm", null, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机能回看"); + assertFalse(decision.punchEnabled(), "末位卡已打完,在途卡不能再提交"); + assertFalse(decision.doneToday()); + assertFalse(decision.dueToday()); } @Test @@ -455,9 +488,11 @@ class WaybillProcessSupportTest { assertNull(WaybillProcessSupport.reportBlockedReason("running")); assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending")); assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching")); - // 结束优先:已完成/已取消先说结束,不提「等你上路」 + // 结束优先:已结束态先说结束,不提「等你上路」 assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed")); assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled")); + assertEquals("运单待业务员确认回单,不能上报异常", + WaybillProcessSupport.reportBlockedReason("pending_return_confirm")); } }