diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java
new file mode 100644
index 00000000..fd16afce
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java
@@ -0,0 +1,77 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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;
+
+/**
+ * 运次查询条件
+ *
+ * 司机端用 {@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;
+ }
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java
new file mode 100644
index 00000000..944c4ded
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java
@@ -0,0 +1,55 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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 统计
+ *
+ * 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。
+ */
+@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;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java
new file mode 100644
index 00000000..ed84cc0a
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java
@@ -0,0 +1,181 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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;
+
+/**
+ * 运次:一趟车的展示与操作单位
+ *
+ * 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。
+ * 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。
+ */
+@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 waybills;
+
+ /**
+ * 运次下的子运单
+ *
+ * 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。
+ */
+ @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;
+
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java
new file mode 100644
index 00000000..7e8ea0d3
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java
@@ -0,0 +1,62 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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;
+
+/**
+ * 运次读服务
+ *
+ * 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
+ * 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态。
+ */
+public interface ITripQueryService {
+
+ /**
+ * 运次分页:一页就是若干个运次(有配载单的一趟车算一个),筛选、排序与分页都在库层按运次完成。
+ *
+ * @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
+ * @return 运次分页
+ */
+ IPage page(TripQueryVO query);
+
+ /**
+ * 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成
+ *
+ * @param query 查询条件,口径同 page
+ * @return 运次计数
+ */
+ TripTabCountsVO tabCounts(TripQueryVO query);
+
+ /**
+ * 按运次主键取单个运次(含子运单),主键为字符串
+ *
+ * @param query 查询条件,tripId 必填
+ * @return 运次
+ */
+ TripVO detail(TripQueryVO query);
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java
new file mode 100644
index 00000000..32fc9d13
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java
@@ -0,0 +1,198 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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
+ *
+ * 四档判据有两种渲染:库层聚合条件用于 Tab 过滤与分页(配载段按配载单号分组,先筛后排序再分页),
+ * Java 判定用于摆上一页的运次给状态。两种渲染必须同档。
+ *
+ * 接单需求要解析过程配置,库层表达不出来,只能在本页复核:库里落的是业务状态,
+ * 而「需接单未接」与「无需接单」两种情况在库里可能都是待接单档。
+ */
+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 PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
+ /** 终态档 */
+ static final List TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
+ /**
+ * 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
+ * 否则「子单全已取消」会被算成运输中而不是已取消。
+ */
+ static final List 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)
+ *
+ * 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;
+ };
+ }
+
+ /**
+ * 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
+ * 否则需接单且未接单取待接单,其余取运输中。
+ *
+ * 子单集合为空时无从判定,返回空。
+ */
+ static String statusOf(List 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 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;
+ }
+
+ /**
+ * 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
+ *
+ * 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
+ */
+ static boolean requiresAccept(List children) {
+ return children.stream()
+ .anyMatch(child -> child != null
+ && WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
+ }
+
+ /**
+ * 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
+ * 否则有子单待接单为待接单,无从判定时为空。
+ */
+ static String acceptStatus(List 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 statuses) {
+ return statuses.stream().map(status -> "'" + status + "'").collect(Collectors.joining(", "));
+ }
+
+ /** 子单聚合计数 */
+ record Counters(int total, int terminal, int completed, int pending) {
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java
new file mode 100644
index 00000000..d1f7bd2c
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java
@@ -0,0 +1,483 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * 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;
+
+/**
+ * 运次读服务实现
+ *
+ * 取数两段,段内都是批量查询:段一在库层按运次分组聚合,一次查询完成筛选、排序与分页;
+ * 段二按这一页的运次主键与配载单号批量取配载单与子运单。
+ *
+ * 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
+ * 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
+ * 于是整趟车被拒单后整个运次自然消失。
+ */
+@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 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 rows = new Page<>(pageNo, pageSize);
+ waybillService.page(rows, tripQuery(condition));
+ Page 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 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 rows = new Page<>(1, 1);
+ waybillService.page(rows, tripQuery(withStatus(condition, status)));
+ return rows.getTotal();
+ }
+
+ /**
+ * 库层运次查询:按运次分组,一次完成筛选、排序与分页
+ *
+ * 分组键与排序键都在 SQL 里,Tab 过滤是分组后的聚合条件(HAVING)——先分页再在服务端过滤会让
+ * 本该在第一页的运次漏到后面几页。
+ */
+ private QueryWrapper tripQuery(TripQueryVO condition) {
+ QueryWrapper 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;
+ }
+
+ /**
+ * 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
+ *
+ * 手机号允许带空格,与运单列表的收口判据一致。
+ */
+ private void applyScope(QueryWrapper 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))));
+ }
+
+ /**
+ * 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
+ *
+ * 关键字不缩小这一组的子单集合,否则状态与排序会按命中的那一部分子单算。
+ */
+ private void applyKeyword(QueryWrapper 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 assemble(List rows) {
+ if (Func.isEmpty(rows)) {
+ return new ArrayList<>();
+ }
+ Set loadingNos = rows.stream()
+ .map(Waybill::getLoadingNo)
+ .filter(Func::isNotEmpty)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ Map loadings = loadLoadings(loadingNos);
+ Map> children = loadChildren(loadingNos);
+ List 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 group = children.getOrDefault(loadingNo, List.of());
+ if (group.isEmpty()) {
+ // 子单全部被拒单:整趟车对司机不可见,与详情口径一致
+ continue;
+ }
+ trips.add(loadingTrip(loading, group, false));
+ }
+ return trips;
+ }
+
+ /**
+ * 段二:按本页运次的配载单号一次取回配载单
+ */
+ private Map loadLoadings(Set loadingNos) {
+ if (loadingNos.isEmpty()) {
+ return Map.of();
+ }
+ return loadingManageService.list(Wrappers.lambdaQuery()
+ .in(LoadingManage::getLoadingNo, loadingNos))
+ .stream()
+ .collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
+ }
+
+ /**
+ * 段二:按本页运次的配载单号一次取回子运单,不逐条查
+ *
+ * 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
+ * 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
+ * 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
+ */
+ private Map> loadChildren(Set loadingNos) {
+ if (loadingNos.isEmpty()) {
+ return Map.of();
+ }
+ List children = waybillService.list(Wrappers.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> 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 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 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 children) {
+ return children.stream()
+ .map(Waybill::getCreateTime)
+ .filter(Objects::nonNull)
+ .max(Comparator.naturalOrder())
+ .orElse(null);
+ }
+
+ private String firstNotEmpty(String preferred, List children, Function 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);
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java
new file mode 100644
index 00000000..13064947
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java
@@ -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;
+
+/**
+ * 运次四档状态与接单需求合并口径。
+ *
+ * 四档判据库里一份(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 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 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 children = List.of(waybill("completed", null, null), waybill("cancelled", null, null));
+
+ assertEquals("completed", TripAggregation.statusOf(children, false, false));
+ }
+
+ @Test
+ @DisplayName("需接单且未接单 → 待接单")
+ void requireAcceptWithoutAcceptance() {
+ List 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 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 children = List.of(waybill("pending", "accepted", ACCEPT_YES));
+
+ assertEquals("running", TripAggregation.statusOf(children, true, true));
+ }
+
+ @Test
+ @DisplayName("子单还有没走完的且需接单 → 待接单")
+ void pendingChildKeepsTripPending() {
+ List children = List.of(waybill("running", null, ACCEPT_YES), waybill("dispatching", null, null));
+
+ assertEquals("pending", TripAggregation.statusOf(children, true, false));
+ }
+
+ @Test
+ @DisplayName("子单集合为空不抛异常,状态无可判定为空")
+ void emptyChildrenDoNotThrow() {
+ List 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 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 children = List.of(
+ waybill("running", null, null),
+ waybill("running", null, ACCEPT_NO));
+
+ assertFalse(TripAggregation.requiresAccept(children));
+ }
+
+ @Test
+ @DisplayName("停用的接单节点不算需要接单")
+ void disabledAcceptNodeIsNotRequirement() {
+ List 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;
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java
new file mode 100644
index 00000000..7473bd21
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java
@@ -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;
+
+/**
+ * 运次读服务的取数两段与聚合输出。
+ *
+ * 单元测试里 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 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 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 page = service.page(query);
+
+ assertEquals(71, page.getTotal(), "总数是库层按运次算的");
+ assertEquals(List.of("3", "2", "1"), page.getRecords().stream().map(TripVO::getId).toList(),
+ "顺序就是库层给的顺序");
+ ArgumentCaptor> 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 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 page = service.page(TripQueryVO.forDriver(PHONE));
+
+ assertTrue(page.getRecords().isEmpty(), "配载单还在但可见子单为空时不给没有子单的运次");
+ }
+
+ // ------------------------------------------------------------ 夹具
+
+ @SuppressWarnings("unchecked")
+ private static Wrapper anyWaybillWrapper() {
+ return ArgumentMatchers.>any();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Wrapper anyLoadingWrapper() {
+ return ArgumentMatchers.>any();
+ }
+
+ private void stubPage(long total, Waybill... rows) {
+ when(waybillService.page(any(), any())).thenAnswer(invocation -> {
+ Page 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> queryCaptor() {
+ return ArgumentCaptor.forClass(QueryWrapper.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ private ArgumentCaptor> pageCaptor() {
+ return ArgumentCaptor.forClass(Page.class);
+ }
+
+ private String capturedTripSql() {
+ ArgumentCaptor> captor = queryCaptor();
+ verify(waybillService).page(any(), captor.capture());
+ return captor.getValue().getTargetSql();
+ }
+
+}