diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 3870cef5..e90dfe99 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -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 + * 查询条件表达不了跨行约束,只能整批取回后在内存里比。 + * + * @param waybills 本次要写入的子运单全集 + */ + private void validateSameProject(List waybills) { + Long projectId = waybills.get(0).getProjectId(); + List 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 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 BaseServiceImpllambdaQuery() + return contractManageService.list(availableCarrierContractQuery() + .in(ContractManage::getProjectId, projectIds) + .orderByDesc(ContractManage::getCreateTime)); + } + + /** + * 按主键取一张可用的承运商合同。 + *

+ * 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。 + * + * @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 availableCarrierContractQuery() { + return Wrappers.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")); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java index 202c2644..ea148e4d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java @@ -107,6 +107,24 @@ public final class LoadableAdmissionSupport { return loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo)); } + /** + * 该运单是否已挂在本配载单上。 + *

+ * 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判: + * 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。 + * + * @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())); + } + /** * 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载 + 非拒单。 * diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java new file mode 100644 index 00000000..0eebe79c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -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; + +/** + * 配载提交/编辑/重新派单的准入校验:落库前的一处判据决定这批运单能不能被配载。 + *

+ * 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据, + * 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。 + *

+ * 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。 + */ +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; + + @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 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 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 anyWaybillWrapper() { + return ArgumentMatchers.>any(); + } + + private static Wrapper anyContractWrapper() { + return ArgumentMatchers.>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); + } + +}