✨ 末位打卡卡上完成运单,后台补「确认回单」入口
司机端打卡页的完成动作从底栏挪到末位卡上:节点下发时按与 submitNode 自动完成同一份判据 (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认)给出「打这张卡即完成运单」, 客户端据此把按钮文案改成「完成运单」。两处判据同源,否则会出现「按钮写着完成运单、 打下去这张运单没完成」。 回单节点配成「需业务员确认」时,司机打完末位卡不置完成,此前全仓没有任何入口让业务员确认后置完成, 运单只能人工去后台点完成。新增 POST /waybill-manage/confirm-return:校验该运单的回单确实要求确认、 司机打卡节点(在途除外——它是频次打卡节点、不是必经一步)全部已打卡、无未了结异常,再走既有的 完成逻辑(改状态 + 生成应收应付 + 尝试完成配载单)。完成接口本身不要求节点打卡,不校验这两件事 就等于借这个入口绕过打卡门禁与异常门禁。本轮不做撤销。 新增 DriverPunchFinishFlagTest(4 项)与 WaybillConfirmReturnTest(5 项); blade-transport 全量 385 项用例通过。
This commit is contained in:
+3
@@ -70,6 +70,9 @@ public class DriverPunchNodeVO implements Serializable {
|
||||
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
|
||||
private Boolean visible;
|
||||
|
||||
@Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单")
|
||||
private Boolean finishOnPunch;
|
||||
|
||||
@Schema(description = "是否默认展开(仅第一个可打卡节点)")
|
||||
private Boolean defaultExpanded;
|
||||
|
||||
|
||||
+7
@@ -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")
|
||||
|
||||
+8
@@ -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);
|
||||
|
||||
|
||||
+6
@@ -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();
|
||||
|
||||
+61
@@ -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,并检查生成应收应付明细。
|
||||
*/
|
||||
|
||||
+29
@@ -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"));
|
||||
|
||||
+163
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
+229
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user