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(); + } + +}