✨ 新增运次读服务,小程序列表与详情按运次聚合

运次=有配载单时那张配载单(含全部子运单),没有配载时就是一张运单。
一次库层查询按运次分组完成状态聚合、接单需求合并、排序与分页,
供司机端与管理端共用;聚合读只读,不触发接单态归一化的写库。
This commit is contained in:
2026-10-17 17:24:36 +08:00
parent 60e2afe938
commit 9a43863c09
8 changed files with 1642 additions and 0 deletions
@@ -0,0 +1,77 @@
/**
* 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.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 运次查询条件
* <p>
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
*/
@Data
@Schema(description = "运次查询条件")
public class TripQueryVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键(详情按它取单个运次)")
private String tripId;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
private Integer status;
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
private String keyword;
@Schema(description = "当前页,从 1 开始")
private Integer current;
@Schema(description = "每页条数(按运次,不是运单)")
private Integer size;
public static TripQueryVO forDriver(String driverPhone) {
TripQueryVO query = new TripQueryVO();
query.setDriverPhone(driverPhone);
return query;
}
public static TripQueryVO forAdmin() {
TripQueryVO query = new TripQueryVO();
query.setAllDrivers(true);
return query;
}
}
@@ -0,0 +1,55 @@
/**
* 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.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 运次 Tab 统计
* <p>
* 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。
*/
@Data
@Schema(description = "运次 Tab 统计")
public class TripTabCountsVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "全部(待接单+进行中+已完成,不含已取消)")
private long all;
@Schema(description = "待接单")
private long pending;
@Schema(description = "进行中")
private long doing;
@Schema(description = "已完成")
private long done;
}
@@ -0,0 +1,181 @@
/**
* 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.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
* 运次:一趟车的展示与操作单位
* <p>
* 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。
* 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。
*/
@Data
@Schema(description = "运次")
public class TripVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String id;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输")
private String transportOrgType;
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消,子单集合为空时为空")
private Integer status;
@Schema(description = "排序键:运次内子运单 create_time 的最大值")
private Date sortTime;
@Schema(description = "是否需要接单:任一子运单的项目有接单节点即为是")
private Boolean requireAccept;
@Schema(description = "接单态:accepted 已接单 / pending 待接单,无从判定时为空")
private String acceptStatus;
@Schema(description = "子运单数(可见口径:已排除本司机拒单的子单)")
private int waybillCount;
@Schema(description = "司机姓名")
private String driverName;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "车牌号")
private String vehicleNo;
@Schema(description = "挂车车牌号")
private String trailerVehicleNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "运输类型")
private String transportType;
@Schema(description = "货物名称")
private String cargoName;
@Schema(description = "货物类型")
private String cargoType;
@Schema(description = "发货地址")
private String departureAddress;
@Schema(description = "途经地")
private String transitAddress;
@Schema(description = "到货地")
private String arrivalAddress;
@Schema(description = "路线信息,仅单个运次详情返回(列表不带,省流量)")
private String routeJson;
@Schema(description = "货物信息,仅单个运次详情返回(列表不带,省流量)")
private String goodsJson;
@Schema(description = "子运单(可见子单,按创建时间升序)")
private List<TripWaybillVO> waybills;
/**
* 运次下的子运单
* <p>
* 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。
*/
@Data
@Schema(description = "运次下的子运单")
public static class TripWaybillVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运单主键,按字符串下发")
private String id;
@Schema(description = "运单号")
private String waybillNo;
@Schema(description = "业务状态")
private String businessStatus;
@Schema(description = "接单状态:pending待接单/accepted已接单/rejected已拒绝")
private String acceptStatus;
@Schema(description = "该运单是否需要接单确认")
private Boolean requireAccept;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "货物名称")
private String cargoName;
@Schema(description = "货物类型")
private String cargoType;
@Schema(description = "数量")
private BigDecimal quantity;
@Schema(description = "数量单位")
private String quantityUnit;
@Schema(description = "发货地")
private String departureName;
@Schema(description = "发货地址")
private String departureAddress;
@Schema(description = "收货地")
private String arrivalName;
@Schema(description = "收货地址")
private String arrivalAddress;
@Schema(description = "创建时间")
private Date createTime;
}
}
@@ -0,0 +1,62 @@
/**
* 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.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
/**
* 运次读服务
* <p>
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态。
*/
public interface ITripQueryService {
/**
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个),筛选、排序与分页都在库层按运次完成。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @return 运次分页
*/
IPage<TripVO> page(TripQueryVO query);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成
*
* @param query 查询条件,口径同 page
* @return 运次计数
*/
TripTabCountsVO tabCounts(TripQueryVO query);
/**
* 按运次主键取单个运次(含子运单),主键为字符串
*
* @param query 查询条件,tripId 必填
* @return 运次
*/
TripVO detail(TripQueryVO query);
}
@@ -0,0 +1,198 @@
/**
* 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.service.impl;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.support.WaybillProcessSupport;
import java.util.List;
import java.util.stream.Collectors;
/**
* 运次聚合口径:状态四档 + 接单需求 OR
* <p>
* 四档判据有两种渲染:库层聚合条件用于 Tab 过滤与分页(配载段按配载单号分组,先筛后排序再分页),
* Java 判定用于摆上一页的运次给状态。两种渲染必须同档。
* <p>
* 接单需求要解析过程配置,库层表达不出来,只能在本页复核:库里落的是业务状态,
* 而「需接单未接」与「无需接单」两种情况在库里可能都是待接单档。
*/
final class TripAggregation {
/** 运单业务状态 */
static final String PENDING = "pending";
static final String WAITING_DISPATCH = "waiting_dispatch";
static final String DISPATCHING = "dispatching";
static final String RUNNING = "running";
static final String COMPLETED = "completed";
static final String CANCELLED = "cancelled";
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
/** 终态档 */
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
/**
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
* 否则「子单全已取消」会被算成运输中而不是已取消。
*/
static final List<String> CHILD_STATUSES = List.of(
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";
/** 四档的库层判据: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;
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 + ")";
};
}
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
static Integer appStatus(String status) {
return switch (Func.toStr(status, "")) {
case PENDING -> 0;
case RUNNING -> 1;
case COMPLETED -> 2;
case CANCELLED -> 3;
default -> null;
};
}
/**
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
* 否则需接单且未接单取待接单,其余取运输中。
* <p>
* 子单集合为空时无从判定,返回空。
*/
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
String status = classify(count(children));
if (status == null || TERMINAL_STATUSES.contains(status)) {
return status;
}
if (!requireAccept) {
return RUNNING;
}
return accepted ? RUNNING : PENDING;
}
/**
* 库层聚合结果:子单总数、终态数、已完成数、待接单档数
*/
static Counters count(List<Waybill> children) {
int total = 0;
int terminal = 0;
int completed = 0;
int pending = 0;
for (Waybill child : children) {
String status = child == null ? null : child.getBusinessStatus();
total++;
if (TERMINAL_STATUSES.contains(status)) {
terminal++;
}
if (COMPLETED.equals(status)) {
completed++;
}
if (PENDING_STATUSES.contains(status)) {
pending++;
}
}
return new Counters(total, terminal, completed, pending);
}
private static String classify(Counters counters) {
if (counters.total() == 0) {
return null;
}
if (counters.terminal() == counters.total()) {
return counters.completed() > 0 ? COMPLETED : CANCELLED;
}
return counters.pending() > 0 ? PENDING : RUNNING;
}
/**
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
* <p>
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
*/
static boolean requiresAccept(List<Waybill> children) {
return children.stream()
.anyMatch(child -> child != null
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
}
/**
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
* 否则有子单待接单为待接单,无从判定时为空。
*/
static String acceptStatus(List<Waybill> children) {
boolean anyPending = false;
for (Waybill child : children) {
String status = child == null ? null : child.getDriverAcceptStatus();
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
return WaybillProcessSupport.ACCEPT_ACCEPTED;
}
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
anyPending = true;
}
}
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) {
}
}
@@ -0,0 +1,483 @@
/**
* 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.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.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.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 运次读服务实现
* <p>
* 取数两段,段内都是批量查询:段一在库层按运次分组聚合,一次查询完成筛选、排序与分页;
* 段二按这一页的运次主键与配载单号批量取配载单与子运单。
* <p>
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
* 于是整趟车被拒单后整个运次自然消失。
*/
@Service
@RequiredArgsConstructor
public class TripQueryServiceImpl implements ITripQueryService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
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)";
/** 关键字在子单上匹配的字段 */
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 final IWaybillService waybillService;
private final ILoadingManageService loadingManageService;
@Override
public IPage<TripVO> page(TripQueryVO query) {
TripQueryVO condition = condition(query);
int pageNo = pageNo(condition.getCurrent());
int pageSize = pageSize(condition.getSize());
if (!scoped(condition)) {
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
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()));
return page;
}
@Override
public TripTabCountsVO tabCounts(TripQueryVO query) {
TripQueryVO condition = condition(query);
TripTabCountsVO counts = new TripTabCountsVO();
if (!scoped(condition)) {
return counts;
}
long pending = countTrips(condition, 0);
long doing = countTrips(condition, 1);
long done = countTrips(condition, 2);
counts.setPending(pending);
counts.setDoing(doing);
counts.setDone(done);
counts.setAll(pending + doing + done);
return counts;
}
@Override
public TripVO detail(TripQueryVO query) {
TripQueryVO condition = condition(query);
Long id = tripId(condition.getTripId());
if (id == null || !scoped(condition)) {
throw new ServiceException("运次不存在或无权操作");
}
LoadingManage loading = visibleLoading(id, condition);
if (loading != null) {
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()))
.getOrDefault(loading.getLoadingNo(), List.of());
if (children.isEmpty()) {
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
throw new ServiceException("运次不存在或无权操作");
}
return loadingTrip(loading, children, true);
}
Waybill waybill = visibleWaybill(id, condition);
if (waybill == null) {
throw new ServiceException("运次不存在或无权操作");
}
return waybillTrip(waybill, true);
}
/**
* 单个 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();
}
/**
* 库层运次查询:按运次分组,一次完成筛选、排序与分页
* <p>
* 分组键与排序键都在 SQL 里,Tab 过滤是分组后的聚合条件(HAVING)——先分页再在服务端过滤会让
* 本该在第一页的运次漏到后面几页。
*/
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);
applyScope(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;
}
/**
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
* <p>
* 手机号允许带空格,与运单列表的收口判据一致。
*/
private void applyScope(QueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (condition.isAllDrivers()) {
return;
}
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
wrapper.and(scope -> scope
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
+ " 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)
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
}
/**
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
* <p>
* 关键字不缩小这一组的子单集合,否则状态与排序会按命中的那一部分子单算。
*/
private void applyKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isEmpty(keyword)) {
return;
}
String like = "%" + keyword.trim() + "%";
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] = 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);
}
/**
* 把这一页的运次行组装成运次:先批量取配载单与子运单,再逐行拼装
*/
private List<TripVO> assemble(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
return new ArrayList<>();
}
Set<String> loadingNos = rows.stream()
.map(Waybill::getLoadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos);
List<TripVO> trips = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
if (Func.isEmpty(loadingNo)) {
trips.add(waybillTrip(row, false));
continue;
}
LoadingManage loading = loadings.get(loadingNo);
if (loading == null) {
// 配载单被并发删除:不给出没有主键的运次
continue;
}
List<Waybill> group = children.getOrDefault(loadingNo, List.of());
if (group.isEmpty()) {
// 子单全部被拒单:整趟车对司机不可见,与详情口径一致
continue;
}
trips.add(loadingTrip(loading, group, false));
}
return trips;
}
/**
* 段二:按本页运次的配载单号一次取回配载单
*/
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
.in(LoadingManage::getLoadingNo, loadingNos))
.stream()
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
}
/**
* 段二:按本页运次的配载单号一次取回子运单,不逐条查
* <p>
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
*/
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.in(Waybill::getLoadingNo, loadingNos)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)));
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
grouped.computeIfAbsent(child.getLoadingNo(), key -> new ArrayList<>()).add(child);
}
grouped.values().forEach(group -> group.sort(
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
return grouped;
}
/**
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
*/
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
TripVO trip = new TripVO();
trip.setId(tripIdOf(loading.getId()));
trip.setLoadingNo(loading.getLoadingNo());
trip.setTripNo(loading.getLoadingNo());
trip.setTransportOrgType(TRANSPORT_LOAD);
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
if (withPayload) {
trip.setRouteJson(loading.getRouteJson());
trip.setGoodsJson(loading.getGoodsJson());
}
fillAggregation(trip, children);
return trip;
}
/**
* 未配载运次:整张运单就是这一趟车
*/
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
TripVO trip = new TripVO();
trip.setId(tripIdOf(waybill.getId()));
trip.setTripNo(waybill.getWaybillNo());
trip.setTransportOrgType(TRANSPORT_COMMON);
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
if (withPayload) {
trip.setRouteJson(waybill.getRouteJson());
trip.setGoodsJson(waybill.getGoodsJson());
}
fillAggregation(trip, List.of(waybill));
return trip;
}
/**
* 聚合出运次的状态、接单需求、接单态与排序键
*/
private void fillAggregation(TripVO trip, List<Waybill> children) {
String acceptStatus = TripAggregation.acceptStatus(children);
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
trip.setAcceptStatus(acceptStatus);
trip.setRequireAccept(requireAccept);
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
trip.setWaybillCount(children.size());
trip.setSortTime(maxCreateTime(children));
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
}
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(tripIdOf(waybill.getId()));
child.setWaybillNo(waybill.getWaybillNo());
child.setBusinessStatus(waybill.getBusinessStatus());
child.setAcceptStatus(waybill.getDriverAcceptStatus());
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
child.setProjectId(waybill.getProjectId());
child.setProjectName(waybill.getProjectName());
child.setCargoName(waybill.getCargoName());
child.setCargoType(waybill.getCargoType());
child.setQuantity(waybill.getQuantity());
child.setQuantityUnit(waybill.getQuantityUnit());
child.setDepartureName(waybill.getDepartureName());
child.setDepartureAddress(waybill.getDepartureAddress());
child.setArrivalName(waybill.getArrivalName());
child.setArrivalAddress(waybill.getArrivalAddress());
child.setCreateTime(waybill.getCreateTime());
return child;
}
/** 排序键:子单 create_time 的最大值,与库层排序用的是同一个聚合 */
private Date maxCreateTime(List<Waybill> children) {
return children.stream()
.map(Waybill::getCreateTime)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.orElse(null);
}
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
if (Func.isNotEmpty(preferred)) {
return preferred;
}
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
}
/**
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
*/
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
LoadingManage loading = loadingManageService.getById(id);
if (loading == null) {
return null;
}
if (condition.isAllDrivers()) {
return loading;
}
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
}
/**
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
*/
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
Waybill waybill = waybillService.getById(id);
if (waybill == null
|| Func.isNotEmpty(waybill.getLoadingNo())
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
return null;
}
if (condition.isAllDrivers()) {
return waybill;
}
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
}
private boolean scoped(TripQueryVO condition) {
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
}
private boolean samePhone(String left, String right) {
String expected = Func.toStr(right, "").trim().replace(" ", "");
if (Func.isEmpty(expected)) {
return false;
}
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
}
private TripQueryVO condition(TripQueryVO query) {
return query == null ? new TripQueryVO() : query;
}
private TripQueryVO withStatus(TripQueryVO source, Integer status) {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setAllDrivers(source.isAllDrivers());
copy.setKeyword(source.getKeyword());
copy.setStatus(status);
return copy;
}
private Long tripId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
return null;
}
}
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
private String tripIdOf(Long id) {
return id == null ? null : String.valueOf(id);
}
private int pageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
private int pageSize(Integer size) {
if (size == null || size < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(size, MAX_PAGE_SIZE);
}
}
@@ -0,0 +1,179 @@
package org.springblade.transport.service.impl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.entity.Waybill;
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 过滤条件的断言兜住,两边必须同档。
*/
class TripAggregationTest {
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
private static final String ACCEPT_YES_DISABLED = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":false,\"confirmMode\":\"yes\"}]";
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
@Test
@DisplayName("子单全部完成 → 已完成")
void allCompleted() {
List<Waybill> children = List.of(waybill("completed", null, null), waybill("completed", "accepted", null));
assertEquals("completed", TripAggregation.statusOf(children, false, false));
assertEquals(2, TripAggregation.appStatus(TripAggregation.statusOf(children, false, false)));
}
@Test
@DisplayName("子单全部取消 → 已取消")
void allCancelled() {
List<Waybill> children = List.of(waybill("cancelled", null, null), waybill("cancelled", null, null));
String status = TripAggregation.statusOf(children, false, false);
assertEquals("cancelled", status);
assertEquals(3, TripAggregation.appStatus(status));
}
@Test
@DisplayName("子单全是终态且含已完成 → 已完成(不看取消的那张)")
void mixedTerminalKeepsCompleted() {
List<Waybill> children = List.of(waybill("completed", null, null), waybill("cancelled", null, null));
assertEquals("completed", TripAggregation.statusOf(children, false, false));
}
@Test
@DisplayName("需接单且未接单 → 待接单")
void requireAcceptWithoutAcceptance() {
List<Waybill> children = List.of(waybill("pending", "pending", ACCEPT_YES));
String status = TripAggregation.statusOf(children, true, false);
assertEquals("pending", status);
assertEquals(0, TripAggregation.appStatus(status));
}
@Test
@DisplayName("不需接单 → 运输中")
void withoutAcceptRequirement() {
List<Waybill> children = List.of(waybill("pending", null, ACCEPT_NO));
String status = TripAggregation.statusOf(children, false, false);
assertEquals("running", status);
assertEquals(1, TripAggregation.appStatus(status));
}
@Test
@DisplayName("需接单且已接单 → 运输中")
void requireAcceptAccepted() {
List<Waybill> children = List.of(waybill("pending", "accepted", ACCEPT_YES));
assertEquals("running", TripAggregation.statusOf(children, true, true));
}
@Test
@DisplayName("子单还有没走完的且需接单 → 待接单")
void pendingChildKeepsTripPending() {
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES), waybill("dispatching", null, null));
assertEquals("pending", TripAggregation.statusOf(children, true, false));
}
@Test
@DisplayName("子单集合为空不抛异常,状态无可判定为空")
void emptyChildrenDoNotThrow() {
List<Waybill> children = List.of();
assertNull(TripAggregation.statusOf(children, true, false));
assertNull(TripAggregation.appStatus(null));
assertEquals(0, TripAggregation.count(children).total());
assertNull(TripAggregation.acceptStatus(children));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("跨项目运次按 OR 合并接单需求:一张要接单整趟车都要接单")
void crossProjectRequirementOr() {
List<Waybill> children = List.of(
waybill("running", null, null),
waybill("running", null, ACCEPT_YES));
assertTrue(TripAggregation.requiresAccept(children));
assertEquals("pending", TripAggregation.statusOf(children, true, false));
}
@Test
@DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
void crossProjectWithoutAnyRequirement() {
List<Waybill> children = List.of(
waybill("running", null, null),
waybill("running", null, ACCEPT_NO));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("停用的接单节点不算需要接单")
void disabledAcceptNodeIsNotRequirement() {
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES_DISABLED));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("接单态:任一子单已接单即整单已接单,否则待接单,无从判定为空")
void acceptStatusAggregation() {
assertEquals("accepted", TripAggregation.acceptStatus(List.of(
waybill("running", "pending", ACCEPT_YES), waybill("running", "accepted", ACCEPT_YES))));
assertEquals("pending", TripAggregation.acceptStatus(List.of(
waybill("running", "pending", ACCEPT_YES), waybill("running", null, ACCEPT_YES))));
assertNull(TripAggregation.acceptStatus(List.of(waybill("completed", null, null))));
}
@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);
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 childStatusScope() {
assertFalse(TripAggregation.CHILD_STATUSES.contains("draft"));
assertTrue(TripAggregation.CHILD_STATUSES.contains("cancelled"));
assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES));
}
private Waybill waybill(String businessStatus, String acceptStatus, String processJson) {
Waybill waybill = new Waybill();
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setProcessJson(processJson);
return waybill;
}
}
@@ -0,0 +1,407 @@
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.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;
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.LoadingManage;
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.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;
import static org.junit.jupiter.api.Assertions.assertNull;
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.lenient;
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;
/**
* 运次读服务的取数两段与聚合输出。
* <p>
* 单元测试里 mapper 是 mock、不会真的执行 SQL,所以「筛选排序分页在库层按运次完成」只能断言 SQL 本身;
* 摆出来的运次行则按「库层已经给好的输入」来验,不假装 mock 会替我们分组。
*/
class TripQueryServiceImplTest {
private static final String PHONE = "17587421208";
private static final String LOADING_NO = "PZ-20261017-001";
private static final Long LOADING_ID = 2111350784070275123L;
private static final Long WAYBILL_ID = 2110690671695380513L;
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
private IWaybillService waybillService;
private ILoadingManageService loadingManageService;
private TripQueryServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
loadingManageService = mock(ILoadingManageService.class);
service = new TripQueryServiceImpl(waybillService, loadingManageService);
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
}
@Test
@DisplayName("取数在库层按运次分组:分组键、排序键、Tab 过滤与司机收口都在 SQL 里")
void tripQueryGroupsInDatabase() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(1);
query.setCurrent(1);
query.setSize(10);
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);
assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql);
assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql);
assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"),
"未配载段按运单司机收口,且容忍手机号带空格:" + sql);
}
@Test
@DisplayName("Tab 过滤落在 HAVING 上,不在服务端过滤")
void tabFilterLandsInHaving() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(2);
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);
}
@Test
@DisplayName("关键字按运次匹配:子单或配载单号命中即整趟车命中,不缩小这一组的子单集合")
void keywordMatchesWholeTrip() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setKeyword("PZ-20261017");
service.page(query);
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);
}
@Test
@DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式")
void unloadedWaybillIsItsOwnTrip() {
stubPage(71, waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertEquals(71, page.getTotal());
assertEquals(1, page.getRecords().size());
TripVO trip = page.getRecords().get(0);
assertEquals("2110690671695380513", trip.getId());
assertEquals(19, trip.getId().length(), "19 位雪花号按字符串下发,不被取整");
assertNull(trip.getLoadingNo());
assertEquals("common", trip.getTransportOrgType());
assertEquals("YD-20260920-0003", trip.getTripNo());
assertEquals(1, trip.getStatus());
assertEquals(1, trip.getWaybillCount());
assertEquals(1, trip.getWaybills().size());
assertEquals("2110690671695380513", trip.getWaybills().get(0).getId());
}
@Test
@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));
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);
assertEquals(String.valueOf(LOADING_ID), trip.getId());
assertEquals(LOADING_NO, trip.getLoadingNo());
assertEquals(LOADING_NO, trip.getTripNo());
assertEquals("load", trip.getTransportOrgType());
assertEquals(4, trip.getWaybillCount());
assertEquals(4, trip.getWaybills().size());
assertEquals(1, trip.getStatus(), "四张子单都在运输中");
assertEquals(Boolean.FALSE, trip.getRequireAccept());
assertEquals(latest, trip.getSortTime(), "排序键是子单 create_time 的最大值");
verify(waybillService, times(1)).list(anyWaybillWrapper());
verify(loadingManageService, times(1)).list(anyLoadingWrapper());
}
@Test
@DisplayName("配载运次的状态与接单态由子单聚合:需接单未接为待接单,无需接单为运输中")
void loadingTripStatusFollowsProcessConfig() {
stubPage(1, waybill(2092186187239780353L, "YD-1", LOADING_NO, "running", null, null));
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);
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单");
assertEquals("pending", trip.getAcceptStatus());
assertEquals(0, trip.getStatus());
}
@Test
@DisplayName("同一运次连读两次状态与接单态不漂移,聚合路径零写库")
void repeatedReadsDoNotDriftAndNeverWrite() {
stubOneLoadedTrip();
TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
assertEquals(first.getStatus(), second.getStatus());
assertEquals(first.getAcceptStatus(), second.getAcceptStatus());
assertEquals(first.getRequireAccept(), second.getRequireAccept());
assertEquals(first.getSortTime(), second.getSortTime());
assertEquals(first.getWaybillCount(), second.getWaybillCount());
verify(waybillService, never()).updateById(any());
verify(waybillService, never()).update(any(), any());
verify(waybillService, never()).saveOrUpdate(any());
verify(waybillService, never()).syncDriverAcceptState(any());
verify(loadingManageService, never()).updateById(any());
verify(loadingManageService, never()).update(any(), any());
}
@Test
@DisplayName("收口缺失时不放行:返回空页、零统计,详情直接拒绝,且不查库")
void missingScopeIsRefused() {
TripQueryVO blank = new TripQueryVO();
IPage<TripVO> page = service.page(blank);
TripTabCountsVO counts = service.tabCounts(blank);
assertEquals(0, page.getTotal());
assertTrue(page.getRecords().isEmpty());
assertEquals(0, counts.getAll());
assertThrows(ServiceException.class, () -> service.detail(blank));
verify(waybillService, never()).page(any(), any());
}
@Test
@DisplayName("分页即分运次:总数与顺序照库层给的来,服务端不重排")
void pagingIsPerTrip() {
stubPage(71,
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);
IPage<TripVO> page = service.page(query);
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 个运次");
}
@Test
@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;
});
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE));
assertEquals(3, counts.getPending());
assertEquals(24, counts.getDoing());
assertEquals(44, counts.getDone());
assertEquals(71, counts.getAll());
}
@Test
@DisplayName("详情:未配载按运单主键取,配载按配载单主键取")
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));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
stubChildren();
TripVO lone = service.detail(tripQuery(String.valueOf(WAYBILL_ID)));
TripVO loaded = service.detail(tripQuery(String.valueOf(LOADING_ID)));
assertEquals("2110690671695380513", lone.getId());
assertEquals(1, lone.getWaybillCount());
assertEquals(String.valueOf(LOADING_ID), loaded.getId());
assertEquals(LOADING_NO, loaded.getTripNo());
assertEquals(2, loaded.getWaybillCount());
}
@Test
@DisplayName("详情的收口与列表一致:别人的运次、子单全被拒单的配载运次都不可达")
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));
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
when(waybillService.getById(WAYBILL_ID)).thenReturn(null);
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(WAYBILL_ID))));
assertThrows(ServiceException.class, () -> service.detail(tripQuery("not-a-number")));
}
@Test
@DisplayName("已拒单的子单不进运次,整趟车只剩被拒的那张时该运次不再出现")
void rejectedChildrenLeaveTheTrip() {
stubPage(1, waybill(LOADING_ID, "YD-20260919-0001", LOADING_NO, "pending", null, null));
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(), "配载单还在但可见子单为空时不给没有子单的运次");
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")
private static Wrapper<Waybill> anyWaybillWrapper() {
return ArgumentMatchers.<Wrapper<Waybill>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<LoadingManage> anyLoadingWrapper() {
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;
});
}
private void stubOneLoadedTrip() {
stubPage(1, waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, null));
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)));
}
private TripQueryVO tripQuery(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setTripId(tripId);
return query;
}
private LoadingManage loading(String driverPhone) {
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setDriverPhone(driverPhone);
loading.setDriverName("黄宇富");
loading.setVehicleNo("桂A32523");
loading.setProjectName("武宣建筑原料运输项目");
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 Waybill waybill(Long id, String no, String loadingNo, String businessStatus, String acceptStatus,
Date createTime, String processJson) {
Waybill waybill = new Waybill();
waybill.setId(id);
waybill.setWaybillNo(no);
waybill.setLoadingNo(loadingNo);
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setDriverPhone(PHONE);
waybill.setProcessJson(processJson);
waybill.setCreateTime(createTime);
return waybill;
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<QueryWrapper<Waybill>> queryCaptor() {
return ArgumentCaptor.forClass(QueryWrapper.class);
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<Page<Waybill>> pageCaptor() {
return ArgumentCaptor.forClass(Page.class);
}
private String capturedTripSql() {
ArgumentCaptor<QueryWrapper<Waybill>> captor = queryCaptor();
verify(waybillService).page(any(), captor.capture());
return captor.getValue().getTargetSql();
}
}