♻️ 运次分档改由服务层只读推导,不再由库层判据决定

库层只负责取回收口与筛选命中的可见运单行,分组、分档、筛选、排序与分页
全部在服务层算完再做,Tab 与卡片由同一份推导得出;接单需求改取项目生效中
的过程配置(与单票路径同口径,仅写回内存不落库),否则快照缺接单节点的
运次会被误判成进行中。筛选只决定哪些运次在范围内,分档永远看整趟车,
避免筛选缩小子单集合后把运输中的车算进已完成。SQL 分档渲染与重复的分组
键声明一并删除,管理端两类筛选合成一条取数路径。
This commit is contained in:
2026-10-17 20:40:57 +08:00
parent e8196e7ef7
commit 076d04471f
9 changed files with 771 additions and 462 deletions
@@ -27,6 +27,9 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Set;
/**
* 运次查询条件
@@ -53,9 +56,30 @@ public class TripQueryVO implements Serializable {
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
private Integer status;
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
private List<Integer> statuses;
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
private String keyword;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
private String transportOrgType;
@Schema(description = "异常筛选:exception 只看命中异常的子单 / normal 只看未命中的,不传为全部(按子单行筛选)")
private String exception;
@Schema(description = "有未完成异常的子运单主键集合,配合 exception 使用(按子单行筛选)")
private Set<Long> exceptionWaybillIds;
@Schema(description = "创建时间下界,按子单行筛选")
private Date createdFrom;
@Schema(description = "创建时间上界,按子单行筛选")
private Date createdTo;
@Schema(description = "是否排除「有子单被拒且这趟车还没结束」的运次(待处理列表用)")
private boolean excludeRejectedTrips;
@Schema(description = "当前页,从 1 开始")
private Integer current;
@@ -31,12 +31,15 @@ import org.springblade.transport.pojo.vo.TripVO;
* 运次读服务
* <p>
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态。
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
* 不按库里的业务状态,也不借读路径做惰性校正。
*/
public interface ITripQueryService {
/**
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个),筛选、排序与分页都在库层按运次完成。
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
* <p>
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @return 运次分页
@@ -44,7 +47,7 @@ public interface ITripQueryService {
IPage<TripVO> page(TripQueryVO query);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
*
* @param query 查询条件,口径同 page
* @return 运次计数
@@ -120,24 +120,15 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/**
* 库层分组键:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
* 与运次读服务同一个表达式,两处的「运次」才是同一个东西。
*/
private static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
/** 关键字在子单上匹配的字段:与运次读服务同一份口径 */
private static final String[] KEYWORD_COLUMNS = {
"k.waybill_no", "k.loading_no", "k.departure_name", "k.arrival_name",
"k.departure_address", "k.arrival_address", "k.vehicle_no"
/** 关键字在子单上匹配的字段:与运次读服务同一份口径,待重新派单列表按它筛 */
private static final String[] REASSIGN_REASSIGN_KEYWORD_COLUMNS = {
"waybill_no", "loading_no", "departure_name", "arrival_name",
"departure_address", "arrival_address", "vehicle_no"
};
/** 已拒子单命中的运次键:待重新派单的判据(已拒单 + 未取消未完成),与首页待办同源 */
private static final String SQL_REJECTED_TRIP_KEYS = "SELECT IFNULL(NULLIF(r.loading_no, ''), CONCAT('W', r.id))"
+ " FROM blade_waybill r WHERE r.is_deleted = 0 AND r.driver_accept_status = '" + ACCEPT_REJECTED + "'"
+ " AND r.business_status IN ('" + STATUS_PENDING + "', '" + STATUS_RUNNING + "')";
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -188,20 +179,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return emptyCardPage(pageNo, pageSize);
}
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
TripQueryVO query = TripQueryVO.forAdmin();
query.setStatus(toTripStatus(status));
query.setKeyword(keyword);
// 运次读服务只认状态、关键字与分页;组织类型 / 异常 / 创建时间这些筛选它表达不了,
// 走库层分组取这一页的运次主键与总数,再逐条交给它取整趟车(状态与货物汇总仍出自同一份实现)
boolean filtered = Func.isNotBlank(transportType) || Func.isNotBlank(exception)
|| Func.isNotBlank(startDate) || Func.isNotBlank(endDate);
if (!filtered) {
IPage<TripVO> trips = tripQueryService.page(tripPageQuery(query, pageNo, pageSize));
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
return groupedTripPage(filteredTripKeyQuery(query, transportType, exception, exceptionWaybillIds,
parseDayStart(startDate), parseDayEnd(endDate)), pageNo, pageSize, exceptionWaybillIds);
query.setTransportOrgType(transportType);
query.setException(exception);
query.setExceptionWaybillIds(exceptionWaybillIds);
query.setCreatedFrom(parseDayStart(startDate));
query.setCreatedTo(parseDayEnd(endDate));
query.setCurrent(pageNo);
query.setSize(pageSize);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
@Override
@@ -239,140 +230,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
TripQueryVO query = TripQueryVO.forAdmin();
query.setKeyword(keyword);
return groupedTripPage(pendingTripKeyQuery(query, needReassign), pageNo, pageSize, exceptionWaybillIds);
}
/* ===========================================================================
* 运次取数:列表 / 待处理都走共享运次读服务,本类只做字段裁剪与调度端独有的筛选
* =========================================================================*/
private TripQueryVO tripPageQuery(TripQueryVO base, int pageNo, int pageSize) {
TripQueryVO query = new TripQueryVO();
query.setAllDrivers(base.isAllDrivers());
query.setDriverPhone(base.getDriverPhone());
query.setStatus(base.getStatus());
query.setKeyword(base.getKeyword());
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
query.setStatuses(List.of(0, 1));
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
query.setCurrent(pageNo);
query.setSize(pageSize);
return query;
}
/**
* 库层按运次分组取这一页的运次主键与总数,再逐条向运次读服务取整趟车。
* <p>
* 为什么不是直接把筛选交给运次读服务:它只认状态、关键字与分页,没有筛选钩子;
* 而按运次在服务端过滤必须先扫完命中的运次才能给出列表口径的总数,几千个运次的租户下要十几秒。
* 这里只把「它表达不了的筛选」落到库层,分组键与 Tab 判据都复用它的那份声明,
* 卡片上的状态、接单需求、站点与货物汇总仍由运次读服务下发。
*/
private Page<AdminWaybillCardVO> groupedTripPage(QueryWrapper<Waybill> keyQuery, int pageNo, int pageSize,
Set<Long> exceptionWaybillIds) {
Page<Waybill> rows = new Page<>(pageNo, pageSize);
waybillService.page(rows, keyQuery);
// 分组行只带运次键:配载运次要换成配载单主键,运次读服务才认这个主键
Set<String> loadingNos = rows.getRecords().stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
List<TripVO> trips = new ArrayList<>(rows.getRecords().size());
for (Waybill row : rows.getRecords()) {
Long tripId = Func.isEmpty(row.getLoadingNo())
? row.getId()
: loadingId(loadings.get(row.getLoadingNo()), row.getId());
TripVO trip = tripId == null ? null : visibleTrip(String.valueOf(tripId));
if (trip != null) {
// 运次在两次查询之间被拒单/释放:这一页少一张卡,不拿没有子单的运次凑数
trips.add(trip);
}
}
return tripCardPage(trips, pageNo, pageSize, rows.getTotal(), exceptionWaybillIds);
}
/**
* 筛选用的库层运次查询:分组键、可见口径与 Tab 判据都与运次读服务同源
*/
private QueryWrapper<Waybill> filteredTripKeyQuery(TripQueryVO base, String transportType, String exception,
Set<Long> exceptionWaybillIds, Date createdFrom, Date createdTo) {
QueryWrapper<Waybill> wrapper = visibleTripRows();
applyTransportOrgTypeFilter(wrapper, transportType);
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
applyCreatedRangeFilter(wrapper, createdFrom, createdTo);
applyTripKeywordFilter(wrapper, base.getKeyword());
wrapper.groupBy(SQL_TRIP_KEY);
wrapper.having(TripAggregation.tabPredicate(base.getStatus()));
wrapper.orderByDesc(SQL_MAX_CREATE_TIME);
return wrapper;
}
/**
* 待处理列表的库层运次查询:待接单 + 进行中两档,可选「未拒单」。
* 已拒子单的运次键子查询与首页待办同源,整单拒绝后这趟车不再算待处理。
*/
private QueryWrapper<Waybill> pendingTripKeyQuery(TripQueryVO base, Boolean needReassign) {
QueryWrapper<Waybill> wrapper = visibleTripRows();
applyTripKeywordFilter(wrapper, base.getKeyword());
if (Boolean.FALSE.equals(needReassign)) {
wrapper.apply(SQL_TRIP_KEY + " NOT IN (" + SQL_REJECTED_TRIP_KEYS + ")");
}
wrapper.groupBy(SQL_TRIP_KEY);
wrapper.having("(" + TripAggregation.tabPredicate(0) + " OR " + TripAggregation.tabPredicate(1) + ")");
wrapper.orderByDesc(SQL_MAX_CREATE_TIME);
return wrapper;
}
/** 可见运次行:与运次读服务的可见口径同一条(已拒子单不进运次,草稿不成运次) */
private QueryWrapper<Waybill> visibleTripRows() {
QueryWrapper<Waybill> wrapper = Wrappers.query();
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", SQL_MAX_CREATE_TIME + " AS create_time");
wrapper.apply("(driver_accept_status IS NULL OR driver_accept_status <> {0})", ACCEPT_REJECTED);
wrapper.in("business_status", TripAggregation.CHILD_STATUSES);
return wrapper;
}
private void applyTransportOrgTypeFilter(QueryWrapper<Waybill> wrapper, String transportType) {
if (TRANSPORT_LOAD.equals(transportType)) {
wrapper.apply("IFNULL(loading_no, '') <> ''");
} else if (TRANSPORT_COMMON.equals(transportType)) {
wrapper.apply("IFNULL(loading_no, '') = ''");
}
}
/** 异常筛选按运单命中:整趟车任一子单有未完成异常即命中(与旧运单级筛选同判据) */
private void applyExceptionFilter(QueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
if (exceptionWaybillIds.isEmpty()) {
return;
}
if (EXCEPTION_YES.equals(exception)) {
wrapper.in("id", exceptionWaybillIds);
} else if (EXCEPTION_NO.equals(exception)) {
wrapper.notIn("id", exceptionWaybillIds);
}
}
private void applyCreatedRangeFilter(QueryWrapper<Waybill> wrapper, Date createdFrom, Date createdTo) {
if (createdFrom != null) {
wrapper.ge("create_time", createdFrom);
}
if (createdTo != null) {
wrapper.le("create_time", createdTo);
}
}
/** 关键字与运次读服务同一口径:任一子单或配载单号命中即整趟车命中 */
private void applyTripKeywordFilter(QueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
Object[] params = new Object[KEYWORD_COLUMNS.length];
StringBuilder matched = new StringBuilder();
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
if (i > 0) {
matched.append(" OR ");
}
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
params[i] = "%" + keyword.trim() + "%";
}
wrapper.apply(SQL_TRIP_KEY + " IN (SELECT IFNULL(NULLIF(k.loading_no, ''), CONCAT('W', k.id))"
+ " FROM blade_waybill k WHERE k.is_deleted = 0 AND (" + matched + "))", params);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
@@ -472,21 +336,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
if (Func.isEmpty(tripIds)) {
return Set.of();
}
// 占位符逐个列出:分组键是 SQL 表达式,用不了 in(column, values),而 {0} 传集合会被当成单个参数序列化
List<String> keys = new ArrayList<>(tripIds);
Object[] params = new Object[keys.size()];
StringBuilder placeholders = new StringBuilder();
for (int i = 0; i < keys.size(); i++) {
if (i > 0) {
placeholders.append(", ");
}
placeholders.append("{").append(i).append("}");
params[i] = keys.get(i);
}
List<Waybill> rejected = waybillService.list(pendingReassignQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.apply(SQL_TRIP_KEY + " IN (" + placeholders + ")", params));
return rejected.stream().map(this::tripKeyOf).collect(Collectors.toSet());
// 被拒子单只有几十行量级:一次取回后在服务层换算运次键,比在库层按分组键表达式反查更省事
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
.stream()
.map(this::tripKeyOf)
.filter(tripIds::contains)
.collect(Collectors.toSet());
}
private boolean hasRejectedChild(String tripId) {
@@ -540,20 +395,14 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
}
/** 库层分组用的运次键,与 SQL_TRIP_KEY 同一个表达式 */
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
private String tripKeyOf(Waybill waybill) {
if (waybill == null) {
return null;
}
return Func.isEmpty(waybill.getLoadingNo()) ? "W" + waybill.getId() : waybill.getLoadingNo();
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
}
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与 SQL_TRIP_KEY 同一个表达式 */
/** 运次的键:同上,运次卡片取运次主键换算 */
private String tripKeyOf(TripVO trip) {
if (trip == null) {
return null;
}
return Func.isEmpty(trip.getLoadingNo()) ? "W" + trip.getId() : trip.getLoadingNo();
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
}
/* ===========================================================================
@@ -830,6 +679,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
*/
private QueryWrapper<Waybill> reassignGroupQuery() {
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
QueryWrapper<Waybill> wrapper = Wrappers.query();
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
@@ -841,7 +691,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
wrapper.groupBy(SQL_TRIP_KEY);
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
return wrapper;
}
@@ -27,16 +27,14 @@ import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.support.WaybillProcessSupport;
import java.util.List;
import java.util.stream.Collectors;
/**
* 运次聚合口径:状态四档 + 接单需求 OR
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
* <p>
* 四档判据有两种渲染:库层聚合条件用于 Tab 过滤与分页(配载段按配载单号分组,先筛后排序再分页),
* Java 判定用于摆上一页的运次给状态。两种渲染必须同档。
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
* <p>
* 接单需求要解析过程配置,库层表达不出来,只能在本页复核:库里落的是业务状态,
* 而「需接单未接」与「无需接单」两种情况在库里可能都是待接单档。
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
*/
final class TripAggregation {
@@ -60,40 +58,21 @@ final class TripAggregation {
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
);
/** 库层聚合计数 */
private static final String SQL_TOTAL = "COUNT(*)";
private static final String SQL_TERMINAL = "SUM(business_status IN (" + quoted(TERMINAL_STATUSES) + "))";
private static final String SQL_COMPLETED = "SUM(business_status = '" + COMPLETED + "')";
private static final String SQL_PENDING = "SUM(business_status IN (" + quoted(PENDING_STATUSES) + "))";
private static final String SQL_ALL_TERMINAL = SQL_TERMINAL + " = " + SQL_TOTAL;
private static final String SQL_NOT_ALL_TERMINAL = SQL_TERMINAL + " < " + SQL_TOTAL;
private static final String SQL_ANY_COMPLETED = SQL_COMPLETED + " > 0";
private static final String SQL_NO_COMPLETED = SQL_COMPLETED + " = 0";
private static final String SQL_ANY_PENDING = SQL_PENDING + " > 0";
private static final String SQL_NO_PENDING = SQL_PENDING + " = 0";
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
/** 四档的库层判据:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
private static final String SQL_PENDING_TIER = SQL_ANY_PENDING + " AND " + SQL_NOT_ALL_TERMINAL;
private static final String SQL_RUNNING_TIER = SQL_NO_PENDING + " AND " + SQL_NOT_ALL_TERMINAL;
private static final String SQL_COMPLETED_TIER = SQL_ANY_COMPLETED + " AND " + SQL_ALL_TERMINAL;
private static final String SQL_CANCELLED_TIER = SQL_NO_COMPLETED + " AND " + SQL_ALL_TERMINAL;
/**
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
*/
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
private TripAggregation() {
}
/**
* Tab 过滤条件(库层 HAVING)
* <p>
* 0 待接单 / 1 进行中 / 2 已完成 / 3 已取消;其余(含不传)按「全部」= 0/1/2,不含已取消。
*/
static String tabPredicate(Integer tab) {
return switch (tab == null ? -1 : tab) {
case 0 -> SQL_PENDING_TIER;
case 1 -> SQL_RUNNING_TIER;
case 2 -> SQL_COMPLETED_TIER;
case 3 -> SQL_CANCELLED_TIER;
default -> "(" + SQL_PENDING_TIER + " OR " + SQL_RUNNING_TIER + " OR " + SQL_COMPLETED_TIER + ")";
};
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
static String keyOf(String loadingNo, Long id) {
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
}
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
@@ -125,7 +104,7 @@ final class TripAggregation {
}
/**
* 库层聚合结果:子单总数、终态数、已完成数、待接单档数
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
*/
static Counters count(List<Waybill> children) {
int total = 0;
@@ -187,10 +166,6 @@ final class TripAggregation {
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
}
private static String quoted(List<String> statuses) {
return statuses.stream().map(status -> "'" + status + "'").collect(Collectors.joining(", "));
}
/** 子单聚合计数 */
record Counters(int total, int terminal, int completed, int pending) {
}
@@ -22,7 +22,7 @@
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -30,12 +30,14 @@ import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
@@ -43,6 +45,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
@@ -57,8 +60,12 @@ import java.util.stream.Collectors;
/**
* 运次读服务实现
* <p>
* 取数两段,段内都是批量查询:段一在库层按运次分组聚合,一次查询完成筛选、排序与分页;
* 段二按这一页的运次主键与配载单号批量取配载单与子运单。
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
* <p>
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
* 先分页再过滤会把该在第一页的运次挤到后面几页。
* <p>
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
@@ -75,19 +82,22 @@ public class TripQueryServiceImpl implements ITripQueryService {
private static final String TRANSPORT_LOAD = "load";
private static final String TRANSPORT_COMMON = "common";
/** 库层分组键:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键) */
private static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
/** 库层排序键:子单 create_time 的最大值 */
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
private static final String LONE_KEY_PREFIX = "W";
/** 异常筛选的取值:命中未完成异常的子单 */
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
/** 关键字在子单上匹配的字段 */
private static final String[] KEYWORD_COLUMNS = {
"k.waybill_no", "k.loading_no", "k.departure_name", "k.arrival_name",
"k.departure_address", "k.arrival_address", "k.vehicle_no"
"waybill_no", "loading_no", "departure_name", "arrival_name",
"departure_address", "arrival_address", "vehicle_no"
};
private final IWaybillService waybillService;
private final ILoadingManageService loadingManageService;
private final IProcessConfigService processConfigService;
@Override
public IPage<TripVO> page(TripQueryVO query) {
@@ -98,10 +108,11 @@ public class TripQueryServiceImpl implements ITripQueryService {
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
return new Page<>(pageNo, pageSize);
}
Page<Waybill> rows = new Page<>(pageNo, pageSize);
waybillService.page(rows, tripQuery(condition));
Page<TripVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
page.setRecords(assemble(rows.getRecords()));
List<TripRows> trips = trips(condition);
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
int from = Math.min((pageNo - 1) * pageSize, trips.size());
int to = Math.min(from + pageSize, trips.size());
page.setRecords(assemble(trips.subList(from, to)));
return page;
}
@@ -112,9 +123,23 @@ public class TripQueryServiceImpl implements ITripQueryService {
if (!scoped(condition)) {
return counts;
}
long pending = countTrips(condition, 0);
long doing = countTrips(condition, 1);
long done = countTrips(condition, 2);
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
long pending = 0;
long doing = 0;
long done = 0;
for (TripRows trip : trips(withoutTab(condition))) {
Integer tier = trip.tier();
if (tier == null) {
continue;
}
switch (tier) {
case 0 -> pending++;
case 1 -> doing++;
case 2 -> done++;
default -> {
}
}
}
counts.setPending(pending);
counts.setDoing(doing);
counts.setDone(done);
@@ -147,33 +172,206 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 单个 Tab 的运次数:只走库层的分组计数,不取子单、不做接单态复核
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
* <p>
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
* 命中的行同时决定这一趟车的子单集合与分档。
*/
private long countTrips(TripQueryVO condition, int status) {
Page<Waybill> rows = new Page<>(1, 1);
waybillService.page(rows, tripQuery(withStatus(condition, status)));
return rows.getTotal();
private List<TripRows> trips(TripQueryVO condition) {
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill row : visibleRows(condition)) {
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
ignored -> new ArrayList<>()).add(row);
}
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
Set<Integer> tiers = tiers(condition);
List<TripRows> trips = new ArrayList<>(grouped.size());
grouped.forEach((key, rows) -> {
if (excluded.contains(key)) {
return;
}
List<Waybill> children = sortedChildren(rows);
if (!inScope(children, condition)) {
return;
}
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
String status = TripAggregation.statusOf(children, requireAccept, accepted);
Integer tier = TripAggregation.appStatus(status);
if (tier == null || !tiers.contains(tier)) {
return;
}
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
});
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(TripRows::key));
return trips;
}
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
private List<Waybill> sortedChildren(List<Waybill> rows) {
List<Waybill> children = new ArrayList<>(rows);
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
return children;
}
/**
* 库层运次查询:按运次分组,一次完成筛选、排序与分页
* 段一:一次取回该收口与行级筛选命中的可见运单行
* <p>
* 分组键与排序键都在 SQL 里,Tab 过滤是分组后的聚合条件(HAVING)——先分页再在服务端过滤会让
* 本该在第一页的运次漏到后面几页。
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
* 可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
*/
private QueryWrapper<Waybill> tripQuery(TripQueryVO condition) {
QueryWrapper<Waybill> wrapper = Wrappers.query();
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no",
SQL_MAX_CREATE_TIME + " AS create_time");
wrapper.apply("(driver_accept_status IS NULL OR driver_accept_status <> {0})",
WaybillProcessSupport.ACCEPT_REJECTED);
wrapper.in("business_status", TripAggregation.CHILD_STATUSES);
private List<Waybill> visibleRows(TripQueryVO condition) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark, Waybill::getCreateTime)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
applyScope(wrapper, condition);
applyOrgTypeFilter(wrapper, condition);
applyKeyword(wrapper, condition.getKeyword());
wrapper.groupBy(SQL_TRIP_KEY);
wrapper.having(TripAggregation.tabPredicate(condition.getStatus()));
wrapper.orderByDesc(SQL_MAX_CREATE_TIME);
return wrapper;
List<Waybill> rows = waybillService.list(wrapper);
applyLiveProcessConfig(rows);
return rows;
}
/**
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
*/
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') <> ''");
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') = ''");
}
}
/**
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
* 会让同一趟车归在某一档、卡片却显示另一档。
* <p>
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
*/
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
if (Func.isNotEmpty(exceptionIds)) {
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
return false;
}
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
return false;
}
}
Date created = maxCreateTime(children);
if (condition.getCreatedFrom() != null
&& (created == null || created.before(condition.getCreatedFrom()))) {
return false;
}
return condition.getCreatedTo() == null
|| (created != null && !created.after(condition.getCreatedTo()));
}
/**
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
* <p>
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
*/
private void applyLiveProcessConfig(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
return;
}
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (projectIds.isEmpty()) {
return;
}
Map<Long, String> live = liveProcessConfigs(projectIds);
if (live.isEmpty()) {
return;
}
for (Waybill row : rows) {
String liveJson = live.get(row.getProjectId());
if (Func.isNotEmpty(liveJson)) {
row.setProcessJson(resolveProcessJson(liveJson, row.getProcessJson()));
}
}
}
/** 生效配置与快照二选一:生效配置没有可打卡节点时回退快照,两边都取不到节点就按生效配置来 */
private String resolveProcessJson(String liveJson, String snapshot) {
if (WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()
&& Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return liveJson;
}
/** 生效中的过程配置:项目 → 节点配置 JSON,取最近更新的一条 */
private Map<Long, String> liveProcessConfigs(Set<Long> projectIds) {
List<ProcessConfig> configs = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime));
Map<Long, String> live = new LinkedHashMap<>();
for (Long projectId : projectIds) {
if (projectId == null) {
continue;
}
String projectIdStr = String.valueOf(projectId);
for (ProcessConfig config : configs) {
if (Func.isEmpty(config.getNodeConfigJson())
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
continue;
}
live.put(projectId, config.getNodeConfigJson());
break;
}
}
return live;
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(",")).map(String::trim).anyMatch(projectId::equals);
}
/**
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
*/
private Set<String> rejectedTripKeys() {
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
*/
private Set<Integer> tiers(TripQueryVO condition) {
if (Func.isNotEmpty(condition.getStatuses())) {
return new LinkedHashSet<>(condition.getStatuses());
}
if (condition.getStatus() != null) {
return Set.of(condition.getStatus());
}
return Set.of(0, 1, 2);
}
/**
@@ -181,7 +379,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
* <p>
* 手机号允许带空格,与运单列表的收口判据一致。
*/
private void applyScope(QueryWrapper<Waybill> wrapper, TripQueryVO condition) {
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (condition.isAllDrivers()) {
return;
}
@@ -191,20 +389,48 @@ public class TripQueryServiceImpl implements ITripQueryService {
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
.or(lone -> lone
.apply("IFNULL(loading_no, '') = ''")
.and(row -> row.eq("driver_phone", phone)
.and(row -> row.eq(Waybill::getDriverPhone, phone)
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
}
/**
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
* <p>
* 关键字不缩小这一组的子单集合,否则状态与排序会按命中的那一部分子单算。
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
* 否则状态与排序会按命中的那一部分子单算。
*/
private void applyKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isEmpty(keyword)) {
return;
}
String like = "%" + keyword.trim() + "%";
Set<String> keys = keywordTripKeys(keyword.trim());
if (keys.isEmpty()) {
wrapper.apply("1 = 0");
return;
}
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
.map(this::tripId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
wrapper.and(matched -> {
if (!loadingNos.isEmpty()) {
matched.in(Waybill::getLoadingNo, loadingNos);
}
if (!loneIds.isEmpty()) {
if (!loadingNos.isEmpty()) {
matched.or();
}
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
}
});
}
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
private Set<String> keywordTripKeys(String keyword) {
String like = "%" + keyword + "%";
Object[] params = new Object[KEYWORD_COLUMNS.length];
StringBuilder matched = new StringBuilder();
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
@@ -214,58 +440,43 @@ public class TripQueryServiceImpl implements ITripQueryService {
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
params[i] = like;
}
wrapper.apply(SQL_TRIP_KEY + " IN (SELECT IFNULL(NULLIF(k.loading_no, ''), CONCAT('W', k.id))"
+ " FROM blade_waybill k WHERE k.is_deleted = 0 AND (" + matched + "))", params);
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.apply("(" + matched + ")", params))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 把这一页的运次行组装成运次:先批量取配载单与子运单,再逐行拼装
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
* <p>
* 页里的行只带分组键与排序键,子运单要按主键/配载单号回查一次:未配载的运次就是那张运单本身,
* 状态、接单需求与货物都得从那行真身来,不能拿分组行凑。
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
*/
private List<TripVO> assemble(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
private List<TripVO> assemble(List<TripRows> trips) {
if (Func.isEmpty(trips)) {
return new ArrayList<>();
}
Set<String> loadingNos = rows.stream()
.map(Waybill::getLoadingNo)
Set<String> loadingNos = trips.stream()
.map(TripRows::loadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = rows.stream()
.filter(row -> Func.isEmpty(row.getLoadingNo()))
.map(Waybill::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos, loneIds);
Map<Long, Waybill> loneWaybills = new LinkedHashMap<>();
children.values().forEach(group -> group.forEach(child -> loneWaybills.put(child.getId(), child)));
List<TripVO> trips = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
if (Func.isEmpty(loadingNo)) {
Waybill waybill = loneWaybills.get(row.getId());
if (waybill == null) {
// 运单在两次查询之间被删或被拒单:这一页不给出没有子单的运次
continue;
}
trips.add(waybillTrip(waybill, false));
List<TripVO> result = new ArrayList<>(trips.size());
for (TripRows trip : trips) {
if (Func.isEmpty(trip.loadingNo())) {
result.add(waybillTrip(trip.children().get(0), false));
continue;
}
LoadingManage loading = loadings.get(loadingNo);
LoadingManage loading = loadings.get(trip.loadingNo());
if (loading == null) {
// 配载单被并发删除:不给出没有主键的运次
continue;
}
List<Waybill> group = children.getOrDefault(loadingNo, List.of());
if (group.isEmpty()) {
// 子单全部被拒单:整趟车对司机不可见,与详情口径一致
continue;
}
trips.add(loadingTrip(loading, group, false));
result.add(loadingTrip(loading, trip.children(), false));
}
return trips;
return result;
}
/**
@@ -370,7 +581,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 聚合出运次的状态、接单需求、接单态与排序键
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
*/
private void fillAggregation(TripVO trip, List<Waybill> children) {
String acceptStatus = TripAggregation.acceptStatus(children);
@@ -469,7 +680,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
return child;
}
/** 排序键:子单 create_time 的最大值,与库层排序用的是同一个聚合 */
/** 排序键:子单 create_time 的最大值 */
private Date maxCreateTime(List<Waybill> children) {
return children.stream()
.map(Waybill::getCreateTime)
@@ -532,12 +743,18 @@ public class TripQueryServiceImpl implements ITripQueryService {
return query == null ? new TripQueryVO() : query;
}
private TripQueryVO withStatus(TripQueryVO source, Integer status) {
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
private TripQueryVO withoutTab(TripQueryVO source) {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setAllDrivers(source.isAllDrivers());
copy.setKeyword(source.getKeyword());
copy.setStatus(status);
copy.setTransportOrgType(source.getTransportOrgType());
copy.setException(source.getException());
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
copy.setCreatedFrom(source.getCreatedFrom());
copy.setCreatedTo(source.getCreatedTo());
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
return copy;
}
@@ -558,6 +775,11 @@ public class TripQueryServiceImpl implements ITripQueryService {
return id == null ? null : String.valueOf(id);
}
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
Date sortTime) {
}
private int pageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
@@ -10,6 +10,7 @@ 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.ArgumentMatchers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
@@ -17,6 +18,7 @@ import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
@@ -39,6 +41,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
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.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -61,6 +66,10 @@ class ManageTripListDetailTest {
private ILoadingManageService loadingManageService;
private IDriverWaybillService driverWaybillService;
private static java.time.LocalDate toLocalDate(Date date) {
return java.time.Instant.ofEpochMilli(date.getTime()).atZone(java.time.ZoneId.systemDefault()).toLocalDate();
}
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
@@ -119,6 +128,47 @@ class ManageTripListDetailTest {
assertFalse(card.getNeedReassign());
}
@Test
@DisplayName("列表筛选走同一次共享取数:组织类型、异常与创建时间随条件一起交给运次读服务")
void pageListFiltersGoThroughSharedQuery() {
when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
service.pageList(2, 20, "PZ-2026", "0", "normal", "load", "2026-09-01", "2026-09-30");
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, times(1)).page(captor.capture());
TripQueryVO query = captor.getValue();
assertEquals("load", query.getTransportOrgType());
assertEquals("normal", query.getException());
assertEquals(0, query.getStatus());
assertEquals("PZ-2026", query.getKeyword());
assertEquals(2, query.getCurrent());
assertEquals(20, query.getSize());
assertEquals(java.time.LocalDate.of(2026, 9, 1), toLocalDate(query.getCreatedFrom()));
assertEquals(java.time.LocalDate.of(2026, 9, 30), toLocalDate(query.getCreatedTo()));
verify(waybillService, never()).page(any(), any());
verify(tripQueryService, never()).detail(any());
}
@Test
@DisplayName("待处理列表:两档与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路")
void pendingListUsesSharedQuery() {
when(tripQueryService.page(any())).thenReturn(pageOf(0));
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
service.pendingList(1, 10, null, false);
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, times(1)).page(captor.capture());
assertEquals(List.of(0, 1), captor.getValue().getStatuses(), "待处理只要待接单与进行中两档");
assertTrue(captor.getValue().isExcludeRejectedTrips(), "未拒单也交给共享取数");
service.pendingList(1, 10, null, true);
verify(tripQueryService, times(1)).page(any());
verify(waybillService, times(1)).page(any(), any());
}
@Test
@DisplayName("详情按运次主键取:配载单号、组织类型、站点 ①②③ 与每站货物都带运单号")
void detailShowsWholeTripStations() {
@@ -8,15 +8,13 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 运次四档状态与接单需求合并口径。
* 运次四档状态、接单需求合并与运次键口径。
* <p>
* 四档判据库里一份(Tab 过滤用)、Java 一份(摆本页运次状态用),这里断言的是 Java 那份;
* 库层那份由 Tab 过滤条件的断言兜住,两边必须同档。
* 四档只在这一处推导演算,Tab 过滤与卡片状态都用它:这里断言的就是分档与卡片共用的那份口径。
*/
class TripAggregationTest {
@@ -142,22 +140,49 @@ class TripAggregationTest {
}
@Test
@DisplayName("Tab 过滤条件四档各不相同,「全部」=待接单/进行中/已完成且不含已取消")
void tabPredicatesCoverFourTiers() {
String pending = TripAggregation.tabPredicate(0);
String doing = TripAggregation.tabPredicate(1);
String done = TripAggregation.tabPredicate(2);
String cancelled = TripAggregation.tabPredicate(3);
@DisplayName("四档都由同一份推导给出:待接单 / 进行中 / 已完成 / 已取消")
void tiersComeFromTheSameDerivation() {
// 需接单且未接单 → 待接单档
assertEquals(0, tier(List.of(waybill("pending", "pending", ACCEPT_YES))));
// 需接单且已接单 → 进行中档
assertEquals(1, tier(List.of(waybill("running", "accepted", ACCEPT_YES))));
// 子单全完成 → 已完成档
assertEquals(2, tier(List.of(waybill("completed", "accepted", ACCEPT_YES))));
// 子单全取消 → 已取消档
assertEquals(3, tier(List.of(waybill("cancelled", null, ACCEPT_YES))));
// 子单无从判定(空集合)时不落任何档,不会混进「全部」
assertNull(tier(List.of()));
}
assertTrue(pending.contains("SUM(business_status IN ('pending', 'waiting_dispatch', 'dispatching')) > 0"), pending);
assertTrue(doing.contains("SUM(business_status IN ('pending', 'waiting_dispatch', 'dispatching')) = 0"), doing);
assertTrue(done.contains("SUM(business_status = 'completed') > 0"), done);
assertTrue(cancelled.contains("SUM(business_status = 'completed') = 0"), cancelled);
assertTrue(pending.contains("COUNT(*)"), "四档都要区分「子单是否全为终态」:" + pending);
assertNotEquals(pending, doing);
assertNotEquals(done, cancelled);
assertEquals("(" + pending + " OR " + doing + " OR " + done + ")", TripAggregation.tabPredicate(null));
assertEquals(TripAggregation.tabPredicate(null), TripAggregation.tabPredicate(5), "未知 Tab 按全部处理");
@Test
@DisplayName("库层业务状态与过程配置不一致时按推导分档,不按库值")
void tiersFollowDerivationNotStoredStatus() {
// 库里落的是 pending(惰性校正没跑过),但该项目不需要接单 → 推导为进行中,不进待接单档
assertEquals(1, tier(List.of(waybill("pending", "pending", ACCEPT_NO))));
// 反过来:库里落的是 running,但项目要接单且没接 → 推导为待接单
assertEquals(0, tier(List.of(waybill("running", "pending", ACCEPT_YES))));
}
@Test
@DisplayName("拼载的多张子单按 OR 合并接单需求:任一要接单且未接单就落在待接单档")
void mixedTripFollowsOrRule() {
assertEquals(0, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "pending", ACCEPT_YES))));
assertEquals(1, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "accepted", ACCEPT_YES))));
}
@Test
@DisplayName("运次键:有配载取配载单号,未配载取 W + 运单主键")
void tripKeyFollowsLoadingNo() {
assertEquals("PZ-20260919-001", TripAggregation.keyOf("PZ-20260919-001", 123L));
assertEquals("W123", TripAggregation.keyOf(null, 123L));
assertEquals("W123", TripAggregation.keyOf("", 123L));
}
private Integer tier(List<Waybill> children) {
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = org.springblade.transport.support.WaybillProcessSupport.ACCEPT_ACCEPTED
.equals(TripAggregation.acceptStatus(children));
return TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted));
}
@Test
@@ -2,10 +2,9 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
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.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -15,16 +14,17 @@ import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IWaybillService;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -40,10 +40,10 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 运次读服务的取数两段与聚合输出。
* 运次读服务的取数与聚合输出。
* <p>
* 单元测试里 mapper 是 mock、不会真的执行 SQL,所以「筛选排序分页在库层按运次完成」只能断言 SQL 本身;
* 摆出来的运次行则按「库层已经给好的输入」来验,不假装 mock 会替我们分组。
* 库层只负责取回运单行:分组、分档、筛选、排序与分页都在服务层,mock 出来的行必须能自己撑起一趟车,
* 不再有「库层已经分好组、回查子单」的第二段。
*/
class TripQueryServiceImplTest {
@@ -56,6 +56,7 @@ class TripQueryServiceImplTest {
private IWaybillService waybillService;
private ILoadingManageService loadingManageService;
private IProcessConfigService processConfigService;
private TripQueryServiceImpl service;
@BeforeAll
@@ -71,14 +72,16 @@ class TripQueryServiceImplTest {
void setUp() {
waybillService = mock(IWaybillService.class);
loadingManageService = mock(ILoadingManageService.class);
service = new TripQueryServiceImpl(waybillService, loadingManageService);
processConfigService = mock(IProcessConfigService.class);
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
}
@Test
@DisplayName("取数在库层按运次分组:分组键、排序键、Tab 过滤与司机收口都在 SQL 里")
void tripQueryGroupsInDatabase() {
@DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里")
void rowsAreFetchedWithoutDatabaseGrouping() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(1);
query.setCurrent(1);
@@ -86,60 +89,109 @@ class TripQueryServiceImplTest {
service.page(query);
String sql = capturedTripSql();
assertTrue(sql.contains("GROUP BY IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))"),
"配载段按配载单号分组、未配载按运单成组:" + sql);
assertTrue(sql.contains("ORDER BY MAX(create_time) DESC"),
"排序键是子单 create_time 的最大值:" + sql);
assertTrue(sql.contains("HAVING"), "Tab 过滤是分组后的聚合条件:" + sql);
String sql = capturedRowSql();
assertFalse(sql.contains("GROUP BY"), "库层不再分组:" + sql);
assertFalse(sql.contains("HAVING"), "库层不再分档:" + sql);
assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql);
assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql);
assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql);
assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql);
assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"),
"未配载段按运单司机收口,且容忍手机号带空格:" + sql);
assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql);
assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql);
}
@Test
@DisplayName("Tab 过滤落在 HAVING 上,不在服务端过滤")
void tabFilterLandsInHaving() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(2);
@DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来")
void onlyNeededColumnsAreSelected() {
service.page(TripQueryVO.forDriver(PHONE));
service.page(query);
String sql = capturedTripSql();
int having = sql.indexOf("HAVING");
assertTrue(having > 0, sql);
assertTrue(sql.substring(having).contains("SUM(business_status = 'completed') > 0"),
"已完成 Tab 的判据要在 HAVING 里:" + sql);
assertFalse(sql.contains("LIMIT"), "分页由分页插件加,不在 wrapper 里拼:" + sql);
String select = String.valueOf(capturedRowQuery().getSqlSelect());
assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select);
assertTrue(select.contains("create_time"), "排序键要用:" + select);
assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select);
assertFalse(select.contains("goods_json"), select);
assertFalse(select.contains("task_info_json"), select);
assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select);
}
@Test
@DisplayName("关键字按运次匹配:子单或配载单号命中即整趟车命中,不缩小这一组的子单集合")
@DisplayName("Tab 在服务层按推导分档过滤,每张卡的 status 与该档一致")
void tabFilterHappensOnDerivedTier() {
stubRows(
waybill(1L, "YD-1", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO));
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
doing.setStatus(1);
IPage<TripVO> doingPage = service.page(doing);
assertEquals(2, doingPage.getTotal(), "只有两趟车在进行中");
assertEquals(List.of("1", "3"), doingPage.getRecords().stream().map(TripVO::getId).toList(),
"排序键是子单 create_time 最大值,倒序(YD-1 的 3000 > YD-3 的 1000)");
assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())),
"每张卡的 status 都要等于这一档");
TripQueryVO done = TripQueryVO.forDriver(PHONE);
done.setStatus(2);
assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList());
TripQueryVO all = TripQueryVO.forDriver(PHONE);
all.setStatus(null);
assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)");
}
@Test
@DisplayName("库里落的状态与过程配置不一致时按推导分档,不按库值")
void tierFollowsDerivationNotStoredStatus() {
// 一张库值 pending 但项目不需要接单(推导为进行中),一张库值 running 但项目要接单且没接(推导为待接单)
stubRows(
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO));
TripQueryVO pending = TripQueryVO.forDriver(PHONE);
pending.setStatus(0);
assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(),
"库值 running 但要接单未接 → 落待接单档");
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
doing.setStatus(1);
assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(),
"库值 pending 但不需要接单 → 落进行中档");
}
@Test
@DisplayName("关键字按运次匹配:任一子单或配载单号命中即整趟车命中,不缩小这一趟车的子单集合")
void keywordMatchesWholeTrip() {
stubRowsBySql(
List.of(waybill(9L, "YD-9", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)),
null,
List.of(
waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES),
waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setKeyword("PZ-20261017");
service.page(query);
TripVO trip = service.page(query).getRecords().get(0);
String sql = capturedTripSql();
assertTrue(sql.contains("IN (SELECT IFNULL(NULLIF(k.loading_no, ''), CONCAT('W', k.id)) FROM blade_waybill k"),
"关键字要用子单上的运次键反查整趟车:" + sql);
assertTrue(sql.contains("k.waybill_no LIKE ?"), sql);
assertTrue(sql.contains("k.loading_no LIKE ?"), sql);
String keywordSql = capturedKeywordSql();
assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql);
assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql);
assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小");
assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql());
}
@Test
@DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式")
void unloadedWaybillIsItsOwnTrip() {
// 页里的分组行只投影出主键与分组键,子运单按主键回查后才是真身
stubPage(71, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper()))
.thenReturn(List.of(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null)));
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertEquals(71, page.getTotal());
assertEquals(1, page.getTotal());
assertEquals(1, page.getRecords().size());
TripVO trip = page.getRecords().get(0);
assertEquals("2110690671695380513", trip.getId());
@@ -151,14 +203,13 @@ class TripQueryServiceImplTest {
assertEquals(1, trip.getWaybillCount());
assertEquals(1, trip.getWaybills().size());
assertEquals("2110690671695380513", trip.getWaybills().get(0).getId());
verify(waybillService, times(1)).list(anyWaybillWrapper());
}
@Test
@DisplayName("未配载运次的状态与接单需求取回查到的运单真身,分组行里没有状态列")
@DisplayName("未配载运次的状态与接单需求来自段一取回的那张运单")
void unloadedTripStateComesFromFetchedWaybill() {
stubPage(1, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", null, ACCEPT_YES)));
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
@@ -170,28 +221,27 @@ class TripQueryServiceImplTest {
}
@Test
@DisplayName("未配载运单在两次查询之间被删或被拒单时不给出没有子单的运次")
void loneWaybillMissingInSecondQueryIsDropped() {
stubPage(1, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
@DisplayName("配载单在两次查询之间被删掉时,不给出没有主键的运次")
void loadingMissingIsDropped() {
stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty());
assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车");
assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在");
}
@Test
@DisplayName("配载运次:主键取配载单主键,子单一次批量取回,状态与排序键按子单聚合")
@DisplayName("配载运次:主键取配载单主键,子单就是段一取回的行,状态与排序键按子单聚合")
void loadingTripAggregatesChildren() {
Date earlier = new Date(1787657033000L);
Date latest = new Date(1787671422000L);
stubPage(1, waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier));
Date earlier = at(1787657033000L);
Date latest = at(1787671422000L);
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier),
waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier),
waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
@@ -211,11 +261,10 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("配载运次的状态与接单态由子单聚合:需接单未接为待接单,无需接单为运输中")
void loadingTripStatusFollowsProcessConfig() {
stubPage(1, waybill(2092186187239780353L, "YD-1", LOADING_NO, "running", null, null));
stubRows(
waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", null, ACCEPT_YES),
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, null, ACCEPT_NO)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
@@ -257,52 +306,131 @@ class TripQueryServiceImplTest {
assertTrue(page.getRecords().isEmpty());
assertEquals(0, counts.getAll());
assertThrows(ServiceException.class, () -> service.detail(blank));
verify(waybillService, never()).list(anyWaybillWrapper());
verify(waybillService, never()).page(any(), any());
}
@Test
@DisplayName("分页即分运次:总数与顺序照库层给的来,服务端不重排")
@DisplayName("分页落在过滤后的完整运次序列上:总数即运次数,换每页条数不串页")
void pagingIsPerTrip() {
stubPage(71,
waybill(3L, null, null, null, null, null),
waybill(2L, null, null, null, null, null),
waybill(1L, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(3L, "YD-3", null, "running", null, null),
waybill(2L, "YD-2", null, "completed", null, null),
waybill(1L, "YD-1", null, "running", null, null)));
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setCurrent(2);
query.setSize(10);
stubRows(
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_YES),
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
IPage<TripVO> page = service.page(query);
TripQueryVO firstPage = TripQueryVO.forDriver(PHONE);
firstPage.setCurrent(1);
firstPage.setSize(2);
assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数");
assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList());
assertEquals(71, page.getTotal(), "总数是库层按运次算的");
assertEquals(List.of("3", "2", "1"), page.getRecords().stream().map(TripVO::getId).toList(),
"顺序就是库层给的顺序");
ArgumentCaptor<Page<Waybill>> captor = pageCaptor();
verify(waybillService).page(captor.capture(), any());
assertEquals(2, captor.getValue().getCurrent());
assertEquals(10, captor.getValue().getSize(), "每页 10 条即 10 个运次");
TripQueryVO secondPage = TripQueryVO.forDriver(PHONE);
secondPage.setCurrent(2);
secondPage.setSize(2);
assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(),
"第 2 页接着第 1 页,不重复不遗漏");
TripQueryVO bySeven = TripQueryVO.forDriver(PHONE);
bySeven.setCurrent(1);
bySeven.setSize(7);
assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变");
assertTrue(service.page(bySeven).getCurrent() == 1);
}
@Test
@DisplayName("Tab 统计按运次计数,全部=待接单+进行中+已完成")
@DisplayName("Tab 统计一次取数算清四档,全部=待接单+进行中+已完成")
void tabCountsPerTrip() {
long[] totals = {3, 24, 44};
AtomicInteger calls = new AtomicInteger();
when(waybillService.page(any(), any())).thenAnswer(invocation -> {
Page<Waybill> page = invocation.getArgument(0);
page.setTotal(totals[calls.getAndIncrement()]);
return page;
});
stubRows(
waybill(4L, "YD-4", null, "pending", "pending", at(4000L), ACCEPT_YES),
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO));
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE));
assertEquals(3, counts.getPending());
assertEquals(24, counts.getDoing());
assertEquals(44, counts.getDone());
assertEquals(71, counts.getAll());
assertEquals(1, counts.getPending());
assertEquals(2, counts.getDoing());
assertEquals(1, counts.getDone());
assertEquals(4, counts.getAll(), "全部不含已取消档");
verify(waybillService, times(1)).list(anyWaybillWrapper());
}
@Test
@DisplayName("待处理两档与「未拒单」都走同一次取数:多档筛选与已拒运次排除")
void multiTierAndRejectedExclusion() {
stubRowsBySql(null,
// 被拒子单的运次键:只有 YD-9 那一趟
List.of(waybill(9L, "YD-9", null, "pending", "rejected", at(500L), ACCEPT_NO)),
List.of(
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "pending", "pending", at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO)));
TripQueryVO query = TripQueryVO.forAdmin();
query.setStatuses(List.of(0, 1));
query.setExcludeRejectedTrips(true);
List<String> ids = service.page(query).getRecords().stream().map(TripVO::getId).toList();
assertEquals(List.of("3", "2"), ids, "只要待接单与进行中两档");
assertEquals(2, service.page(query).getTotal());
}
@Test
@DisplayName("管理端筛选只决定哪些运次在范围内,分档仍看整趟车的子单")
void filtersDoNotShrinkTheTieringChildSet() {
// 一趟车两张子单:一张已完成(命中异常子单,也落在创建时间区间内)、一张还在运输中
stubRows(
waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripQueryVO query = TripQueryVO.forAdmin();
query.setException("exception");
query.setExceptionWaybillIds(java.util.Set.of(2L));
query.setCreatedFrom(at(1900L));
query.setCreatedTo(at(2100L));
TripVO trip = service.page(query).getRecords().get(0);
assertEquals(2, trip.getWaybillCount(), "筛选不缩小子单集合");
assertEquals(1, trip.getStatus(), "整趟车还在运输中,不能因为命中的是那张已完成的子单就归到已完成档");
}
@Test
@DisplayName("管理端筛选:组织类型按子单行筛(同一趟车里是同一个值),异常与创建时间不进 SQL")
void adminFiltersStayAtTripGranularity() {
TripQueryVO query = TripQueryVO.forAdmin();
query.setTransportOrgType("common");
query.setException("normal");
query.setExceptionWaybillIds(java.util.Set.of(7L, 8L));
query.setCreatedFrom(at(1000L));
query.setCreatedTo(at(2000L));
service.page(query);
String sql = capturedRowSql();
assertTrue(sql.contains("IFNULL(loading_no, '') = ''"), "普通运输在库层按子单行筛:" + sql);
assertFalse(sql.contains("id NOT IN ("), "异常要看整趟车,不能在库层缩小子单集合:" + sql);
assertFalse(sql.contains("create_time"), "创建时间按运次排序键在服务层判:" + sql);
assertFalse(sql.contains("blade_loading_manage"), "管理端不按司机收口:" + sql);
}
@Test
@DisplayName("创建时间按子单 create_time 的最大值(卡片上显示的就是它)判区间")
void createdRangeUsesTripSortKey() {
// 两张子单:一张在区间外、一张在区间内,运次的排序键取后创建的那张
stubRows(
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_NO));
TripQueryVO inside = TripQueryVO.forAdmin();
inside.setCreatedFrom(at(1900L));
inside.setCreatedTo(at(2100L));
assertEquals(1, service.page(inside).getTotal(), "排序键 2000 落在区间内 → 这趟车在范围内");
TripQueryVO outside = TripQueryVO.forAdmin();
outside.setCreatedFrom(at(1100L));
outside.setCreatedTo(at(1900L));
assertEquals(0, service.page(outside).getTotal(), "排序键 2000 不在区间内 → 这趟车不在范围内");
}
@Test
@@ -310,7 +438,7 @@ class TripQueryServiceImplTest {
void detailResolvesBothTripKinds() {
when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null);
when(waybillService.getById(WAYBILL_ID))
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null));
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
stubChildren();
@@ -329,7 +457,7 @@ class TripQueryServiceImplTest {
void detailRefusesForeignTrips() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading("13800000001"));
when(waybillService.getById(WAYBILL_ID))
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null));
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
@@ -345,13 +473,14 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("已拒单的子单不进运次,整趟车只剩被拒的那张时该运次不再出现")
void rejectedChildrenLeaveTheTrip() {
stubPage(1, waybill(LOADING_ID, "YD-20260919-0001", LOADING_NO, "pending", null, null));
// 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在
stubRows();
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty(), "配载单还在但可见子单为空时不给没有子单的运次");
assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次");
assertEquals(0, page.getTotal());
}
// ------------------------------------------------------------ 夹具
@@ -366,25 +495,42 @@ class TripQueryServiceImplTest {
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
}
private void stubPage(long total, Waybill... rows) {
when(waybillService.page(any(), any())).thenAnswer(invocation -> {
Page<Waybill> page = invocation.getArgument(0);
page.setRecords(List.of(rows));
page.setTotal(total);
return page;
@SuppressWarnings("unchecked")
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
/** 段一取回的可见运单行 */
private void stubRows(Waybill... rows) {
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(rows));
}
/**
* 关键字先查一次命中的运次键、再查一次运单行,两次都走 list:按 SQL 里有没有 LIKE 分流
*/
private void stubRowsBySql(List<Waybill> keywordRows, List<Waybill> rejectedRows, List<Waybill> rows) {
when(waybillService.list(anyWaybillWrapper())).thenAnswer(invocation -> {
Wrapper<Waybill> wrapper = invocation.getArgument(0);
String sql = String.valueOf(wrapper.getTargetSql());
if (sql.contains("LIKE")) {
return keywordRows;
}
// 被拒运次那次按等值找已拒子单;段一那次用 IS NULL 兼容为空
return sql.contains("IS NULL") ? rows : rejectedRows;
});
}
private void stubOneLoadedTrip() {
stubPage(1, waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, null));
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
stubChildren();
}
private void stubChildren() {
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, null, ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", null, ACCEPT_NO)));
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO)));
}
private TripQueryVO tripQuery(String tripId) {
@@ -404,8 +550,8 @@ class TripQueryServiceImplTest {
return loading;
}
private Waybill waybill(Long id, String no, String loadingNo, String businessStatus, String acceptStatus, Date createTime) {
return waybill(id, no, loadingNo, businessStatus, acceptStatus, createTime, ACCEPT_NO);
private static Date at(long millis) {
return new Date(millis);
}
private Waybill waybill(Long id, String no, String loadingNo, String businessStatus, String acceptStatus,
@@ -423,19 +569,34 @@ class TripQueryServiceImplTest {
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<QueryWrapper<Waybill>> queryCaptor() {
return ArgumentCaptor.forClass(QueryWrapper.class);
private ArgumentCaptor<LambdaQueryWrapper<Waybill>> rowCaptor() {
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
}
/** 段一的运单行查询:按可见口径那次(带 IS NULL)取,关键字与已拒运次那两次另算 */
@SuppressWarnings("unchecked")
private ArgumentCaptor<Page<Waybill>> pageCaptor() {
return ArgumentCaptor.forClass(Page.class);
private LambdaQueryWrapper<Waybill> capturedRowQuery() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService, org.mockito.Mockito.atLeastOnce()).list(captor.capture());
return (LambdaQueryWrapper<Waybill>) captor.getAllValues().stream()
.filter(wrapper -> String.valueOf(wrapper.getTargetSql()).contains("IS NULL"))
.findFirst()
.orElseThrow(() -> new AssertionError("没有发出段一的运单行查询"));
}
private String capturedTripSql() {
ArgumentCaptor<QueryWrapper<Waybill>> captor = queryCaptor();
verify(waybillService).page(any(), captor.capture());
return captor.getValue().getTargetSql();
private String capturedRowSql() {
return String.valueOf(capturedRowQuery().getTargetSql());
}
/** 关键字那次查询:带 LIKE 的那次调用 */
private String capturedKeywordSql() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService, times(2)).list(captor.capture());
return captor.getAllValues().stream()
.map(wrapper -> String.valueOf(wrapper.getTargetSql()))
.filter(sql -> sql.contains("LIKE"))
.findFirst()
.orElseThrow(() -> new AssertionError("没有发出关键字查询"));
}
}
@@ -13,10 +13,12 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IWaybillService;
import java.math.BigDecimal;
@@ -53,6 +55,7 @@ class TripReadServiceBoundaryTest {
private IWaybillService waybillService;
private ILoadingManageService loadingManageService;
private IProcessConfigService processConfigService;
private TripQueryServiceImpl service;
@BeforeAll
@@ -68,7 +71,9 @@ class TripReadServiceBoundaryTest {
void setUp() {
waybillService = mock(IWaybillService.class);
loadingManageService = mock(ILoadingManageService.class);
service = new TripQueryServiceImpl(waybillService, loadingManageService);
processConfigService = mock(IProcessConfigService.class);
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
}
@@ -94,7 +99,10 @@ class TripReadServiceBoundaryTest {
child(CHILD_A, "cancelled", null, ACCEPT_NO, at(1787657033000L)),
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
TripVO trip = onlyTrip();
// 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(3);
TripVO trip = onlyTrip(query);
assertEquals(3, trip.getStatus(), "已取消子单必须进运次,否则全取消会被算成运输中");
}
@@ -223,13 +231,8 @@ class TripReadServiceBoundaryTest {
}
@Test
@DisplayName("未配载运次按主键回查真身:分组行没有状态列,拿它当子单会让接单需求恒为 false")
@DisplayName("未配载运次的状态与接单需求取自段一取回的那张运单")
void loneTripReadsStateFromFetchedWaybill() {
// 段一给的行只有主键、分组键与排序键,真身要靠段二回查
Waybill row = new Waybill();
row.setId(CHILD_A);
row.setCreateTime(at(1787657033000L));
stubPage(1, row);
Waybill bare = new Waybill();
bare.setId(CHILD_A);
bare.setWaybillNo("YD-20260920-0003");
@@ -296,27 +299,23 @@ class TripReadServiceBoundaryTest {
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
/** 配载运次:段一给一行分组行,段二给配载单与这一组的子单 */
private void stubLoadedTrip(Waybill... children) {
Waybill row = new Waybill();
row.setId(children.length == 0 ? CHILD_A : children[0].getId());
row.setLoadingNo(LOADING_NO);
stubPage(1, row);
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading()));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(children));
}
private void stubPage(long total, Waybill... rows) {
when(waybillService.page(any(), any())).thenAnswer(invocation -> {
Page<Waybill> page = invocation.getArgument(0);
page.setRecords(List.of(rows));
page.setTotal(total);
return page;
});
private TripVO onlyTrip() {
return onlyTrip(TripQueryVO.forDriver(PHONE));
}
private TripVO onlyTrip() {
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
private TripVO onlyTrip(TripQueryVO query) {
IPage<TripVO> page = service.page(query);
assertEquals(1, page.getRecords().size(), "本页应只有一个运次");
return page.getRecords().get(0);
}