🔀 合并读侧可配载判据与写侧准入校验、子运单状态推导

冲突仅限 LoadingManageServiceImpl 的 import 段:读侧判据分支引入 LiveProcessConfigSupport、
写侧准入分支引入 LoadableAdmissionSupport,两处按字母序并存。
This commit is contained in:
2026-10-18 14:36:57 +08:00
3 changed files with 452 additions and 13 deletions
@@ -31,6 +31,7 @@ import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.LoadableAdmissionSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.LoadingManageWrapper;
@@ -751,16 +752,61 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (waybills.size() != waybillIds.size()) {
throw new ServiceException("待配载运单不存在或已删除");
}
String ownLoadingNo = loadingManage.getLoadingNo();
for (Waybill waybill : waybills) {
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
// 本单既有子运单在先前提交时已过准入判据,重新提交/编辑不再判:
// 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了
if (LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
continue;
}
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
if (!LoadableAdmissionSupport.isLoadable(waybill, ownLoadingNo)) {
throw new ServiceException(unloadableReason(waybill));
}
}
validateSameProject(waybills);
}
/**
* 逐张准入失败的可读原因。判据本身在可配载支持类里,这里只把命中的那一条翻成文案。
*
* @param waybill 未通过准入判据的运单
* @return 含运单号与原因的提示
*/
private String unloadableReason(Waybill waybill) {
String prefix = "运单" + waybill.getWaybillNo();
if (Func.isNotEmpty(waybill.getLoadingNo())) {
return prefix + "已关联其他配载单";
}
if (!LoadableAdmissionSupport.isRoadTransport(waybill.getTransportType())) {
return prefix + "运输方式不是公路,不可配载";
}
if (!LoadableAdmissionSupport.LOADABLE_BUSINESS_STATUS.equals(waybill.getBusinessStatus())) {
return prefix + "当前状态不是待执行,不可配载";
}
if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) {
return prefix + "已被司机拒绝接单,不可配载";
}
return prefix + "不可配载";
}
/**
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
* <p>
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
*
* @param waybills 本次要写入的子运单全集
*/
private void validateSameProject(List<Waybill> waybills) {
Long projectId = waybills.get(0).getProjectId();
List<String> others = waybills.stream()
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
.map(Waybill::getWaybillNo)
.toList();
if (Func.isNotEmpty(others)) {
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
+ String.join("、", others) + "与其它子运单不属于同一项目");
}
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
@@ -778,10 +824,14 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCarrierName(null);
return;
}
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
.findFirst()
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
if (carrierContract == null) {
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
}
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
if (!projectIdsByWaybills(loadingManage).contains(carrierContract.getProjectId())) {
throw new ServiceException("所选承运商合同不属于本项目");
}
loadingManage.setCarrierName(carrierContract.getPartyB());
}
@@ -814,15 +864,37 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (Func.isEmpty(projectIds)) {
return List.of();
}
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
return contractManageService.list(availableCarrierContractQuery()
.in(ContractManage::getProjectId, projectIds)
.orderByDesc(ContractManage::getCreateTime));
}
/**
* 按主键取一张可用的承运商合同。
* <p>
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
*
* @param contractId 合同主键
* @return 可用的合同;不存在或不可用返回 null
*/
private ContractManage availableCarrierContractById(Long contractId) {
if (Func.isEmpty(contractId)) {
return null;
}
return contractManageService.list(availableCarrierContractQuery()
.eq(ContractManage::getId, contractId)
.orderByDesc(ContractManage::getCreateTime))
.stream().findFirst().orElse(null);
}
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
return Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getStatus, 1)
.eq(ContractManage::getContractCategory, "承运商合同")
.in(ContractManage::getProjectId, projectIds)
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime));
.or().ne(ContractManage::getContractStage, "terminated"));
}
/**
@@ -107,6 +107,24 @@ public final class LoadableAdmissionSupport {
return loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo));
}
/**
* 该运单是否已挂在本配载单上。
* <p>
* 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判:
* 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。
*
* @param waybill 运单
* @param ownLoadingNo 本配载单号;新建时传 null
* @return true-已是本单子运单
*/
public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) {
if (waybill == null) {
return false;
}
String own = text(ownLoadingNo);
return !own.isEmpty() && own.equals(text(waybill.getLoadingNo()));
}
/**
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载 + 非拒单。
*
@@ -0,0 +1,349 @@
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.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.LoadingManageMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import java.lang.reflect.Field;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
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>
* 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据,
* 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。
* <p>
* 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。
*/
class LoadingManageAdmissionTest {
private static final Long DEPT_ID = 1001L;
private static final Long PROJECT_A = 2001L;
private static final Long PROJECT_B = 2002L;
private static final Long CONTRACT_ID = 3001L;
private static final Long OTHER_CONTRACT_ID = 3002L;
private static final Long OWN_LOADING_ID = 9001L;
private static final String OWN_LOADING_NO = "PZ20261018-001";
private static final String WAYBILL_NO_A = "YD202610180001";
private static final String WAYBILL_NO_B = "YD202610180002";
private LoadingManageServiceImpl service;
private WaybillMapper waybillMapper;
private LoadingManageMapper loadingManageMapper;
private IContractManageService contractManageService;
private MockedStatic<AuthUtil> authUtil;
@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, LoadingManage.class);
TableInfoHelper.initTableInfo(assistant, ContractManage.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
loadingManageMapper = mock(LoadingManageMapper.class);
contractManageService = mock(IContractManageService.class);
service = new LoadingManageServiceImpl();
inject("baseMapper", loadingManageMapper);
inject("waybillMapper", waybillMapper);
inject("contractManageService", contractManageService);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
}
@AfterEach
void tearDown() {
authUtil.close();
}
// ------------------------------------------------------------ 逐张准入
@Test
@DisplayName("非公路运单被拒,错误含运单号与原因")
void nonRoadWaybillRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", null, PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
}
@Test
@DisplayName("状态不是待执行一律被拒:进行中、已完成、已取消、草稿")
void nonPendingStatusRejected() {
for (String status : List.of("running", "completed", "cancelled", "draft")) {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", status, null, PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))), status);
assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage(), status);
}
}
@Test
@DisplayName("已挂在其它的配载单上的运单被拒")
void waybillOnAnotherManifestRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", "PZ20261018-999", PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "已关联其他配载单", exception.getMessage());
}
@Test
@DisplayName("已拒单的运单被拒:接单事实由配载写入路径清除,不靠放行拒单票")
void rejectedWaybillRejected() {
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A);
rejected.setDriverAcceptStatus("rejected");
stubWaybills(rejected);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "已被司机拒绝接单,不可配载", exception.getMessage());
}
// ------------------------------------------------------------ 本单既有子运单
@Test
@DisplayName("编辑本单:子运单挂的就是本单号,不算冲突")
void editingOwnChildrenPasses() throws Exception {
stubExistingManifest();
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.submit(payload));
}
@Test
@DisplayName("不需接单项目的子运单提交后即为进行中;重新提交本单不得因此被挡")
void editingOwnRunningChildrenPasses() throws Exception {
stubExistingManifest();
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.submit(payload));
}
@Test
@DisplayName("本单既有子运单放行,但同一次请求新塞进来的进行中运单仍被拒")
void newlyAddedRunningWaybillIsStillRejected() throws Exception {
stubExistingManifest();
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "road", "running", null, PROJECT_A));
LoadingManage payload = submitPayload(List.of(1L, 2L));
payload.setId(OWN_LOADING_ID);
ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(payload));
assertEquals("运单" + WAYBILL_NO_B + "当前状态不是待执行,不可配载", exception.getMessage());
}
@Test
@DisplayName("重新派单:本单被拒的子运单仍挂在本单号上,不因拒单被挡——重新派单本就是为它而设")
void reassignKeepsRejectedOwnChild() throws Exception {
stubExistingManifest();
when(waybillMapper.selectCount(anyWaybillWrapper())).thenReturn(1L);
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A);
rejected.setDriverAcceptStatus("rejected");
stubWaybills(rejected);
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.reassign(payload));
}
// ------------------------------------------------------------ 跨行判据
@Test
@DisplayName("跨项目的子运单集合被拒,错误点名与其它子运单不同项目的运单号")
void crossProjectChildrenRejected() {
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_B));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L, 2L))));
assertEquals("配载单的子运单必须属于同一项目,运单" + WAYBILL_NO_B + "与其它子运单不属于同一项目",
exception.getMessage());
}
@Test
@DisplayName("合同不属于本项目:与本项目无可选合同区分开")
void contractOfAnotherProjectRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_B);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("所选承运商合同不属于本项目", exception.getMessage());
}
@Test
@DisplayName("合同本身不可用(未审核通过或已失效)仍报原有文案,不与「不属于本项目」混同")
void unavailableContractKeepsExistingMessage() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of());
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage());
}
// ------------------------------------------------------------ 放行
@Test
@DisplayName("公路 + 待执行 + 未配载 + 同项目 + 合同属于本项目:通过并带出合同乙方")
void legalRequestPasses() {
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "公路运输", "pending", "", PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L, 2L));
assertDoesNotThrow(() -> service.submit(payload));
assertEquals("甲承运商", payload.getCarrierName());
}
@Test
@DisplayName("暂存不受准入校验影响:跨项目、非公路、进行中的票照旧落草稿")
void draftIsNotGated() {
LoadingManage draft = new LoadingManage();
draft.setDeptId(DEPT_ID);
draft.setLoadingSubNos(WAYBILL_NO_A);
draft.setCarrierType("自运");
draft.setWaybillIdsJson(waybillIdsJson(List.of(1L, 2L)));
assertDoesNotThrow(() -> service.saveDraft(draft));
verify(waybillMapper, never()).selectList(anyWaybillWrapper());
}
// ------------------------------------------------------------ 夹具
private Waybill waybill(Long id, String waybillNo, String transportType, String businessStatus,
String loadingNo, Long projectId) {
Waybill waybill = new Waybill();
waybill.setId(id);
waybill.setWaybillNo(waybillNo);
waybill.setTransportType(transportType);
waybill.setBusinessStatus(businessStatus);
waybill.setLoadingNo(loadingNo);
waybill.setProjectId(projectId);
waybill.setDeptId(DEPT_ID);
waybill.setIsDeleted(0);
return waybill;
}
private LoadingManage submitPayload(List<Long> waybillIdList) {
LoadingManage loadingManage = new LoadingManage();
loadingManage.setDeptId(DEPT_ID);
loadingManage.setLoadingSubNos(WAYBILL_NO_A);
loadingManage.setTransportType("road");
loadingManage.setCarrierType("承运商");
loadingManage.setCarrierContractId(CONTRACT_ID);
loadingManage.setVehicleNo("苏A12345");
loadingManage.setDepartureAddress("南京");
loadingManage.setArrivalAddress("上海");
loadingManage.setWaybillIdsJson(waybillIdsJson(waybillIdList));
return loadingManage;
}
private static String waybillIdsJson(List<Long> waybillIdList) {
return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]"));
}
private void stubExistingManifest() {
LoadingManage existing = new LoadingManage();
existing.setId(OWN_LOADING_ID);
existing.setLoadingNo(OWN_LOADING_NO);
existing.setDeptId(DEPT_ID);
existing.setBusinessStatus("pending");
when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing);
}
private void stubWaybills(Waybill... waybills) {
when(waybillMapper.selectList(anyWaybillWrapper())).thenReturn(List.of(waybills));
}
private void stubContract(Long contractId, Long projectId) {
ContractManage contract = new ContractManage();
contract.setId(contractId);
contract.setProjectId(projectId);
contract.setPartyB(contractId.equals(OTHER_CONTRACT_ID) ? "乙承运商" : "甲承运商");
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of(contract));
}
private static Wrapper<Waybill> anyWaybillWrapper() {
return ArgumentMatchers.<Wrapper<Waybill>>any();
}
private static Wrapper<ContractManage> anyContractWrapper() {
return ArgumentMatchers.<Wrapper<ContractManage>>any();
}
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);
}
}