✨ 运单查询新增可配载开关与按主键回查

This commit is contained in:
2026-10-18 14:03:41 +08:00
parent 4011577491
commit 543a690902
5 changed files with 425 additions and 0 deletions
@@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.Waybill;
import java.io.Serial;
import java.util.List;
/**
* 运单管理视图实体类
@@ -78,4 +79,12 @@ public class WaybillVO extends Waybill {
@Schema(description = "是否仅查询未配载运单")
private Integer onlyUnassignedLoading;
@TableField(exist = false)
@Schema(description = "是否仅查询可配载运单")
private Integer loadableOnly;
@TableField(exist = false)
@Schema(description = "运单主键集合")
private List<Long> ids;
}
@@ -68,6 +68,7 @@ import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IWaybillService;
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.WaybillWrapper;
@@ -1735,6 +1736,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
if (Objects.equals(waybill.getOnlyUnassignedLoading(), 1)) {
queryWrapper.and(wrapper -> wrapper.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
if (Func.isNotEmpty(waybill.getIds())) {
queryWrapper.in(Waybill::getId, waybill.getIds());
}
if (Objects.equals(waybill.getLoadableOnly(), 1)) {
LoadableAdmissionSupport.applyLoadableConditions(queryWrapper);
}
if (Func.isNotEmpty(waybill.getBatchNo())) {
queryWrapper.like(Waybill::getBatchNo, waybill.getBatchNo());
}
@@ -0,0 +1,144 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springblade.transport.pojo.entity.Waybill;
import java.util.List;
import java.util.Locale;
/**
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单 + 未拒单。
* <p>
* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。
*/
public final class LoadableAdmissionSupport {
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
/**
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
* <p>
* 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值,
* 因此词表写在代码里,不从字典取。
*/
public static final List<String> ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway");
/** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */
public static final List<String> ROAD_TRANSPORT_CODES = List.of("gl");
private LoadableAdmissionSupport() {
}
/**
* 是否公路运输。未知或为空一律按非公路处理。
*
* @param transportType 运单的运输方式取值
* @return true-公路
*/
public static boolean isRoadTransport(String transportType) {
String value = text(transportType).toLowerCase(Locale.ROOT);
if (value.isEmpty()) {
return false;
}
if (ROAD_TRANSPORT_CODES.contains(value)) {
return true;
}
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
if (value.contains(keyword)) {
return true;
}
}
return false;
}
/**
* 该运单此刻是否可被配载:要求未关联任何配载单。
*
* @param waybill 运单
* @return true-可配载
*/
public static boolean isLoadable(Waybill waybill) {
return isLoadable(waybill, null);
}
/**
* 该运单此刻是否可被配载。
* <p>
* ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突,
* 否则编辑保存会把自己的子运单判为不可用。
*
* @param waybill 运单
* @param ownLoadingNo 本配载单号;新建时传 null
* @return true-可配载
*/
public static boolean isLoadable(Waybill waybill, String ownLoadingNo) {
if (waybill == null) {
return false;
}
if (!isRoadTransport(waybill.getTransportType())
|| !LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))
|| WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) {
return false;
}
String loadingNo = text(waybill.getLoadingNo());
return loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo));
}
/**
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载 + 非拒单。
*
* @param wrapper 运单列表查询条件
*/
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
applyRoadTransportCondition(wrapper);
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
// 接单状态绝大多数为 NULL,而 NULL <> 'rejected' 不成立,只写不等于会把它们一起滤掉
wrapper.and(nested -> nested.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
}
private static void applyRoadTransportCondition(LambdaQueryWrapper<Waybill> wrapper) {
wrapper.and(nested -> {
boolean started = false;
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
if (started) {
nested.or();
}
nested.like(Waybill::getTransportType, keyword);
started = true;
}
for (String code : ROAD_TRANSPORT_CODES) {
nested.or().eq(Waybill::getTransportType, code);
}
});
}
private static String text(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,173 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.ArgumentCaptor;
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.pojo.vo.WaybillVO;
import org.springblade.transport.support.LoadableAdmissionSupport;
import java.lang.reflect.Field;
import java.util.Collection;
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.verify;
import static org.mockito.Mockito.when;
/**
* 运单列表的可配载取数:开关一次带上整条判据,另支持按主键集合回查。
* <p>
* 候选列表与「公路配载」按钮预检都走这次查询,所以断言下在生成的 SQL 片段上——
* 开关不启用时默认列表不能被顺手改口径,那是全部运单入口共用的查询。
*/
class WaybillLoadableQueryTest {
private WaybillMapper waybillMapper;
private WaybillServiceImpl service;
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);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
when(waybillMapper.selectPage(any(), any())).thenReturn(new Page<Waybill>(1, 10));
// 组织过滤只看当前用户;超级管理员不带组织条件,断言里就只剩本用例关心的条件
authUtil = mockStatic(AuthUtil.class);
when(AuthUtil.getUserRole()).thenReturn("administrator");
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("开关开启:公路词表、待执行、未配载、非拒单一次全部进 wrapper")
void loadableSwitchAppliesWholePredicate() {
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(loadableQuery());
String sql = String.valueOf(wrapper.getTargetSql());
Collection<Object> params = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("transport_type"), "公路判定要落在运单自己的运输方式上:" + sql);
for (String keyword : LoadableAdmissionSupport.ROAD_TRANSPORT_KEYWORDS) {
assertTrue(params.contains("%" + keyword + "%"),
"公路词表要整份进条件,只写 road 会漏掉中文取值的运单:" + params);
}
assertTrue(sql.contains("business_status =") && params.contains("pending"),
"只放行待执行:" + sql + " " + params);
assertFalse(params.contains("running"),
"进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params);
assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="),
"未配载要同时覆盖 NULL 与空串:" + sql);
assertTrue(sql.contains("driver_accept_status <>") && params.contains("rejected"),
"接单状态为已拒绝的要排除:" + sql);
assertTrue(sql.contains("driver_accept_status IS NULL"),
"接单状态大多是 NULL,只写不等于 rejected 会把它们一起滤掉:" + sql);
}
@Test
@DisplayName("开关开启 + 主键集合:收窄到所选运单,用于按钮预检")
void loadableSwitchNarrowsBySelectedIds() {
WaybillVO query = loadableQuery();
query.setIds(List.of(11L, 22L));
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
Collection<Object> params = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
assertTrue(params.contains(11L) && params.contains(22L), "所选运单主键要进条件:" + params);
assertTrue(sql.contains("transport_type"), "回查仍要带整条判据:" + sql);
}
@Test
@DisplayName("只传主键集合:按主键收窄,不附带可配载判据")
void idsNarrowWithoutTheSwitch() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
query.setIds(List.of(33L));
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
assertTrue(wrapper.getParamNameValuePairs().values().contains(33L), sql);
assertFalse(sql.contains("transport_type"), "没开可配载开关就不加公路条件:" + sql);
}
@Test
@DisplayName("开关未开启:运单管理默认列表条件与展示口径不变")
void withoutSwitchTheDefaultListIsUntouched() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
assertFalse(sql.contains("transport_type"), sql);
assertFalse(sql.contains("business_status"), sql);
assertFalse(sql.contains("loading_no"), sql);
assertFalse(sql.contains("driver_accept_status"), sql);
}
// ------------------------------------------------------------ 夹具
private WaybillVO loadableQuery() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
query.setLoadableOnly(1);
return query;
}
private LambdaQueryWrapper<Waybill> capturedQuery(WaybillVO query) {
IPage<WaybillVO> page = service.selectWaybillPage(new Page<>(1, 10), query);
assertTrue(page.getRecords().isEmpty());
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(waybillMapper).selectPage(any(), captor.capture());
return captor.getValue();
}
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);
}
}
@@ -0,0 +1,92 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.entity.Waybill;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 可配载准入判据:公路词表与单张运单的准入检查。
* <p>
* 判据是读侧候选列表与写侧提交/编辑/重新派单校验共用的同一份定义。
*/
class LoadableAdmissionSupportTest {
// ------------------------------------------------------------ 公路词表
@Test
@DisplayName("公路:road 与中文同义写法都命中")
void roadVocabularyHits() {
assertTrue(LoadableAdmissionSupport.isRoadTransport("road"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("ROAD"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("道路运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("汽车运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("陆运"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("highway"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("gl"));
}
@Test
@DisplayName("非公路与空值一律不命中——未知运输方式按非公路拒绝,不能放行")
void nonRoadVocabularyMisses() {
assertFalse(LoadableAdmissionSupport.isRoadTransport("railway"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("water"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("air"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("river"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("水路运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("铁路运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("航空运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport(""));
assertFalse(LoadableAdmissionSupport.isRoadTransport(" "));
assertFalse(LoadableAdmissionSupport.isRoadTransport(null));
}
// ------------------------------------------------------------ 单张运单准入
@Test
@DisplayName("公路 + 待执行 + 未配载 + 未拒单才可配载")
void loadableOnlyWhenAllConditionsHold() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending")));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null)));
}
@Test
@DisplayName("非公路、非待执行、已关联配载单、已拒单各自单独即可拒绝")
void eachConditionRejectsOnItsOwn() {
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending")),
"非公路不可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null)),
"进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null)));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null)));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null)));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null)),
"已挂在其它配载单上的票不可再配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected")),
"已拒单的票不可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(null));
}
@Test
@DisplayName("编辑/重新派单:loadingNo 等于本配载单号时不算冲突,其它配载单号仍拒绝")
void ownLoadingNoIsNotAConflict() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, null), "PZ20260101-001"));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"),
"挂在别的配载单上时即使正在编辑本单也不能合进来");
}
private Waybill waybill(String transportType, String businessStatus, String loadingNo, String driverAcceptStatus) {
Waybill waybill = new Waybill();
waybill.setTransportType(transportType);
waybill.setBusinessStatus(businessStatus);
waybill.setLoadingNo(loadingNo);
waybill.setDriverAcceptStatus(driverAcceptStatus);
return waybill;
}
}