✨ 司机打完末位到货卸货签收节点且回单无需确认时自动完成运单

This commit is contained in:
2026-10-08 09:24:30 +08:00
parent 9f8ea5412d
commit 7c488b6f09
3 changed files with 279 additions and 1 deletions
@@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable {
@Schema(description = "数量")
private String quantity;
@Schema(description = "本次打卡是否使运单自动完成")
private Boolean waybillCompleted;
}
@@ -360,7 +360,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
nodePunchMapper.insert(punch);
advanceCurrentProcessNode(waybill, nodeCfg);
return toNodePunchVO(punch);
// 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成;
// 判定与找节点共用上面已解析的那份过程配置,不重新解析。
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
String nodeName = WaybillProcessSupport.nodeName(nodeCfg);
boolean completed = false;
if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey)
&& WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName)
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson)
&& canAutoCompleteWaybill(waybill)) {
completed = waybillService.completeWithoutDeptCheck(waybill.getId());
}
DriverNodePunchVO vo = toNodePunchVO(punch);
vo.setWaybillCompleted(completed);
return vo;
}
@Override
@@ -1022,6 +1035,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return countByType;
}
/**
* 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。
* <p>
* 不满足返回 false,由调用方静默跳过,打卡不受影响。
*/
private boolean canAutoCompleteWaybill(Waybill waybill) {
String status = waybill.getBusinessStatus();
boolean statusAllowed = STATUS_RUNNING.equals(status)
|| STATUS_PENDING.equals(status)
|| STATUS_WAITING_DISPATCH.equals(status)
|| STATUS_DISPATCHING.equals(status);
return statusAllowed && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty();
}
/**
* 断言该运单没有未了结的异常。
* <p>
@@ -0,0 +1,248 @@
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.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
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.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机打完末位打卡节点后运单自动完成。
* <p>
* 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、
* 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水,
* 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。
*/
class DriverWaybillAutoCompleteTest {
private static final Long WAYBILL_ID = 5801L;
private static final Long USER_ID = 7001L;
private static final String PLATE = "桂AAW3255";
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
/** 末位打卡节点是「签收」,没有回单节点 */
private static final String SIGN_LAST_JSON = """
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
""";
/** 末位打卡节点是「签收」,回单启用且需确认完成 */
private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
/** 末位打卡节点是「发货」,不是到货/卸货/签收 */
private static final String DISPATCH_LAST_JSON = """
[{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}]
""";
private IWaybillService waybillService;
private IDriverService driverService;
private WaybillNodePunchMapper nodePunchMapper;
private ExceptionDisposalMapper exceptionDisposalMapper;
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, Waybill.class);
TableInfoHelper.initTableInfo(assistant, Driver.class);
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
driverService = mock(IDriverService.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
nodePunchMapper,
exceptionDisposalMapper);
when(driverService.getOne(any())).thenReturn(driver());
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(waybillService.updateById(any())).thenReturn(true);
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1);
}
// ------------------------------------------------------------ 命中
@Test
@DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted")
void lastDeliveryPunchCompletesWaybill() {
givenWaybill(SIGN_LAST_JSON, "running");
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
DriverNodePunchVO vo = submit("sign");
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成");
}
@Test
@DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断")
void completionFlagFollowsServiceReturn() {
givenWaybill(SIGN_LAST_JSON, "running");
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false);
DriverNodePunchVO vo = submit("sign");
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成");
}
// ------------------------------------------------------------ 不命中
@Test
@DisplayName("末位是发货时只打卡不完成")
void punchOnNonDeliveryLastNodeDoesNotComplete() {
givenWaybill(DISPATCH_LAST_JSON, "running");
DriverNodePunchVO vo = submit("dispatch");
verify(waybillService, never()).completeWithoutDeptCheck(any());
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
assertFalse(vo.getWaybillCompleted());
}
@Test
@DisplayName("回单启用且需确认完成时只打卡不完成")
void returnNeedingConfirmationDoesNotComplete() {
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());
}
// ------------------------------------------------------------ 前置:运单本身能否完成
@Test
@DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功")
void draftWaybillIsPunchedButNotCompleted() {
givenWaybill(SIGN_LAST_JSON, "draft");
DriverNodePunchVO vo = submit("sign");
verify(waybillService, never()).completeWithoutDeptCheck(any());
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
assertFalse(vo.getWaybillCompleted());
}
@Test
@DisplayName("待执行与调度中间态同样允许自动完成")
void dispatchableStatusesStillComplete() {
for (String status : List.of("pending", "waiting_dispatch", "dispatching")) {
givenWaybill(SIGN_LAST_JSON, status);
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
DriverNodePunchVO vo = submit("sign");
assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态");
}
}
@Test
@DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦")
void unresolvedExceptionBlocksAutoComplete() {
givenWaybill(SIGN_LAST_JSON, "running");
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload")));
DriverNodePunchVO vo = submit("sign");
verify(waybillService, never()).completeWithoutDeptCheck(any());
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
assertFalse(vo.getWaybillCompleted());
}
// ------------------------------------------------------------ 夹具
private DriverNodePunchVO submit(String nodeCode) {
NodeSubmitDTO dto = new NodeSubmitDTO();
dto.setWaybillId(WAYBILL_ID);
dto.setNodeCode(nodeCode);
dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO)));
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
return service.submitNode(dto);
}
}
private void givenWaybill(String processJson, String businessStatus) {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610080001");
waybill.setVehicleNo(PLATE);
waybill.setProcessJson(processJson);
waybill.setBusinessStatus(businessStatus);
waybill.setIsDeleted(0);
when(waybillService.getOne(any())).thenReturn(waybill);
}
private PunchPhotoDTO photo(String type, String url) {
PunchPhotoDTO photo = new PunchPhotoDTO();
photo.setType(type);
photo.setUrl(url);
return photo;
}
private Driver driver() {
Driver driver = new Driver();
driver.setId(9001L);
driver.setUserId(USER_ID);
driver.setDrivingVehicle(PLATE);
return driver;
}
private ExceptionDisposal unresolvedException(String nodeCode) {
ExceptionDisposal exception = new ExceptionDisposal();
exception.setNodeCode(nodeCode);
exception.setDisposalStatus("processing");
return exception;
}
}