Merge remote-tracking branch 'websoft/dev'

This commit is contained in:
2026-10-19 22:37:23 +08:00
60 changed files with 9010 additions and 341 deletions
@@ -51,6 +51,12 @@ public class PersonCertSubmitRequest implements Serializable {
private String realName;
@Schema(description = "身份证号")
private String idCardNo;
@Schema(description = "出生年月")
private LocalDate birthday;
@Schema(description = "住址-区划:省市区名称拼接串")
private String addressRegion;
@Schema(description = "住址-详细地址")
private String address;
@Schema(description = "身份证正面照")
private String idCardFrontUrl;
@Schema(description = "身份证反面照")
@@ -92,4 +92,30 @@ public class AdminWaybillCardVO implements Serializable {
@Schema(description = "采购方是否已付款(预留)")
private Boolean buyerPaid;
/* ===== 运次(一趟车)字段:列表按运次下发,卡片的展示单号与路线取这些 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "合计重量(整趟车同单位时才有值,单位不一致为空)")
private String totalWeight;
@Schema(description = "子运单数(可见口径;配载运次即整趟车的子单数)")
private Integer waybillCount;
}
@@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "卸货地址")
private String unloadAddress;
@Schema(description = "货物名称")
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接")
private String cargoName;
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物数量(带单位)")
private String cargoQuantity;
@@ -135,6 +138,29 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "途打卡记录")
private List<DriverEnrouteRecordVO> enrouteRecords;
/* ===== 运次(一趟车)字段:详情按运次下发,与司机端接单页同一口径 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "站点任务安排(整趟车按实际途经顺序,第 1 站即 ①;配载运次含每站装卸与货物条目)")
private List<TripStationVO> stations;
@Data
@Schema(description = "装卸点")
public static class AdminRoutePointVO implements Serializable {
@@ -70,6 +70,9 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
private Boolean visible;
@Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单")
private Boolean finishOnPunch;
@Schema(description = "是否默认展开(仅第一个可打卡节点)")
private Boolean defaultExpanded;
@@ -153,6 +153,26 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "运输组织:common普通 / load配载")
private String transportOrgType;
/* ===== 运次(一趟车)字段:运次接口返回,旧运单接口不填 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "计划发货时间")
private String planShipTime;
@@ -168,6 +188,9 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "装卸点(详情返回)")
private List<DriverRoutePointVO> routePoints;
@Schema(description = "站点任务安排(详情返回,按实际途经顺序,第 1 站即 ①;有配载时含每站装卸与货物条目)")
private List<TripStationVO> stations;
@Schema(description = "过程配置 JSON(详情返回,供前端兜底推导打卡节点)")
private String processJson;
@@ -0,0 +1,104 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Set;
/**
* 运次查询条件
* <p>
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
*/
@Data
@Schema(description = "运次查询条件")
public class TripQueryVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键(详情按它取单个运次)")
private String tripId;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤")
private List<Long> deptIds;
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
private Integer status;
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
private List<Integer> statuses;
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
private String keyword;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
private String transportOrgType;
@Schema(description = "异常筛选:exception 只看命中异常的子单 / normal 只看未命中的,不传为全部(按子单行筛选)")
private String exception;
@Schema(description = "有未完成异常的子运单主键集合,配合 exception 使用(按子单行筛选)")
private Set<Long> exceptionWaybillIds;
@Schema(description = "创建时间下界,按子单行筛选")
private Date createdFrom;
@Schema(description = "创建时间上界,按子单行筛选")
private Date createdTo;
@Schema(description = "是否排除「有子单被拒且这趟车还没结束」的运次(待处理列表用)")
private boolean excludeRejectedTrips;
@Schema(description = "当前页,从 1 开始")
private Integer current;
@Schema(description = "每页条数(按运次,不是运单)")
private Integer size;
public static TripQueryVO forDriver(String driverPhone) {
TripQueryVO query = new TripQueryVO();
query.setDriverPhone(driverPhone);
return query;
}
public static TripQueryVO forAdmin() {
TripQueryVO query = new TripQueryVO();
query.setAllDrivers(true);
return query;
}
}
@@ -0,0 +1,105 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/**
* 运次站点(任务安排)
* <p>
* 有配载单时按配载单的路线信息给出实际途经顺序,第 1 站即 ①;站点上的「装N / 卸N」指向配载单运单集合里的第 N 张子运单,
* 同一站可以既卸又装(装卸)。没有路线信息时回退到子运单的起讫点去重成站。
* <p>
* 站点上的货物条目取子运单,不取配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
*/
@Data
@Schema(description = "运次站点")
public class TripStationVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "站序,从 1 开始(展示为 ①②③)")
private Integer index;
@Schema(description = "站名:展示地址优先,回退详细地址")
private String name;
@Schema(description = "详细地址")
private String address;
@Schema(description = "站点标记原文:装N / 卸N")
private List<String> tags;
@Schema(description = "操作类型:load 装货 / unload 卸货 / load_unload 装卸,无从判定时为空")
private String operationType;
@Schema(description = "操作类型文案:装货 / 卸货 / 装卸")
private String operationTypeLabel;
@Schema(description = "该站涉及的货物条目")
private List<TripCargoVO> cargos;
/**
* 站点货物条目
*/
@Data
@Schema(description = "运次站点货物条目")
public static class TripCargoVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "所属子运单主键,按字符串下发")
private String waybillId;
@Schema(description = "所属运单号")
private String waybillNo;
@Schema(description = "货物名称")
private String cargoName;
@Schema(description = "货主名称")
private String customerName;
@Schema(description = "数量")
private BigDecimal quantity;
@Schema(description = "数量单位")
private String quantityUnit;
@Schema(description = "该站的操作:load 装货 / unload 卸货")
private String operation;
@Schema(description = "项目名称")
private String projectName;
}
}
@@ -0,0 +1,55 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 运次 Tab 统计
* <p>
* 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。
*/
@Data
@Schema(description = "运次 Tab 统计")
public class TripTabCountsVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "全部(待接单+进行中+已完成,不含已取消)")
private long all;
@Schema(description = "待接单")
private long pending;
@Schema(description = "进行中")
private long doing;
@Schema(description = "已完成")
private long done;
}
@@ -0,0 +1,227 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
/**
* 运次:一趟车的展示与操作单位
* <p>
* 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。
* 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。
*/
@Data
@Schema(description = "运次")
public class TripVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String id;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输")
private String transportOrgType;
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消,子单集合为空时为空")
private Integer status;
@Schema(description = "排序键:运次内子运单 create_time 的最大值")
private Date sortTime;
@Schema(description = "是否需要接单:任一子运单的项目有接单节点即为是")
private Boolean requireAccept;
@Schema(description = "接单态:accepted 已接单 / pending 待接单,无从判定时为空")
private String acceptStatus;
@Schema(description = "子运单数(可见口径:已排除本司机拒单的子单)")
private int waybillCount;
@Schema(description = "司机姓名")
private String driverName;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "车牌号")
private String vehicleNo;
@Schema(description = "挂车车牌号")
private String trailerVehicleNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "运输类型")
private String transportType;
@Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序")
private List<String> cargoNames;
@Schema(description = "货物类型:整趟车全部类型以「、」连接")
private String cargoType;
@Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@Schema(description = "发货地址")
private String departureAddress;
@Schema(description = "途经地")
private String transitAddress;
@Schema(description = "到货地")
private String arrivalAddress;
@Schema(description = "起点名称:整趟车首站,展示地址优先、回退详细地址")
private String departureName;
@Schema(description = "途经站点:中间各站按顺序以「 → 」连接,无中间站为空")
private String transitName;
@Schema(description = "终点名称:整趟车末站,展示地址优先、回退详细地址")
private String arrivalName;
@Schema(description = "站点数(整趟车,含起终点)")
private int stationCount;
@Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计")
private String cargoSummary;
@Schema(description = "合计重量:全部子运单同单位时为数量合计,单位不一致时为空")
private BigDecimal totalQuantity;
@Schema(description = "合计重量的单位,与 totalQuantity 同时为空或同时有值")
private String quantityUnit;
@Schema(description = "计划发货日期(配载单,未配载为空)")
private LocalDate estimatedStartDate;
@Schema(description = "计划完成日期(配载单,未配载为空)")
private LocalDate estimatedEndDate;
@Schema(description = "任务备注(配载单的运输说明)")
private String taskRemark;
@Schema(description = "路线信息,仅单个运次详情返回(列表不带,省流量)")
private String routeJson;
@Schema(description = "货物信息,仅单个运次详情返回(列表不带,省流量)")
private String goodsJson;
@Schema(description = "站点(任务安排,按实际途经顺序),仅单个运次详情返回;列表用起/经/终与站点数")
private List<TripStationVO> stations;
@Schema(description = "子运单(可见子单,按创建时间升序)")
private List<TripWaybillVO> waybills;
/**
* 运次下的子运单
* <p>
* 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。
*/
@Data
@Schema(description = "运次下的子运单")
public static class TripWaybillVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运单主键,按字符串下发")
private String id;
@Schema(description = "运单号")
private String waybillNo;
@Schema(description = "业务状态")
private String businessStatus;
@Schema(description = "接单状态:pending待接单/accepted已接单/rejected已拒绝")
private String acceptStatus;
@Schema(description = "该运单是否需要接单确认")
private Boolean requireAccept;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes")
private String cargoType;
@Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@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;
}
}
@@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.Waybill;
import java.io.Serial;
import java.util.List;
/**
* 运单管理视图实体类
@@ -78,4 +79,12 @@ public class WaybillVO extends Waybill {
@Schema(description = "是否仅查询未配载运单")
private Integer onlyUnassignedLoading;
@TableField(exist = false)
@Schema(description = "是否仅查询可配载运单")
private Integer loadableOnly;
@TableField(exist = false)
@Schema(description = "运单主键集合")
private List<Long> ids;
}
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.detail(id));
}
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
public R<DriverWaybillCardVO> tripCurrentTask() {
return R.data(driverWaybillService.tripCurrentTask());
}
@GetMapping("/task/pending-preview")
@ApiOperationSupport(order = 22)
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
public R<DriverWaybillPreviewVO> tripPendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.tripPendingPreview(size));
}
@GetMapping("/task/counts")
@ApiOperationSupport(order = 23)
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
public R<DriverWaybillTabCountsVO> tripCounts() {
return R.data(driverWaybillService.tripTabCounts());
}
@GetMapping("/task/page")
@ApiOperationSupport(order = 24)
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
public R<IPage<DriverWaybillCardVO>> tripPage(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
}
@GetMapping("/task/detail")
@ApiOperationSupport(order = 25)
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
public R<DriverWaybillCardVO> tripDetail(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.data(driverWaybillService.tripDetail(id));
}
@PostMapping("/task/accept")
@ApiOperationSupport(order = 26)
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
public R tripAccept(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.status(driverWaybillService.tripAccept(id));
}
@PostMapping("/task/reject")
@ApiOperationSupport(order = 27)
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
public R tripReject(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
return R.status(driverWaybillService.tripReject(id, reason));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
@@ -69,7 +69,7 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
public R<AdminHomeStatsVO> stats() {
return R.data(manageWaybillService.stats());
}
@@ -99,9 +99,9 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
public R<AdminWaybillDetailVO> detail(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
return R.data(manageWaybillService.detail(id));
}
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
return R.data(waybillService.batchComplete(ids));
}
@PostMapping("/confirm-return")
@ApiOperationSupport(order = 24)
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.status(waybillService.confirmReturnReceipt(id));
}
@PostMapping("/road-loading")
@ApiOperationSupport(order = 23)
@Operation(summary = "公路配载", description = "传入ids")
@@ -102,7 +102,8 @@
FROM blade_receipt_claim c
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
WHERE <include refid="claimRecordConditions"/>
ORDER BY c.create_time DESC
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
ORDER BY c.claim_date DESC, c.id DESC
</select>
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
*/
DriverWaybillCardVO detail(Long id);
/**
* 当前登录司机的运输中运次(一趟车,最多一条)
*/
DriverWaybillCardVO tripCurrentTask();
/**
* 当前登录司机的待接运次预览
*
* @param size 预览条数,默认 2
*/
DriverWaybillPreviewVO tripPendingPreview(Integer size);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
*/
DriverWaybillTabCountsVO tripTabCounts();
/**
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
*
* @param current 当前页,从 1 开始
* @param size 每页条数(按运次,默认 10)
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
*/
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
/**
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
*/
DriverWaybillCardVO tripDetail(String tripId);
/**
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
*
* @param tripId 运次主键(配载单主键或运单主键)
*/
boolean tripAccept(String tripId);
/**
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
*
* @param tripId 运次主键(配载单主键或运单主键)
* @param reason 拒绝原因,可空,最长 200 字
*/
boolean tripReject(String tripId, String reason);
/**
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
* <p>
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
String exception, String transportType, String startDate, String endDate);
/**
* 调度端运单详情(不校验司机归属)
* 调度端运次详情(不校验司机归属)
* <p>
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
*/
AdminWaybillDetailVO detail(Long id);
AdminWaybillDetailVO detail(String tripId);
/**
* 待处理运单(待接单 / 运输中;可筛需重新派单)
@@ -0,0 +1,65 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
/**
* 运次读服务
* <p>
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
* 不按库里的业务状态,也不借读路径做惰性校正。
*/
public interface ITripQueryService {
/**
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
* <p>
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @return 运次分页
*/
IPage<TripVO> page(TripQueryVO query);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
*
* @param query 查询条件,口径同 page
* @return 运次计数
*/
TripTabCountsVO tabCounts(TripQueryVO query);
/**
* 按运次主键取单个运次(含子运单),主键为字符串
*
* @param query 查询条件,tripId 必填
* @return 运次
*/
TripVO detail(TripQueryVO query);
}
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
*/
boolean completeWithoutDeptCheck(Long id);
/**
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
* <p>
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
*/
boolean confirmReturnReceipt(Long id);
BusinessRemoveResultVO batchComplete(String ids);
LoadingManageVO roadLoading(String ids);
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
driver.setDriverName(snapshot.getRealName());
driver.setIdCardNo(snapshot.getIdCardNo());
driver.setBirthday(snapshot.getBirthday());
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
driver.setAddress(blankToNull(snapshot.getAddress()));
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
driver.setIdCardBack(snapshot.getIdCardBackUrl());
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
@@ -28,6 +28,7 @@ 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.secure.BladeUser;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.DateUtil;
@@ -45,13 +46,14 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
@@ -59,10 +61,15 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
@@ -130,6 +137,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
/** 被异常阻断时的不可打卡原因(司机端展示) */
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
/** 运次状态码:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消,与运单侧同一套 */
private static final int TRIP_STATUS_PENDING = 0;
private static final int TRIP_STATUS_RUNNING = 1;
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IMpUserClient mpUserClient;
@@ -139,6 +150,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final WaybillEnroutePunchMapper enroutePunchMapper;
private final WaybillNodePunchMapper nodePunchMapper;
private final ExceptionDisposalMapper exceptionDisposalMapper;
private final ITripQueryService tripQueryService;
@Override
public DriverWaybillCardVO currentTask() {
@@ -252,6 +264,81 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO tripCurrentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return null;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(TRIP_STATUS_RUNNING);
query.setCurrent(1);
query.setSize(1);
IPage<TripVO> page = tripQueryService.page(query);
return page.getRecords().isEmpty() ? null : toTripCard(page.getRecords().get(0), false);
}
@Override
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return preview;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(TRIP_STATUS_PENDING);
query.setCurrent(1);
query.setSize(normalizePreviewSize(size));
IPage<TripVO> page = tripQueryService.page(query);
preview.setTotal(page.getTotal());
preview.setRecords(page.getRecords().stream()
.map(trip -> toTripCard(trip, false))
.collect(Collectors.toList()));
return preview;
}
@Override
public DriverWaybillTabCountsVO tripTabCounts() {
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return counts;
}
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
counts.setPending(tripCounts.getPending());
counts.setDoing(tripCounts.getDoing());
counts.setDone(tripCounts.getDone());
counts.setAll(tripCounts.getAll());
return counts;
}
@Override
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return new Page<>(normalizePageNo(current), normalizePageSize(size));
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(status);
query.setKeyword(keyword);
query.setCurrent(current);
query.setSize(size);
IPage<TripVO> trips = tripQueryService.page(query);
Page<DriverWaybillCardVO> page = new Page<>(trips.getCurrent(), trips.getSize(), trips.getTotal());
page.setRecords(trips.getRecords().stream()
.map(trip -> toTripCard(trip, false))
.collect(Collectors.toList()));
return page;
}
@Override
public DriverWaybillCardVO tripDetail(String tripId) {
requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
query.setTripId(tripId);
return toTripCard(tripQueryService.detail(query), true);
}
@Override
public WaybillTrackVO track(Long id) {
requireCurrentDriver();
@@ -557,6 +644,135 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tripAccept(String tripId) {
Driver driver = requireCurrentDriver();
// 认证门槛按整车一次过:一次接单只校验一次司机与车辆
assertCertified(driver);
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
assertTripAcceptable(children);
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许接单");
// 更新痕迹与单票接单一致:单票走 updateById,写前会把 update_time 刷成提交时刻、
// 把 update_user 填成当前用户(无登录态则不填);这里一次写完,显式补上同样两列,
// 时刻取与接单时刻同一个 now,操作人取同一个登录用户
Date now = new Date();
Long operatorId = currentOperatorId();
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)
.set(Waybill::getDriverAcceptTime, now)
.set(Waybill::getDriverAcceptDriverId, driver.getId())
.set(Waybill::getDriverRejectTime, null)
.set(Waybill::getDriverRejectReason, null)
.set(Waybill::getBusinessStatus, STATUS_RUNNING)
.set(Waybill::getUpdateTime, now)
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
.in(Waybill::getId, pendingIds));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tripReject(String tripId, String reason) {
requireCurrentDriver();
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
assertTripRejectable(children);
String rejectReason = Func.isEmpty(reason) ? null : reason.trim();
if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) {
throw new ServiceException("拒绝原因不能超过200字");
}
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单");
// 整单拒绝落在每张未终态子运单上,一次一条语句写完,拒单时刻与更新痕迹全趟车一致
Date now = new Date();
Long operatorId = currentOperatorId();
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
.set(Waybill::getDriverAcceptTime, null)
.set(Waybill::getDriverAcceptDriverId, null)
.set(Waybill::getDriverRejectTime, now)
.set(Waybill::getDriverRejectReason, rejectReason)
.set(Waybill::getBusinessStatus, STATUS_PENDING)
.set(Waybill::getUpdateTime, now)
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
.in(Waybill::getId, pendingIds));
}
/**
* 当前登录用户主键:与框架写入时填充更新人的口径一致——没有登录态就不填
*/
private Long currentOperatorId() {
BladeUser operator = AuthUtil.getUser();
return operator == null ? null : operator.getUserId();
}
/**
* 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单),
* 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。
* <p>
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
*/
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
query.setTripId(tripId);
TripVO trip = tripQueryService.detail(query);
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
if (children.isEmpty()) {
throw new ServiceException("运次不存在或无权操作");
}
return children;
}
/**
* 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单
*/
private void assertTripAcceptable(List<TripVO.TripWaybillVO> children) {
if (children.stream().allMatch(this::isTerminalChild)) {
throw new ServiceException("当前运单状态不允许接单");
}
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
throw new ServiceException("该运单无需确认接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
throw new ServiceException("该运单已接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isRejected(child.getAcceptStatus()))) {
throw new ServiceException("该运单已拒单,请联系调度重新派单");
}
}
/**
* 拒单校验按整车:有子单没走完才可拒绝,任一子单已接单则整单不能拒绝
*/
private void assertTripRejectable(List<TripVO.TripWaybillVO> children) {
if (children.stream().allMatch(this::isTerminalChild)) {
throw new ServiceException("当前运单状态不允许拒绝接单");
}
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
throw new ServiceException("该运单无需确认接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
throw new ServiceException("该运单已接单,无法拒绝");
}
}
/**
* 可写入的子运单:已走完的子单不动——把已完成/已取消的子单改回运输中或待执行等于回退历史状态
*/
private List<Long> writeCandidateIds(List<TripVO.TripWaybillVO> children, String emptyMessage) {
List<Long> ids = children.stream()
.filter(child -> !isTerminalChild(child))
.map(child -> parseId(child.getId()))
.filter(Objects::nonNull)
.toList();
if (ids.isEmpty()) {
throw new ServiceException(emptyMessage);
}
return ids;
}
private boolean isTerminalChild(TripVO.TripWaybillVO child) {
return WaybillProcessSupport.isTerminalBusinessStatus(child.getBusinessStatus());
}
private Waybill loadDriverWaybill(Long id) {
return loadDriverWaybill(id, false);
}
@@ -758,9 +974,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), "")));
card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName()));
card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName()));
card.setCargoNames(splitCargoNames(waybill.getCargoName()));
card.setCargoCategory(Func.toStr(waybill.getCargoType(), ""));
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
// 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
card.setCargoNames(cargoNames);
card.setCargoCategory(cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("、")));
card.setWeight(formatCargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
String createTime = formatDateTime(waybill.getCreateTime());
card.setCreateTime(createTime);
@@ -777,7 +1000,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setRejectReason(waybill.getDriverRejectReason());
// 详情页字段(列表也可带上,体积很小)
String cargoName = Func.toStr(waybill.getCargoName(), "");
String cargoName = String.join("、", cargoNames);
String weightText = card.getWeight();
card.setCargoName(cargoName);
card.setPickupAddress(card.getFromAddress());
@@ -828,6 +1051,142 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return List.of(load, unload);
}
/**
* 运次卡片
* <p>
* 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在),
* 只是状态按运次口径只读推导,不走会落库的接单态归一化。
* 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空——
* 一趟车里每张子单各有各的项目配置与打卡节点。
*/
private DriverWaybillCardVO toTripCard(TripVO trip, boolean withDetail) {
DriverWaybillCardVO card = Func.isEmpty(trip.getLoadingNo()) && withDetail
? unloadedTripDetail(trip)
: new DriverWaybillCardVO();
fillTripFields(card, trip, withDetail);
return card;
}
/**
* 未配载运次的详情照运单详情拼,只读:不调用 normalizeAcceptStatus
*/
private DriverWaybillCardVO unloadedTripDetail(TripVO trip) {
Long waybillId = parseId(trip.getId());
Waybill waybill = waybillId == null ? null : waybillService.getById(waybillId);
return waybill == null ? new DriverWaybillCardVO() : toCard(waybill, true);
}
private void fillTripFields(DriverWaybillCardVO card, TripVO trip, boolean withDetail) {
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
card.setTripId(trip.getId());
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
// 旧字段 id / waybillNo 保留原语义:未配载就是这张运单,配载取下第一张可见子运单(运次操作请用 tripId)
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
card.setStatus(trip.getStatus());
card.setRequireAccept(trip.getRequireAccept());
card.setAcceptStatus(trip.getAcceptStatus());
card.setTransportOrgType(trip.getTransportOrgType());
card.setTransportType(toTransportTypeLabel(trip.getTransportType()));
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
card.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
card.setCreateTime(formatDateTime(trip.getSortTime()));
card.setPublishTime(card.getCreateTime());
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的。
// 未配载详情进来时卡片已照运单填过,运次级为空就保留它,不覆盖成空
if (trip.getEstimatedStartDate() != null) {
card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate()));
}
if (trip.getEstimatedEndDate() != null) {
card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate()));
}
// 备注只有配载运次有运次级的值,未配载的保留运单卡片自己带来的备注
if (Func.isNotEmpty(card.getLoadingNo())) {
card.setRemark(Func.toStr(trip.getTaskRemark(), ""));
}
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
card.setFromAddress(Func.toStrWithEmpty(trip.getDepartureAddress(), card.getFromName()));
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
card.setToAddress(Func.toStrWithEmpty(trip.getArrivalAddress(), card.getToName()));
card.setViaName(Func.toStr(trip.getTransitName(), ""));
card.setStationCount(trip.getStationCount());
card.setPickupAddress(card.getFromAddress());
card.setUnloadAddress(card.getToAddress());
// 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。
// 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失
List<String> cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames();
if (cargoNames.isEmpty()) {
cargoNames = children.stream()
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
.distinct()
.collect(Collectors.toList());
}
card.setCargoNames(cargoNames);
card.setCargoName(String.join("、", cargoNames));
List<String> cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes();
if (cargoTypes.isEmpty()) {
cargoTypes = children.stream()
.map(TripVO.TripWaybillVO::getCargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.toList());
}
card.setCargoCategory(String.join("、", cargoTypes));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
// 合计重量只在全部货物同单位时才有值;单位不一致时留空,卡片上已经由货物汇总逐条列出,
// 拿汇总串冒充重量会让「货名 + 重量」两栏把同一段文字打印两遍
String weight = trip.getTotalQuantity() == null
? ""
: formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
card.setWeight(weight);
card.setCargoQuantity(weight);
card.setTotalWeight(weight);
if (withDetail) {
card.setStations(trip.getStations());
card.setRoutePoints(toTripRoutePoints(trip.getStations()));
}
}
/**
* 详情里的点位列:整趟车的全部站点,站序即途经顺序
*/
private List<DriverWaybillCardVO.DriverRoutePointVO> toTripRoutePoints(List<TripStationVO> stations) {
if (Func.isEmpty(stations)) {
return List.of();
}
return stations.stream().map(station -> {
DriverWaybillCardVO.DriverRoutePointVO point = new DriverWaybillCardVO.DriverRoutePointVO();
point.setName(Func.toStr(station.getName(), ""));
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
point.setStatus("pending");
return point;
}).collect(Collectors.toList());
}
private Long parseId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
private int normalizePageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
private int normalizePageSize(Integer size) {
if (size == null || size < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(size, MAX_PAGE_SIZE);
}
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
@@ -850,55 +1209,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson,
* 避免项目配置改坏后司机端打卡页空白。
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照,
* 避免项目配置改坏后司机端打卡页空白。取法与规则都在 {@link LiveProcessConfigSupport}。
*/
private String resolveProcessJson(Waybill waybill) {
if (waybill == null) {
return null;
}
String snapshot = waybill.getProcessJson();
String live = loadLiveProcessConfigJson(waybill.getProjectId());
if (Func.isNotEmpty(live)) {
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
return live;
}
// 动态配置存在但无可打卡节点:仍回退快照
if (Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return live;
}
return snapshot;
}
private String loadLiveProcessConfigJson(Long projectId) {
if (projectId == null) {
return null;
}
String projectIdStr = String.valueOf(projectId);
return processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectIdStr)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectIdStr))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(","))
.map(String::trim)
.anyMatch(projectId::equals);
return LiveProcessConfigSupport.resolveForPunch(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
}
/**
@@ -1030,7 +1350,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
if (!unfinished.isEmpty()) {
throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
throw new ServiceException("请先完成「"
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
}
}
@@ -1240,6 +1561,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
vo.setVisible(true);
vo.setDone(done);
vo.setActionable(!done);
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey())
&& WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName())
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson));
if (done) {
if (punched.getPunchTime() != null) {
LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
@@ -1296,7 +1623,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
vo.setBlockedByOrder(true);
vo.setActionable(false);
vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
vo.setOrderLockReason("请先完成「"
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
}
// 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用);
@@ -1345,18 +1673,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return nodes;
}
/** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */
private String prerequisiteNames(String processJson, Set<String> nodeKeys) {
List<String> names = new ArrayList<>();
for (Map<String, Object> node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
String key = WaybillProcessSupport.nodeKey(node);
if (nodeKeys.contains(key)) {
names.add(WaybillProcessSupport.nodeName(node));
}
}
return names.isEmpty() ? "" : String.join("、", names);
}
private Map<String, Object> findPunchNodeConfig(String processJson, String nodeCode) {
List<Map<String, Object>> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson);
for (Map<String, Object> cfg : punchConfigs) {
@@ -1597,16 +1913,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
};
}
/**
* 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径
*/
private List<String> splitCargoNames(String cargoName) {
if (Func.isEmpty(cargoName)) {
return Collections.emptyList();
List<String> names = WaybillCargoSupport.splitCargoNames(cargoName);
return names.isEmpty() ? List.of() : names;
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿 goodsJson 算不出合计(有货物没填数量或单位)时,退回主表那份汇总值——宁可显示一个来源不明的数,
* 也不要让卡片上的重量凭空消失。
*/
private String formatCargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
}
String normalized = cargoName.replace(",", ",").replace("、", ",");
List<String> names = Func.toStrList(",", normalized).stream()
.map(String::trim)
.filter(Func::isNotEmpty)
.collect(Collectors.toList());
return names.isEmpty() ? List.of(cargoName.trim()) : names;
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (WaybillCargoSupport.Cargo cargo : cargos) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return fallback;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return "";
}
total = total.add(cargo.quantity());
}
return formatWeight(total, unit);
}
private String formatWeight(BigDecimal quantity, String unit) {
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.LoadableAdmissionSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.LoadingManageWrapper;
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
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.stream.Collectors;
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
@Lazy
private IReceivablePayableDetailService receivablePayableDetailService;
@Resource
private IProcessConfigService processConfigService;
@Override
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
prepare(loadingManage);
validate(loadingManage, false);
List<Long> oldWaybillIds = new ArrayList<>();
boolean ownChildrenAdmitted = false;
if (Func.isNotEmpty(loadingManage.getId())) {
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (Func.isEmpty(loadingManage.getLoadingNo())) {
loadingManage.setLoadingNo(nextCode());
}
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
validateCarrierContract(loadingManage, true);
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
validateCarrierContract(loadingManage, true, waybills);
boolean result = saveOrUpdate(loadingManage);
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
return result;
}
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
loadingManage.setBusinessStatus(STATUS_PENDING);
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
validateCarrierContract(loadingManage, true);
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
validateCarrierContract(loadingManage, true, waybills);
boolean result = updateById(loadingManage);
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
clearAssociatedWaybillAcceptRecords(loadingManage);
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
return result;
}
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
}
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
if (Func.isEmpty(waybillIds)) {
throw new ServiceException("请选择待配载运单");
}
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.in(Waybill::getId, waybillIds));
List<Waybill> waybills = loadWaybills(waybillIds);
if (waybills.size() != waybillIds.size()) {
throw new ServiceException("待配载运单不存在或已删除");
}
String ownLoadingNo = loadingManage.getLoadingNo();
for (Waybill waybill : waybills) {
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
continue;
}
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
LoadableAdmissionSupport.UnloadableReason reason =
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
if (reason != null) {
throw new ServiceException(unloadableMessage(waybill, reason));
}
}
validateSameProject(waybills);
return waybills;
}
/**
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
*/
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
String prefix = "运单" + waybill.getWaybillNo();
return prefix + switch (reason) {
case NOT_ROAD -> "运输方式不是公路,不可配载";
case NOT_PENDING -> "当前状态不是待执行,不可配载";
case ON_OTHER_MANIFEST -> "已关联其他配载单";
};
}
/**
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
* <p>
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
*/
private void validateSameProject(List<Waybill> waybills) {
Long projectId = waybills.get(0).getProjectId();
List<String> others = waybills.stream()
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
.map(Waybill::getWaybillNo)
.toList();
if (Func.isNotEmpty(others)) {
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
+ String.join("、", others) + "与其它子运单不属于同一项目");
}
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
validateCarrierContract(loadingManage, required, null);
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
loadingManage.setCarrierContractId(null);
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCarrierName(null);
return;
}
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
.findFirst()
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
if (carrierContract == null) {
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
}
List<Waybill> childWaybills = waybills == null
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
: waybills;
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
String waybillNos = childWaybills.stream()
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
.map(Waybill::getWaybillNo)
.filter(Func::isNotEmpty)
.collect(Collectors.joining("、"));
throw new ServiceException(Func.isEmpty(waybillNos)
? "所选承运商合同不属于本项目"
: "所选承运商合同不属于运单" + waybillNos + "的项目");
}
loadingManage.setCarrierName(carrierContract.getPartyB());
}
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
private List<Waybill> loadWaybills(List<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) {
return List.of();
}
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getProjectId)
.eq(Waybill::getIsDeleted, 0)
.in(Waybill::getId, waybillIds))
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
.in(Waybill::getId, waybillIds));
}
private List<Long> projectIdsOf(List<Waybill> waybills) {
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
}
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
if (Func.isEmpty(projectIds)) {
return List.of();
}
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getStatus, 1)
.eq(ContractManage::getContractCategory, "承运商合同")
return contractManageService.list(availableCarrierContractQuery()
.in(ContractManage::getProjectId, projectIds)
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime));
}
/**
* 按主键取一张可用的承运商合同。
* <p>
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
* 取不到可用的合同返回 null。
*/
private ContractManage availableCarrierContractById(Long contractId) {
if (Func.isEmpty(contractId)) {
return null;
}
return contractManageService.list(availableCarrierContractQuery()
.eq(ContractManage::getId, contractId)
.orderByDesc(ContractManage::getCreateTime))
.stream().findFirst().orElse(null);
}
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
return Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getStatus, 1)
.eq(ContractManage::getContractCategory, "承运商合同")
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"));
}
/**
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
* <p>
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
*/
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
clearAssociatedWaybillAcceptRecords(loadingManage);
}
/**
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
* <p>
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
*/
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
if (Func.isEmpty(waybills)) {
return businessStatusByWaybillId;
}
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
waybills.stream().map(Waybill::getProjectId).toList());
for (Waybill waybill : waybills) {
String processJson = LiveProcessConfigSupport.resolve(
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
}
return businessStatusByWaybillId;
}
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
statusByWaybillId.put(waybillId, status);
}
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
}
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
if (Func.isNotEmpty(oldWaybillIds)) {
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (Func.isEmpty(newWaybillIds)) {
return;
}
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
for (Long waybillId : newWaybillIds) {
waybillIdsByStatus
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
.add(waybillId);
}
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
}
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
.in(Waybill::getId, newWaybillIds)
.in(Waybill::getId, waybillIds)
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
.set(Waybill::getBusinessStatus, status)
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
@@ -23,16 +23,19 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
@@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@@ -58,16 +70,24 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
* <p>
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
* <p>
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private final IExceptionDisposalService exceptionDisposalService;
private final IRiskDisposalService riskDisposalService;
private final IDriverWaybillService driverWaybillService;
private final ITripQueryService tripQueryService;
private final ILoadingManageService loadingManageService;
@Override
public AdminHomeStatsVO stats() {
AdminHomeStatsVO vo = new AdminHomeStatsVO();
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
vo.setPendingAccept(tripCounts.getPending());
vo.setTransporting(tripCounts.getDoing());
vo.setCompleted(tripCounts.getDone());
vo.setException(countIncompleteExceptions());
return vo;
}
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
emptyVo.setRecords(List.of());
return emptyVo;
return emptyCardPage(pageNo, pageSize);
}
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
applyStatusFilter(wrapper, status);
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
applyTransportTypeFilter(wrapper, transportType);
applyKeywordFilter(wrapper, keyword);
applyCreateTimeFilter(wrapper, startDate, endDate);
wrapper.orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setStatus(toTripStatus(status));
query.setKeyword(keyword);
query.setTransportOrgType(transportType);
query.setException(exception);
query.setExceptionWaybillIds(exceptionWaybillIds);
query.setCreatedFrom(parseDayStart(startDate));
query.setCreatedTo(parseDayEnd(endDate));
query.setCurrent(pageNo);
query.setSize(pageSize);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
@Override
public AdminWaybillDetailVO detail(Long id) {
public AdminWaybillDetailVO detail(String tripId) {
Long id = parseId(tripId);
if (id == null) {
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
throw new ServiceException("运次不存在或无权操作");
}
TripVO trip = visibleTrip(tripId);
if (trip != null) {
return tripDetail(trip);
}
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
return waybillDetail(waybill);
}
boolean hasException = false;
Long exceptionId = null;
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, id)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
if (latest != null) {
hasException = true;
exceptionId = latest.getId();
LoadingManage loading = loadingManageService.getById(id);
if (loading == null) {
throw new ServiceException("运次不存在或无权操作");
}
return toDetail(waybill, hasException, exceptionId);
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
}
@Override
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
if (Boolean.TRUE.equals(needReassign)) {
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
} else if (Boolean.FALSE.equals(needReassign)) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
return reassignPendingPage(pageNo, pageSize, keyword);
}
applyKeywordFilter(wrapper, keyword);
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setKeyword(keyword);
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
query.setStatuses(List.of(0, 1));
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
query.setCurrent(pageNo);
query.setSize(pageSize);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
page.setRecords(List.of());
return page;
}
/**
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
*/
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
Set<Long> exceptionWaybillIds) {
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
if (!trips.isEmpty()) {
Set<String> reassignKeys = loadReassignTripKeys(
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
for (TripVO trip : trips) {
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
}
}
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
page.setRecords(cards);
return page;
}
/**
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
*/
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
Set<String> reassignTripIds) {
AdminWaybillCardVO card = new AdminWaybillCardVO();
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
card.setTripId(trip.getId());
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
card.setWaybillCount(trip.getWaybillCount());
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
card.setViaName(Func.toStr(trip.getTransitName(), ""));
card.setStationCount(trip.getStationCount());
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
card.setWeight(weight);
card.setTotalWeight(weight);
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
card.setStatus(trip.getStatus());
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
card.setCreateTime(formatDateTime(trip.getSortTime()));
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
card.setBuyerPaid(false);
return card;
}
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
if (Func.isEmpty(tripIds)) {
return Set.of();
}
// 被拒子单一次取回后在服务层换算运次键
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
.stream()
.map(this::tripKeyOf)
.filter(tripIds::contains)
.collect(Collectors.toSet());
}
private boolean hasRejectedChild(String tripId) {
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
}
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
return false;
}
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
}
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
private Integer toTripStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> 0;
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
case "2", STATUS_COMPLETED, "done" -> 2;
case "3", STATUS_CANCELLED -> 3;
default -> null;
};
}
private Date parseDayStart(String date) {
if (Func.isBlank(date)) {
return null;
}
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
}
private Date parseDayEnd(String date) {
if (Func.isBlank(date)) {
return null;
}
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
}
private Long parseId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
private String tripKeyOf(Waybill waybill) {
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
}
/** 运次的键:同上,运次卡片取运次主键换算 */
private String tripKeyOf(TripVO trip) {
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
}
/* ===========================================================================
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
* =========================================================================*/
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
private TripVO visibleTrip(String tripId) {
try {
TripQueryVO query = TripQueryVO.forAdmin();
query.setTripId(tripId);
return tripQueryService.detail(query);
} catch (ServiceException ignored) {
return null;
}
}
/**
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
*/
private AdminWaybillDetailVO tripDetail(TripVO trip) {
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
List<Long> scopeIds = new ArrayList<>();
children.forEach(child -> {
Long childId = parseId(child.getId());
if (childId != null) {
scopeIds.add(childId);
}
});
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
scopeIds.add(parseId(trip.getId()));
}
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
detail.setStatus(trip.getStatus());
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
detail.setTripId(trip.getId());
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
detail.setStationCount(trip.getStationCount());
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
detail.setPickupAddress(detail.getFromAddress());
detail.setUnloadAddress(detail.getToAddress());
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
detail.setCarrierName("");
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
detail.setRejectReason("");
detail.setStations(stations);
detail.setRoutePoints(toRoutePoints(stations));
fillException(detail, scopeIds);
fillPunchSnapshot(detail, firstChildId);
return detail;
}
/**
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
*/
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
Set<Long> scopeIds = new LinkedHashSet<>();
scopeIds.add(waybill.getId());
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
if (Func.isEmpty(waybill.getLoadingNo())) {
fillException(detail, scopeIds);
return detail;
}
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
children.forEach(child -> scopeIds.add(child.getId()));
LoadingManage loading = findLoading(waybill.getLoadingNo());
if (loading != null) {
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
detail.setTripId(String.valueOf(loading.getId()));
detail.setLoadingNo(loading.getLoadingNo());
detail.setTripNo(loading.getLoadingNo());
detail.setTransportOrgType(TRANSPORT_LOAD);
detail.setViaName(TripStationAssembler.transitName(stations));
detail.setStationCount(stations.size());
detail.setStations(stations);
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
if (Func.isEmpty(detail.getDriverName())) {
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
}
if (Func.isEmpty(detail.getVehicleNo())) {
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
}
}
fillException(detail, scopeIds);
return detail;
}
/**
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
*/
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
Waybill head = children.isEmpty() ? null : children.get(0);
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(head == null ? loading.getId() : head.getId());
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
detail.setTransportOrgType(TRANSPORT_LOAD);
detail.setTripId(String.valueOf(loading.getId()));
detail.setLoadingNo(loading.getLoadingNo());
detail.setTripNo(loading.getLoadingNo());
detail.setViaName(TripStationAssembler.transitName(stations));
detail.setStationCount(stations.size());
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
detail.setFromName(TripStationAssembler.departureName(stations));
detail.setToName(TripStationAssembler.arrivalName(stations));
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
detail.setPickupAddress(detail.getFromAddress());
detail.setUnloadAddress(detail.getToAddress());
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
detail.setCarrierName("");
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
detail.setNeedReassign(true);
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
detail.setRejectReason("");
detail.setStations(stations);
detail.setRoutePoints(toRoutePoints(stations));
fillException(detail, scopeIds);
fillPunchSnapshot(detail, head == null ? null : head.getId());
return detail;
}
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
private String tripBusinessStatus(List<Waybill> children) {
if (children.isEmpty()) {
return null;
}
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
return TripAggregation.statusOf(children, requireAccept, accepted);
}
private List<Waybill> visibleChildren(String loadingNo) {
if (Func.isEmpty(loadingNo)) {
return List.of();
}
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getLoadingNo, loadingNo)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
.orderByAsc(Waybill::getCreateTime));
}
private LoadingManage findLoading(String loadingNo) {
if (Func.isEmpty(loadingNo)) {
return null;
}
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
.eq(LoadingManage::getLoadingNo, loadingNo)
.last("LIMIT 1"));
}
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
ExceptionDisposal latest = latestException(waybillIds);
detail.setHasException(latest != null);
detail.setExceptionId(latest == null ? null : latest.getId());
}
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) {
return null;
}
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getWaybillId, waybillIds)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
}
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
if (waybillId == null) {
return;
}
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
if (punch == null) {
return;
}
detail.setPunchNodes(punch.getPunchNodes());
detail.setEnrouteRecords(punch.getEnrouteRecords());
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
rp.setName(p.getName());
rp.setAddress(p.getAddress());
rp.setStatus(p.getStatus());
return rp;
}).toList());
}
}
/** 点位列:整趟车的全部站点,站序即途经顺序 */
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
if (Func.isEmpty(stations)) {
return List.of();
}
return stations.stream().map(station -> {
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
point.setName(Func.toStr(station.getName(), ""));
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
point.setStatus("pending");
return point;
}).toList();
}
/**
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
*/
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
applyGroupKeyword(wrapper, keyword);
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
Page<Waybill> rows = new Page<>(pageNo, pageSize);
waybillService.page(rows, wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
return page;
}
/**
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
*/
private QueryWrapper<Waybill> reassignGroupQuery() {
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
QueryWrapper<Waybill> wrapper = Wrappers.query();
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
"MIN(driver_reject_reason) AS driver_reject_reason",
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
List<Long> deptIds = manageableDeptIds();
if (Func.isNotEmpty(deptIds)) {
wrapper.in("dept_id", deptIds);
}
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
return wrapper;
}
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
if (Func.isEmpty(rows)) {
return List.of();
}
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
card.setLoadingNo(loadingNo);
card.setWaybillCount(1);
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
card.setNeedReassign(true);
if (Func.isNotEmpty(loadingNo)) {
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
card.setTransportType(TRANSPORT_LOAD);
card.setFromName(TripStationAssembler.departureName(stations));
card.setToName(TripStationAssembler.arrivalName(stations));
card.setViaName(TripStationAssembler.transitName(stations));
card.setStationCount(stations.size());
} else {
card.setTransportType(TRANSPORT_COMMON);
card.setStationCount(2);
}
card.setCreateTime(formatDateTime(row.getCreateTime()));
cards.add(card);
}
return cards;
}
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
.stream()
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
}
private Long loadingId(LoadingManage loading, Long fallback) {
return loading == null || loading.getId() == null ? fallback : loading.getId();
}
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
}
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
}
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setPickupAddress(fromAddr);
detail.setUnloadAddress(toAddr);
String cargo = Func.toStr(waybill.getCargoName(), "");
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
detail.setCargoName(cargo);
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
detail.setCargoName(String.join("、", cargoNames));
detail.setCargoNames(cargoNames);
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
@@ -371,82 +883,26 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
};
}
private long countWaybillByStatus(String status) {
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getBusinessStatus, status));
/**
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
* <p>
* 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}),
* 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。
*/
private List<Long> manageableDeptIds() {
return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理");
}
/**
* 小程序调度端不做组织过滤。
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
* <p>
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
*/
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
return Wrappers.<Waybill>lambdaQuery();
}
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
String businessStatus = toBusinessStatus(status);
if (Func.isNotBlank(businessStatus)) {
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
}
}
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
if (EXCEPTION_YES.equals(exception)) {
wrapper.in(Waybill::getId, exceptionWaybillIds);
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
}
}
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
if (TRANSPORT_LOAD.equals(transportType)) {
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
} else if (TRANSPORT_COMMON.equals(transportType)) {
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
}
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
.or().like(Waybill::getDriverName, key)
.or().like(Waybill::getVehicleNo, key));
}
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
if (Func.isNotBlank(startDate)) {
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
if (start != null) {
wrapper.ge(Waybill::getCreateTime, start);
}
}
if (Func.isNotBlank(endDate)) {
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
if (end != null) {
wrapper.le(Waybill::getCreateTime, end);
}
}
}
/** 小程序 status → 后端 businessStatus */
private String toBusinessStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> STATUS_PENDING;
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
default -> null;
};
}
private Set<Long> loadIncompleteExceptionWaybillIds() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.select(ExceptionDisposal::getWaybillId)
@@ -546,6 +1002,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return Func.isBlank(unit) ? qty : qty + unit;
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
*/
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
}
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (WaybillCargoSupport.Cargo cargo : cargos) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return fallback;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return "";
}
total = total.add(cargo.quantity());
}
return formatWeight(total, unit);
}
private String formatLocalDate(LocalDate date) {
if (date == null) {
return "";
@@ -570,13 +1053,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
}
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
/**
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
* <p>
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
*/
private long countPendingReassignments() {
return waybillService.count(pendingReassignQuery());
Page<Waybill> rows = new Page<>(1, 1);
waybillService.page(rows, reassignGroupQuery());
return rows.getTotal();
}
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
return Wrappers.<Waybill>lambdaQuery()
return rejectedChildQuery()
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
}
@@ -611,11 +1101,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
.collect(Collectors.toList());
}
/**
* 待重新派单的待办:一条 = 一个运次。
* <p>
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
*/
private List<TimedTodo> buildReassignFeed() {
List<Waybill> list = waybillService.list(pendingReassignQuery()
.orderByDesc(Waybill::getDriverRejectTime)
List<Waybill> rows = waybillService.list(reassignGroupQuery()
.orderByDesc(SQL_MAX_REJECT_TIME)
.last("LIMIT " + FEED_LIMIT));
return list.stream()
return rows.stream()
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
.collect(Collectors.toList());
}
@@ -649,7 +1145,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
item.setTitle("重新派单待处理");
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
item.setDesc(buildReassignDesc(waybill));
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
item.setActionLabel("重新派单");
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
return item;
@@ -0,0 +1,173 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.support.WaybillProcessSupport;
import java.util.List;
/**
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
* <p>
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
* <p>
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
*/
final class TripAggregation {
/** 运单业务状态 */
static final String PENDING = "pending";
static final String WAITING_DISPATCH = "waiting_dispatch";
static final String DISPATCHING = "dispatching";
static final String RUNNING = "running";
static final String COMPLETED = "completed";
static final String CANCELLED = "cancelled";
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
/** 终态档 */
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
/**
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
* 否则「子单全已取消」会被算成运输中而不是已取消。
*/
static final List<String> CHILD_STATUSES = List.of(
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
);
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
/**
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
*/
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
private TripAggregation() {
}
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
static String keyOf(String loadingNo, Long id) {
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
}
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
static Integer appStatus(String status) {
return switch (Func.toStr(status, "")) {
case PENDING -> 0;
case RUNNING -> 1;
case COMPLETED -> 2;
case CANCELLED -> 3;
default -> null;
};
}
/**
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
* 否则需接单且未接单取待接单,其余取运输中。
* <p>
* 子单集合为空时无从判定,返回空。
*/
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
String status = classify(count(children));
if (status == null || TERMINAL_STATUSES.contains(status)) {
return status;
}
if (!requireAccept) {
return RUNNING;
}
return accepted ? RUNNING : PENDING;
}
/**
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
*/
static Counters count(List<Waybill> children) {
int total = 0;
int terminal = 0;
int completed = 0;
int pending = 0;
for (Waybill child : children) {
String status = child == null ? null : child.getBusinessStatus();
total++;
if (status != null && TERMINAL_STATUSES.contains(status)) {
terminal++;
}
if (COMPLETED.equals(status)) {
completed++;
}
if (status != null && PENDING_STATUSES.contains(status)) {
pending++;
}
}
return new Counters(total, terminal, completed, pending);
}
private static String classify(Counters counters) {
if (counters.total() == 0) {
return null;
}
if (counters.terminal() == counters.total()) {
return counters.completed() > 0 ? COMPLETED : CANCELLED;
}
return counters.pending() > 0 ? PENDING : RUNNING;
}
/**
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
* <p>
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
*/
static boolean requiresAccept(List<Waybill> children) {
return children.stream()
.anyMatch(child -> child != null
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
}
/**
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
* 否则有子单待接单为待接单,无从判定时为空。
*/
static String acceptStatus(List<Waybill> children) {
boolean anyPending = false;
for (Waybill child : children) {
String status = child == null ? null : child.getDriverAcceptStatus();
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
return WaybillProcessSupport.ACCEPT_ACCEPTED;
}
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
anyPending = true;
}
}
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
}
/** 子单聚合计数 */
record Counters(int total, int terminal, int completed, int pending) {
}
}
@@ -0,0 +1,820 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 运次读服务实现
* <p>
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
* <p>
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
* 先分页再过滤会把该在第一页的运次挤到后面几页。
* <p>
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
* 于是整趟车被拒单后整个运次自然消失。
*/
@Service
@RequiredArgsConstructor
public class TripQueryServiceImpl implements ITripQueryService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
private static final String TRANSPORT_LOAD = "load";
private static final String TRANSPORT_COMMON = "common";
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
private static final String LONE_KEY_PREFIX = "W";
/** 异常筛选的取值:命中未完成异常的子单 */
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
/** 关键字在子单上匹配的字段 */
private static final String[] KEYWORD_COLUMNS = {
"waybill_no", "loading_no", "departure_name", "arrival_name",
"departure_address", "arrival_address", "vehicle_no"
};
private final IWaybillService waybillService;
private final ILoadingManageService loadingManageService;
private final IProcessConfigService processConfigService;
@Override
public IPage<TripVO> page(TripQueryVO query) {
TripQueryVO condition = condition(query);
int pageNo = pageNo(condition.getCurrent());
int pageSize = pageSize(condition.getSize());
if (!scoped(condition)) {
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
return new Page<>(pageNo, pageSize);
}
List<TripRows> trips = trips(condition);
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
int from = Math.min((pageNo - 1) * pageSize, trips.size());
int to = Math.min(from + pageSize, trips.size());
page.setRecords(assemble(trips.subList(from, to)));
return page;
}
@Override
public TripTabCountsVO tabCounts(TripQueryVO query) {
TripQueryVO condition = condition(query);
TripTabCountsVO counts = new TripTabCountsVO();
if (!scoped(condition)) {
return counts;
}
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
long pending = 0;
long doing = 0;
long done = 0;
for (TripRows trip : trips(withoutTab(condition))) {
Integer tier = trip.tier();
if (tier == null) {
continue;
}
switch (tier) {
case 0 -> pending++;
case 1 -> doing++;
case 2 -> done++;
default -> {
}
}
}
counts.setPending(pending);
counts.setDoing(doing);
counts.setDone(done);
counts.setAll(pending + doing + done);
return counts;
}
@Override
public TripVO detail(TripQueryVO query) {
TripQueryVO condition = condition(query);
Long id = tripId(condition.getTripId());
if (id == null || !scoped(condition)) {
throw new ServiceException("运次不存在或无权操作");
}
LoadingManage loading = visibleLoading(id, condition);
if (loading != null) {
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
.getOrDefault(loading.getLoadingNo(), List.of());
if (children.isEmpty()) {
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
throw new ServiceException("运次不存在或无权操作");
}
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
applyLiveProcessConfig(children);
return loadingTrip(loading, children, true);
}
Waybill waybill = visibleWaybill(id, condition);
if (waybill == null) {
throw new ServiceException("运次不存在或无权操作");
}
applyLiveProcessConfig(List.of(waybill));
return waybillTrip(waybill, true);
}
/**
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
* <p>
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
* 命中的行同时决定这一趟车的子单集合与分档。
*/
private List<TripRows> trips(TripQueryVO condition) {
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill row : visibleRows(condition)) {
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
ignored -> new ArrayList<>()).add(row);
}
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
Set<Integer> tiers = tiers(condition);
List<TripRows> trips = new ArrayList<>(grouped.size());
grouped.forEach((key, rows) -> {
if (excluded.contains(key)) {
return;
}
List<Waybill> children = sortedChildren(rows);
if (!inScope(children, condition)) {
return;
}
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
String status = TripAggregation.statusOf(children, requireAccept, accepted);
Integer tier = TripAggregation.appStatus(status);
if (tier == null || !tiers.contains(tier)) {
return;
}
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
});
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(TripRows::key));
return trips;
}
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
private List<Waybill> sortedChildren(List<Waybill> rows) {
List<Waybill> children = new ArrayList<>(rows);
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
return children;
}
/**
* 段一:一次取回该收口与行级筛选命中的可见运单行
* <p>
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
*/
private List<Waybill> visibleRows(TripQueryVO condition) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
Waybill::getGoodsJson,
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
applyScope(wrapper, condition);
applyDeptScope(wrapper, condition);
applyOrgTypeFilter(wrapper, condition);
applyKeyword(wrapper, condition.getKeyword());
List<Waybill> rows = waybillService.list(wrapper);
applyLiveProcessConfig(rows);
return rows;
}
/**
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
* <p>
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
*/
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (Func.isEmpty(condition.getDeptIds())) {
return;
}
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
}
/**
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
*/
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') <> ''");
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') = ''");
}
}
/**
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
* 会让同一趟车归在某一档、卡片却显示另一档。
* <p>
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
*/
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
if (Func.isNotEmpty(exceptionIds)) {
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
return false;
}
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
return false;
}
}
Date created = maxCreateTime(children);
if (condition.getCreatedFrom() != null
&& (created == null || created.before(condition.getCreatedFrom()))) {
return false;
}
return condition.getCreatedTo() == null
|| (created != null && !created.after(condition.getCreatedTo()));
}
/**
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
* <p>
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
*/
private void applyLiveProcessConfig(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
return;
}
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (projectIds.isEmpty()) {
return;
}
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
if (live.isEmpty()) {
return;
}
for (Waybill row : rows) {
String liveJson = live.get(row.getProjectId());
if (Func.isNotEmpty(liveJson)) {
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
}
}
}
/**
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
*/
private Set<String> rejectedTripKeys() {
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
*/
private Set<Integer> tiers(TripQueryVO condition) {
if (Func.isNotEmpty(condition.getStatuses())) {
return new LinkedHashSet<>(condition.getStatuses());
}
if (condition.getStatus() != null) {
return Set.of(condition.getStatus());
}
return Set.of(0, 1, 2);
}
/**
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
* <p>
* 手机号允许带空格,与运单列表的收口判据一致。
*/
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (condition.isAllDrivers()) {
return;
}
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
wrapper.and(scope -> scope
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
.or(lone -> lone
.apply("IFNULL(loading_no, '') = ''")
.and(row -> row.eq(Waybill::getDriverPhone, phone)
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
}
/**
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
* <p>
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
* 否则状态与排序会按命中的那一部分子单算。
*/
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isEmpty(keyword)) {
return;
}
Set<String> keys = keywordTripKeys(keyword.trim());
if (keys.isEmpty()) {
wrapper.apply("1 = 0");
return;
}
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
.map(this::tripId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
wrapper.and(matched -> {
if (!loadingNos.isEmpty()) {
matched.in(Waybill::getLoadingNo, loadingNos);
}
if (!loneIds.isEmpty()) {
if (!loadingNos.isEmpty()) {
matched.or();
}
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
}
});
}
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
private Set<String> keywordTripKeys(String keyword) {
String like = "%" + keyword + "%";
Object[] params = new Object[KEYWORD_COLUMNS.length];
StringBuilder matched = new StringBuilder();
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
if (i > 0) {
matched.append(" OR ");
}
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
params[i] = like;
}
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.apply("(" + matched + ")", params))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
* <p>
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
*/
private List<TripVO> assemble(List<TripRows> trips) {
if (Func.isEmpty(trips)) {
return new ArrayList<>();
}
Set<String> loadingNos = trips.stream()
.map(TripRows::loadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
List<TripVO> result = new ArrayList<>(trips.size());
for (TripRows trip : trips) {
if (Func.isEmpty(trip.loadingNo())) {
result.add(waybillTrip(trip.children().get(0), false));
continue;
}
LoadingManage loading = loadings.get(trip.loadingNo());
if (loading == null) {
// 配载单被并发删除:不给出没有主键的运次
continue;
}
result.add(loadingTrip(loading, trip.children(), false));
}
return result;
}
/**
* 段二:按本页运次的配载单号一次取回配载单
*/
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
.in(LoadingManage::getLoadingNo, loadingNos))
.stream()
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
}
/**
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
* <p>
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
*/
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
return Map.of();
}
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
}
grouped.values().forEach(group -> group.sort(
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
return grouped;
}
/**
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
*/
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
TripVO trip = new TripVO();
trip.setId(tripIdOf(loading.getId()));
trip.setLoadingNo(loading.getLoadingNo());
trip.setTripNo(loading.getLoadingNo());
trip.setTransportOrgType(TRANSPORT_LOAD);
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(loading.getRouteJson());
trip.setGoodsJson(loading.getGoodsJson());
}
fillAggregation(trip, children);
fillRouteAndCargo(trip, loading, children, withPayload);
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(), ""));
// 未配载没有配载单可依,计划时间只能取这张运单自己的
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(waybill.getRouteJson());
trip.setGoodsJson(waybill.getGoodsJson());
}
fillAggregation(trip, List.of(waybill));
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
return trip;
}
/**
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
*/
private void fillAggregation(TripVO trip, List<Waybill> children) {
String acceptStatus = TripAggregation.acceptStatus(children);
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
trip.setAcceptStatus(acceptStatus);
trip.setRequireAccept(requireAccept);
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
trip.setWaybillCount(children.size());
trip.setSortTime(maxCreateTime(children));
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
}
/**
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
*/
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
trip.setDepartureName(TripStationAssembler.departureName(stations));
trip.setTransitName(TripStationAssembler.transitName(stations));
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
trip.setStationCount(stations.size());
if (withPayload) {
trip.setStations(stations);
}
List<WaybillCargoSupport.Cargo> cargos = children.stream()
.flatMap(child -> WaybillCargoSupport.of(child).stream())
.toList();
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
trip.setCargoNames(cargoNames);
if (!cargoNames.isEmpty()) {
trip.setCargoName(String.join("、", cargoNames));
}
List<String> cargoTypes = cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList();
trip.setCargoTypes(cargoTypes);
if (!cargoTypes.isEmpty()) {
trip.setCargoType(String.join("、", cargoTypes));
}
trip.setCargoSummary(cargoSummary(children));
fillTotalQuantity(trip, children);
}
/**
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
* <p>
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
*/
private String cargoSummary(List<Waybill> children) {
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
if (cargos.isEmpty()) {
continue;
}
for (WaybillCargoSupport.Cargo cargo : cargos) {
String name = cargo.displayName();
String unit = Func.toStr(cargo.quantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
byUnit.merge(unit, quantity, BigDecimal::add);
}
}
List<String> parts = new ArrayList<>();
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
return String.join("、", parts);
}
/**
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
* <p>
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
*/
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
String unit = null;
BigDecimal total = BigDecimal.ZERO;
boolean any = false;
for (Waybill child : children) {
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return;
}
total = total.add(cargo.quantity());
any = true;
}
}
if (!any || unit == null) {
return;
}
trip.setTotalQuantity(total);
trip.setQuantityUnit(unit);
}
private String formatQuantity(BigDecimal quantity, String unit) {
String value = quantity.stripTrailingZeros().toPlainString();
return Func.isEmpty(unit) ? value : value + unit;
}
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());
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
child.setCargoTypes(cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList());
child.setCargoType(waybill.getCargoType());
child.setQuantity(waybill.getQuantity());
child.setQuantityUnit(waybill.getQuantityUnit());
child.setDepartureName(waybill.getDepartureName());
child.setDepartureAddress(waybill.getDepartureAddress());
child.setArrivalName(waybill.getArrivalName());
child.setArrivalAddress(waybill.getArrivalAddress());
child.setCreateTime(waybill.getCreateTime());
return child;
}
/** 排序键:子单 create_time 的最大值 */
private Date maxCreateTime(List<Waybill> children) {
return children.stream()
.map(Waybill::getCreateTime)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.orElse(null);
}
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
if (Func.isNotEmpty(preferred)) {
return preferred;
}
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
}
/**
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
*/
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
LoadingManage loading = loadingManageService.getById(id);
if (loading == null) {
return null;
}
if (condition.isAllDrivers()) {
return loading;
}
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
}
/**
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
*/
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
Waybill waybill = waybillService.getById(id);
if (waybill == null
|| Func.isNotEmpty(waybill.getLoadingNo())
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
return null;
}
if (condition.isAllDrivers()) {
return waybill;
}
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
}
private boolean scoped(TripQueryVO condition) {
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
}
private boolean samePhone(String left, String right) {
String expected = Func.toStr(right, "").trim().replace(" ", "");
if (Func.isEmpty(expected)) {
return false;
}
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
}
private TripQueryVO condition(TripQueryVO query) {
return query == null ? new TripQueryVO() : query;
}
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
private TripQueryVO withoutTab(TripQueryVO source) {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setAllDrivers(source.isAllDrivers());
copy.setDeptIds(source.getDeptIds());
copy.setKeyword(source.getKeyword());
copy.setTransportOrgType(source.getTransportOrgType());
copy.setException(source.getException());
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
copy.setCreatedFrom(source.getCreatedFrom());
copy.setCreatedTo(source.getCreatedTo());
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
return copy;
}
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 record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
Date sortTime) {
}
private int pageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
private int pageSize(Integer size) {
if (size == null || size < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(size, MAX_PAGE_SIZE);
}
}
@@ -0,0 +1,368 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import org.springblade.core.tool.jackson.JsonUtil;
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.TripStationVO;
import org.springblade.transport.support.WaybillCargoSupport;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来
* <p>
* 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。
* 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址;
* 都没有则给空站点数组,前端按空站点渲染而不是崩。
* <p>
* 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
*/
final class TripStationAssembler {
private static final String LOAD = "load";
private static final String UNLOAD = "unload";
private static final String LOAD_UNLOAD = "load_unload";
/** 途经站点之间的连接符 */
private static final String TRANSIT_JOINER = " → ";
/** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */
private static final String KEY_SEPARATOR = "\u0000";
private TripStationAssembler() {
}
/**
* 拼装整趟车的站点,站序从 1 开始
*
* @param loading 配载单,未配载传 null
* @param children 该运次的可见子运单
*/
static List<TripStationVO> assemble(LoadingManage loading, List<Waybill> children) {
List<TripStationVO> stations = fromRoute(loading, children);
if (stations.isEmpty()) {
stations = fromWaybillPoints(children);
}
if (stations.isEmpty() && loading != null) {
stations = fromLoadingAddresses(loading);
}
for (int i = 0; i < stations.size(); i++) {
stations.get(i).setIndex(i + 1);
}
return stations;
}
/**
* 起点名称:首站站名,没有站点时为空
*/
static String departureName(List<TripStationVO> stations) {
return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), "");
}
/**
* 终点名称:末站站名,没有站点时为空
*/
static String arrivalName(List<TripStationVO> stations) {
return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), "");
}
/**
* 途经站点:中间各站按顺序连接,不足三站为空
*/
static String transitName(List<TripStationVO> stations) {
if (stations.size() < 3) {
return "";
}
List<String> names = new ArrayList<>();
for (int i = 1; i < stations.size() - 1; i++) {
String name = Func.toStr(stations.get(i).getName(), "");
if (Func.isNotEmpty(name)) {
names.add(name);
}
}
return String.join(TRANSIT_JOINER, names);
}
/**
* 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取
*/
private static List<TripStationVO> fromRoute(LoadingManage loading, List<Waybill> children) {
if (loading == null) {
return new ArrayList<>();
}
List<Map<String, Object>> route = objectList(loading.getRouteJson());
if (route.isEmpty()) {
return new ArrayList<>();
}
List<String> waybillIds = stringList(loading.getWaybillIdsJson());
Map<String, Waybill> byId = new LinkedHashMap<>();
for (Waybill child : children) {
if (child.getId() != null) {
byId.put(String.valueOf(child.getId()), child);
}
}
List<TripStationVO> stations = new ArrayList<>();
for (Map<String, Object> element : route) {
String address = stringValue(element.get("address"));
String display = stringValue(element.get("displayAddress"));
TripStationVO station = new TripStationVO();
station.setName(Func.toStrWithEmpty(display, address));
station.setAddress(Func.toStrWithEmpty(address, station.getName()));
List<String> tags = stringList(element.get("tags"));
station.setTags(tags);
List<TripStationVO.TripCargoVO> cargos = new ArrayList<>();
boolean load = false;
boolean unload = false;
for (String tag : tags) {
boolean loadTag = tag.contains("装");
boolean unloadTag = tag.contains("卸");
if (!loadTag && !unloadTag) {
continue;
}
Waybill child = childOfTag(tag, waybillIds, byId);
if (child == null) {
continue;
}
load |= loadTag;
unload |= unloadTag;
addCargo(cargos, child, loadTag ? LOAD : UNLOAD);
if (loadTag && unloadTag) {
addCargo(cargos, child, UNLOAD);
}
}
fillOperation(station, load, unload);
station.setCargos(cargos);
stations.add(station);
}
return stations;
}
/**
* 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现
*/
private static List<TripStationVO> fromWaybillPoints(List<Waybill> children) {
Map<String, TripStationVO> stations = new LinkedHashMap<>();
Map<String, boolean[]> operations = new LinkedHashMap<>();
for (Waybill child : children) {
String fromName = pointName(child.getDepartureName(), child.getDepartureAddress());
String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName);
String toName = pointName(child.getArrivalName(), child.getArrivalAddress());
String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName);
point(stations, operations, fromName, fromAddress, true, child);
point(stations, operations, toName, toAddress, false, child);
}
operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1]));
return new ArrayList<>(stations.values());
}
private static void point(Map<String, TripStationVO> stations, Map<String, boolean[]> operations,
String name, String address, boolean load, Waybill child) {
if (Func.isEmpty(name) && Func.isEmpty(address)) {
return;
}
String key = name + KEY_SEPARATOR + address;
TripStationVO station = stations.get(key);
if (station == null) {
station = new TripStationVO();
station.setName(name);
station.setAddress(address);
station.setTags(new ArrayList<>());
station.setCargos(new ArrayList<>());
stations.put(key, station);
operations.put(key, new boolean[2]);
}
boolean[] flags = operations.get(key);
flags[0] |= load;
flags[1] |= !load;
addCargo(station.getCargos(), child, load ? LOAD : UNLOAD);
}
/**
* 站点再退到配载单自己的发货 / 途经 / 到货地址
*/
private static List<TripStationVO> fromLoadingAddresses(LoadingManage loading) {
List<TripStationVO> stations = new ArrayList<>();
for (String address : List.of(
Func.toStr(loading.getDepartureAddress(), ""),
Func.toStr(loading.getTransitAddress(), ""),
Func.toStr(loading.getArrivalAddress(), ""))) {
if (Func.isEmpty(address)) {
continue;
}
TripStationVO station = new TripStationVO();
station.setName(address);
station.setAddress(address);
station.setTags(new ArrayList<>());
station.setCargos(new ArrayList<>());
stations.add(station);
}
return stations;
}
/**
* 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次
*/
private static Waybill childOfTag(String tag, List<String> waybillIds, Map<String, Waybill> byId) {
String digits = tag.replaceAll("\\D+", "");
if (Func.isEmpty(digits)) {
return null;
}
int order = Func.toInt(digits, 0);
if (order < 1 || order > waybillIds.size()) {
return null;
}
return byId.get(waybillIds.get(order - 1));
}
/**
* 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行,
* 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。
* <p>
* 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载,
* 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。
*/
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
if (child == null) {
return;
}
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
List<WaybillCargoSupport.Cargo> items = WaybillCargoSupport.of(child);
if (items.isEmpty()) {
// 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName("");
cargo.setCustomerName(child.getCustomerName());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
return;
}
for (WaybillCargoSupport.Cargo item : items) {
String cargoName = item.displayName();
boolean exists = cargos.stream().anyMatch(cargo ->
Objects.equals(cargo.getWaybillId(), waybillId)
&& Objects.equals(cargo.getOperation(), operation)
&& Objects.equals(cargo.getCargoName(), cargoName));
if (exists) {
continue;
}
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName(cargoName);
cargo.setCustomerName(child.getCustomerName());
cargo.setQuantity(item.quantity());
cargo.setQuantityUnit(item.quantityUnit());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
}
}
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
if (load && unload) {
station.setOperationType(LOAD_UNLOAD);
station.setOperationTypeLabel("装卸");
} else if (load) {
station.setOperationType(LOAD);
station.setOperationTypeLabel("装货");
} else if (unload) {
station.setOperationType(UNLOAD);
station.setOperationTypeLabel("卸货");
} else {
station.setOperationType(null);
station.setOperationTypeLabel("");
}
}
private static String pointName(String name, String address) {
return Func.toStrWithEmpty(name, Func.toStr(address, ""));
}
private static List<Map<String, Object>> objectList(String json) {
if (Func.isEmpty(json)) {
return new ArrayList<>();
}
try {
Object parsed = JsonUtil.parse(json, List.class);
if (parsed instanceof List<?> list) {
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map<?, ?> map) {
Map<String, Object> element = new LinkedHashMap<>();
map.forEach((key, value) -> element.put(String.valueOf(key), value));
result.add(element);
}
}
return result;
}
} catch (Exception ignored) {
// 路线信息坏了不该让整趟车读不出来,按没有路线信息回退
}
return new ArrayList<>();
}
private static List<String> stringList(Object value) {
List<String> result = new ArrayList<>();
if (value instanceof List<?> list) {
for (Object item : list) {
String text = stringValue(item);
if (Func.isNotEmpty(text)) {
result.add(text);
}
}
return result;
}
String text = stringValue(value);
if (Func.isEmpty(text)) {
return result;
}
try {
Object parsed = JsonUtil.parse(text, List.class);
if (parsed instanceof List<?> list) {
for (Object item : list) {
String item2 = stringValue(item);
if (Func.isNotEmpty(item2)) {
result.add(item2);
}
}
return result;
}
} catch (Exception ignored) {
// 不是 JSON 数组就当逗号分隔处理
}
return Func.toStrList(",", text);
}
private static String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
}
@@ -40,12 +40,13 @@ import org.springblade.thirdparty.lbs.feign.ILbsClient;
import org.springblade.thirdparty.lbs.pojo.dto.LbsLocateRequest;
import org.springblade.thirdparty.lbs.pojo.vo.LbsLocateResponse;
import org.springblade.transport.excel.WaybillExcel;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
@@ -66,6 +67,8 @@ import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.LoadableAdmissionSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.WaybillWrapper;
@@ -82,6 +85,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -125,6 +129,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@jakarta.annotation.Resource
private WaybillEnroutePunchMapper waybillEnroutePunchMapper;
@jakarta.annotation.Resource
private ExceptionDisposalMapper exceptionDisposalMapper;
@jakarta.annotation.Resource
private ILbsClient lbsClient;
@@ -822,27 +829,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return null;
}
/** 动态过程配置优先,回退运单快照 */
/**
* 生效配置优先,回退运单快照。
* <p>
* 取法与二选一规则在 {@link LiveProcessConfigSupport};这里不用「生效配置没有可打卡节点就回退快照」
* 那一版——接单需求、节点门禁、节点订正都读这份配置判断节点存不存在,生效配置是权威,
* 不能因为快照里有节点而被顶掉。
*/
private String resolveLiveProcessJson(Waybill waybill) {
if (waybill.getProjectId() != null) {
String projectId = String.valueOf(waybill.getProjectId());
String live = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectId)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
if (Func.isNotEmpty(live)) {
return live;
}
if (waybill == null) {
return null;
}
return waybill.getProcessJson();
return LiveProcessConfigSupport.resolve(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
}
private Map<String, List<String>> buildVoucherTypesIndex(String processJson) {
@@ -1175,15 +1175,6 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setDriverRejectReason(null);
}
private boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds)) {
return false;
}
return List.of(projectIds.split(",")).stream()
.map(String::trim)
.anyMatch(projectId::equals);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BusinessRemoveResultVO removeWaybill(String ids) {
@@ -1512,6 +1503,61 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return doComplete(waybill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean confirmReturnReceipt(Long id) {
Waybill waybill = loadEditable(id, true);
String processJson = LiveProcessConfigSupport.resolve(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
// 否则用户会以为是确认没生效
throw new ServiceException("该运单的回单不需要业务员确认");
}
assertPunchNodesFinished(waybill.getId(), processJson);
assertNoUnresolvedException(waybill.getId());
return doComplete(waybill);
}
/**
* 司机打卡节点(在途除外)必须全部已打卡:完成接口本身不要求节点打卡,
* 不挡住就能从「确认回单」绕过打卡门禁。
*/
private void assertPunchNodesFinished(Long waybillId, String processJson) {
List<String> missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId));
if (!missing.isEmpty()) {
throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing));
}
}
/** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */
private Set<String> punchedNodeKeys(Long waybillId) {
Set<String> keys = new LinkedHashSet<>();
List<WaybillNodePunch> punches = waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId));
for (WaybillNodePunch punch : punches) {
if (Func.isNotEmpty(punch.getNodeCode())) {
keys.add(punch.getNodeCode().trim().toLowerCase(Locale.ROOT));
}
if (Func.isNotEmpty(punch.getNodeName())) {
keys.add(punch.getNodeName().trim().toLowerCase(Locale.ROOT));
}
}
return keys;
}
/** 存在未了结异常时不允许置完成:判据与司机端完成同一道门(处置单达终态才算解除) */
private void assertNoUnresolvedException(Long waybillId) {
Long unresolved = exceptionDisposalMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, waybillId)
.eq(ExceptionDisposal::getIsDeleted, 0)
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES));
if (unresolved != null && unresolved > 0) {
throw new ServiceException("该运单存在未处理异常,处理完成后才能确认回单");
}
}
/**
* 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。
*/
@@ -1691,6 +1737,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
if (Objects.equals(waybill.getOnlyUnassignedLoading(), 1)) {
queryWrapper.and(wrapper -> wrapper.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
if (Func.isNotEmpty(waybill.getIds())) {
queryWrapper.in(Waybill::getId, waybill.getIds());
}
if (Objects.equals(waybill.getLoadableOnly(), 1)) {
LoadableAdmissionSupport.applyLoadableConditions(queryWrapper);
}
if (Func.isNotEmpty(waybill.getBatchNo())) {
queryWrapper.like(Waybill::getBatchNo, waybill.getBatchNo());
}
@@ -0,0 +1,161 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.service.IProcessConfigService;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。
* <p>
* 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取
* 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。
* 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 ——
* 只靠 LIKE 会把「1234」命中到「12345」上。
* <p>
* 两个二选一版本的区别是**得到什么结果**,不是风格:
* <ul>
* <li>{@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。
* 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。</li>
* <li>{@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。
* 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。</li>
* </ul>
*/
public final class LiveProcessConfigSupport {
private LiveProcessConfigSupport() {
}
/**
* 单个项目的生效配置 JSON,没有则返回 null
*
* @param service 过程配置服务
* @param projectId 项目主键
*/
public static String byProject(IProcessConfigService service, Long projectId) {
if (service == null || projectId == null) {
return null;
}
String projectIdStr = String.valueOf(projectId);
return service.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectIdStr)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(config -> containsProjectId(config.getProjectIds(), projectIdStr))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
}
/**
* 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查
*
* @param service 过程配置服务
* @param projectIds 项目主键集合,允许含 null
*/
public static Map<Long, String> byProjects(IProcessConfigService service, Collection<Long> projectIds) {
Map<Long, String> live = new LinkedHashMap<>();
if (service == null || Func.isEmpty(projectIds)) {
return live;
}
Set<Long> wanted = new LinkedHashSet<>();
projectIds.stream().filter(Objects::nonNull).forEach(wanted::add);
if (wanted.isEmpty()) {
return live;
}
List<ProcessConfig> configs = service.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime));
if (Func.isEmpty(configs)) {
return live;
}
for (Long projectId : wanted) {
String projectIdStr = String.valueOf(projectId);
for (ProcessConfig config : configs) {
if (Func.isEmpty(config.getNodeConfigJson())
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
continue;
}
live.put(projectId, config.getNodeConfigJson());
break;
}
}
return live;
}
/**
* 生效配置优先,取不到时回退运单快照
*
* @param liveJson 生效配置 JSON,可为空
* @param snapshot 运单快照 JSON,可为空
*/
public static String resolve(String liveJson, String snapshot) {
return Func.isNotEmpty(liveJson) ? liveJson : snapshot;
}
/**
* 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来
*
* @param liveJson 生效配置 JSON,可为空
* @param snapshot 运单快照 JSON,可为空
*/
public static String resolveForPunch(String liveJson, String snapshot) {
if (Func.isEmpty(liveJson)) {
return snapshot;
}
if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) {
return liveJson;
}
if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return liveJson;
}
/** 项目集合是逗号串,必须按逗号分段精确比对 */
private static boolean containsProjectId(String projectIds, String projectId) {
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
return false;
}
return Arrays.stream(projectIds.split(","))
.map(String::trim)
.anyMatch(projectId::equals);
}
}
@@ -0,0 +1,181 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springblade.transport.pojo.entity.Waybill;
import java.util.List;
import java.util.Locale;
/**
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单。
* <p>
* 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单,
* 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。
* <p>
* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。
*/
public final class LoadableAdmissionSupport {
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
/**
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
* <p>
* 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值,
* 因此词表写在代码里,不从字典取。
*/
public static final List<String> ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway");
/** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */
public static final List<String> ROAD_TRANSPORT_CODES = List.of("gl");
private LoadableAdmissionSupport() {
}
/**
* 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。
* <p>
* 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。
*/
public enum UnloadableReason {
/** 运输方式不是公路 */
NOT_ROAD,
/** 业务状态不是待执行 */
NOT_PENDING,
/** 已关联其它配载单 */
ON_OTHER_MANIFEST
}
/**
* 是否公路运输。未知或为空一律按非公路处理。
*
* @param transportType 运单的运输方式取值
* @return true-公路
*/
public static boolean isRoadTransport(String transportType) {
String value = text(transportType).toLowerCase(Locale.ROOT);
if (value.isEmpty()) {
return false;
}
if (ROAD_TRANSPORT_CODES.contains(value)) {
return true;
}
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
if (value.contains(keyword)) {
return true;
}
}
return false;
}
/**
* 该运单此刻是否可被配载。
* <p>
* ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突,
* 否则编辑保存会把自己的子运单判为不可用。
*
* @param waybill 运单
* @param ownLoadingNo 本配载单号;新建时传 null
* @return true-可配载
*/
public static boolean isLoadable(Waybill waybill, String ownLoadingNo) {
return unloadableReason(waybill, ownLoadingNo) == null;
}
/**
* 该运单此刻不可配载的原因。
*
* @param waybill 运单
* @param ownLoadingNo 本配载单号;新建时传 null
* @return 命中的第一条原因;可配载时返回 null
*/
public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) {
if (waybill == null || !isRoadTransport(waybill.getTransportType())) {
return UnloadableReason.NOT_ROAD;
}
if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) {
return UnloadableReason.NOT_PENDING;
}
String loadingNo = text(waybill.getLoadingNo());
if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) {
return null;
}
return UnloadableReason.ON_OTHER_MANIFEST;
}
/**
* 该运单是否已挂在本配载单上。
* <p>
* 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判:
* 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。
*
* @param waybill 运单
* @param ownLoadingNo 本配载单号;新建时传 null
* @return true-已是本单子运单
*/
public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) {
if (waybill == null) {
return false;
}
String own = text(ownLoadingNo);
return !own.isEmpty() && own.equals(text(waybill.getLoadingNo()));
}
/**
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。
*
* @param wrapper 运单列表查询条件
*/
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
applyRoadTransportCondition(wrapper);
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
private static void applyRoadTransportCondition(LambdaQueryWrapper<Waybill> wrapper) {
wrapper.and(nested -> {
boolean started = false;
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
if (started) {
nested.or();
}
nested.like(Waybill::getTransportType, keyword);
started = true;
}
for (String code : ROAD_TRANSPORT_CODES) {
nested.or().eq(Waybill::getTransportType, code);
}
});
}
private static String text(String value) {
return value == null ? "" : value.trim();
}
}
@@ -279,6 +279,10 @@ public final class OcrFieldSupport {
Map<String, List<String>> aliases = new LinkedHashMap<>();
aliases.put("realName", List.of("姓名"));
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
// 身份证正面的「出生」是一整串日期(1990年1月1日),按 birthday 归一成 yyyy-MM-dd;
// 住址是身份证上的登记住址,写进 driver.address
aliases.put("birthday", List.of("出生", "出生年月", "出生日期"));
aliases.put("address", List.of("住址", "地址"));
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
return aliases;
@@ -319,7 +323,8 @@ public final class OcrFieldSupport {
}
private static boolean isDateField(String key) {
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
// birthday 的语义是日期但拼写不以 Date 结尾,漏了它会把「1990年1月1日」原样下发
return "birthday".equals(key) || key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
}
private static boolean isNumberField(String key) {
@@ -0,0 +1,219 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Waybill;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 运单货物明细的取法,只此一处。
* <p>
* 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} /
* {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。
* 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。
* <p>
* 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。
*/
public final class WaybillCargoSupport {
/** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */
private static final String FALLBACK_NAME = "货物";
/** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */
private static final String NAME_SEPARATOR_REGEX = "[、,,]";
/** 明细里可能承载数量的字段,按优先级取第一个有值的 */
private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"};
/** 明细里可能承载单位的字段,按优先级取第一个有值的 */
private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"};
/** 明细里可能承载名称的字段,按优先级取第一个有值的 */
private static final String[] NAME_KEYS = {"cargoName", "goodsName"};
/** 明细里可能承载类型的字段,按优先级取第一个有值的 */
private static final String[] TYPE_KEYS = {"cargoType", "goodsType"};
private WaybillCargoSupport() {
}
/**
* 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。
* <p>
* 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。
*/
public static List<Cargo> of(Waybill waybill) {
if (waybill == null) {
return List.of();
}
List<Cargo> detail = parseDetail(waybill.getGoodsJson());
if (!detail.isEmpty()) {
return detail;
}
return fallback(waybill);
}
/**
* 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它
*/
public static List<String> cargoNames(List<Cargo> cargos) {
List<String> names = new ArrayList<>();
for (Cargo cargo : cargos) {
String name = Func.toStr(cargo.cargoName(), "").trim();
if (Func.isNotEmpty(name) && !names.contains(name)) {
names.add(name);
}
}
return names;
}
/**
* 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底
*/
private static List<Cargo> parseDetail(String goodsJson) {
if (Func.isEmpty(goodsJson)) {
return List.of();
}
List<Map> rows;
try {
rows = JsonUtil.parseArray(goodsJson, Map.class);
} catch (Exception exception) {
// 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开
return List.of();
}
if (Func.isEmpty(rows)) {
return List.of();
}
List<Cargo> cargos = new ArrayList<>(rows.size());
for (Map row : rows) {
if (row == null) {
continue;
}
cargos.add(new Cargo(
firstText(row, NAME_KEYS),
firstText(row, TYPE_KEYS),
quantityOf(row),
firstText(row, UNIT_KEYS)));
}
return cargos;
}
/**
* 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准,
* 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计)
*/
private static List<Cargo> fallback(Waybill waybill) {
List<String> names = splitCargoNames(waybill.getCargoName());
if (names.isEmpty()) {
return List.of();
}
List<Cargo> cargos = new ArrayList<>(names.size());
boolean first = true;
for (String name : names) {
cargos.add(new Cargo(
name,
Func.toStr(waybill.getCargoType(), ""),
first ? waybill.getQuantity() : null,
first ? Func.toStr(waybill.getQuantityUnit(), "") : ""));
first = false;
}
return cargos;
}
/**
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
*/
public static List<String> splitCargoNames(String cargoName) {
if (Func.isEmpty(cargoName)) {
return List.of();
}
List<String> names = new ArrayList<>();
for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) {
String name = part.trim();
if (Func.isNotEmpty(name)) {
names.add(name);
}
}
return names;
}
private static String firstText(Map row, String... keys) {
for (String key : keys) {
Object value = row.get(key);
if (value == null) {
continue;
}
String text = String.valueOf(value).trim();
if (Func.isNotEmpty(text)) {
return text;
}
}
return "";
}
private static BigDecimal quantityOf(Map row) {
for (String key : QUANTITY_KEYS) {
Object value = row.get(key);
if (value == null) {
continue;
}
String text = String.valueOf(value).trim();
if (Func.isEmpty(text)) {
continue;
}
try {
BigDecimal quantity = new BigDecimal(text);
// 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数
if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) {
continue;
}
return quantity;
} catch (NumberFormatException exception) {
// 这一字段不是数字:继续试下一个候选字段
}
}
return null;
}
/**
* 运单上的一条货物
* <p>
* 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。
*/
public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) {
/**
* 展示用货名:空名称回退成「货物」
*/
public String displayName() {
return Func.toStrWithEmpty(cargoName, FALLBACK_NAME);
}
}
}
@@ -38,6 +38,7 @@ import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -419,6 +420,28 @@ public final class WaybillProcessSupport {
return Collections.emptySet();
}
/**
* 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。
* <p>
* 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了,
* 一起列出来既长,又不是一个可执行的下一步。
*
* @param processJson 过程配置
* @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回
*/
public static String firstUnfinishedPrerequisiteName(String processJson, Collection<String> nodeKeys) {
if (nodeKeys == null || nodeKeys.isEmpty()) {
return "";
}
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
String key = nodeKey(node);
if (nodeKeys.contains(key)) {
return nodeName(node);
}
}
return "";
}
/**
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
* <p>
@@ -637,6 +660,34 @@ public final class WaybillProcessSupport {
return true;
}
/**
* 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则
* 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。
* <p>
* 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。
*
* @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点)
*/
public static List<String> missingPunchNodeNames(String processJson, Collection<String> punchedKeys) {
List<Map<String, Object>> punchConfigs = listDriverPunchNodes(processJson);
if (punchConfigs.isEmpty()) {
return Collections.emptyList();
}
Collection<String> punched = punchedKeys == null ? Collections.emptySet() : punchedKeys;
List<String> missing = new ArrayList<>();
for (Map<String, Object> node : punchConfigs) {
if (isTransitNode(node)) {
continue;
}
String code = nodeKey(node).toLowerCase(Locale.ROOT);
String name = nodeName(node).toLowerCase(Locale.ROOT);
if (!punched.contains(code) && !punched.contains(name)) {
missing.add(nodeName(node));
}
}
return missing;
}
private static boolean isTransitNode(Map<String, Object> node) {
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));
@@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.util.ArrayList;
@@ -100,7 +101,8 @@ class DriverNodePunchPhotoTest {
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
nodePunchMapper,
exceptionDisposalMapper);
exceptionDisposalMapper,
mock(ITripQueryService.class));
when(driverService.getOne(any())).thenReturn(driver());
when(waybillService.getOne(any())).thenReturn(waybill());
@@ -0,0 +1,163 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。
* <p>
* 判据必须与 {@code submitNode} 的自动完成同一份——末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认;
* 两处不同源就会出现「按钮写着完成运单、打下去这张运单没完成」。接单与回单不是司机打卡节点,不参与排位。
*/
class DriverPunchFinishFlagTest {
private static final Long WAYBILL_ID = 5601L;
private static final String NO_RETURN_JSON = """
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
""";
private static final String RETURN_NO_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
""";
private static final String RETURN_NEED_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
/** 末位可打卡节点是「到场」:不在到货/卸货/签收之列,打完不算完成 */
private static final String LAST_NODE_NOT_DELIVERY_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"arrive_scene","name":"到场","type":"location","enabled":true,"punch":true}]
""";
private IWaybillService waybillService;
private WaybillNodePunchMapper nodePunchMapper;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
exceptionDisposalMapper,
mock(ITripQueryService.class));
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
}
@Test
@DisplayName("末位是签收且没有回单节点:只有它带完成标记,装货与接单都不带")
void lastDeliveryNodeCarriesFinishFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(NO_RETURN_JSON);
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), "签收是末位到货类节点,打完即完成");
assertFalse(Boolean.TRUE.equals(nodes.get("load").getFinishOnPunch()), "装货不是末位");
assertFalse(nodes.containsKey("accept"), "接单不是司机打卡节点,不出现在卡片里");
}
@Test
@DisplayName("回单要求业务员确认:末位卡不带完成标记(司机打完不置完成,等业务员确认)")
void returnNeedingConfirmationTakesTheFlagAway() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NEED_CONFIRM_JSON);
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()),
"回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单");
}
@Test
@DisplayName("回单不要求业务员确认:末位卡仍带完成标记")
void returnWithoutConfirmationKeepsTheFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NO_CONFIRM_JSON);
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()));
}
@Test
@DisplayName("末位节点不是到货/卸货/签收:不带完成标记")
void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() {
Map<String, DriverPunchNodeVO> nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON);
assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()),
"到场不在到货/卸货/签收之列,打完不置完成");
}
// ------------------------------------------------------------ 夹具
/** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */
private Map<String, DriverPunchNodeVO> punchNodes(String processJson) {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610180001");
waybill.setProcessJson(processJson);
waybill.setBusinessStatus("running");
waybill.setIsDeleted(0);
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
}
}
@@ -23,6 +23,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.time.LocalDateTime;
@@ -92,7 +93,8 @@ class DriverPunchNodeCardTest {
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
exceptionDisposalMapper);
exceptionDisposalMapper,
mock(ITripQueryService.class));
Waybill waybill = waybill();
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
@@ -0,0 +1,442 @@
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.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
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.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 运次级接单与拒单:一次写完整趟车、写不上已完成/已取消的子单、司机字段一概不动。
* <p>
* 运次的解析与收口在运次读服务里,这里以 mock 给出「该司机可见的运次」,只验运次级校验与写入口径。
*/
class DriverTripAcceptRejectTest {
private static final Long USER_ID = 7401L;
private static final Long DRIVER_ID = 9001L;
private static final String PHONE = "17587421208";
private static final String TRIP_ID = "2101146192739459074";
private static final Long PENDING_ID = 2101144166395699202L;
private static final Long SECOND_ID = 2101145869819994113L;
private IWaybillService waybillService;
private IDriverService driverService;
private CertificationGate certificationGate;
private ITripQueryService tripQueryService;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
driverService = mock(IDriverService.class);
certificationGate = mock(CertificationGate.class);
tripQueryService = mock(ITripQueryService.class);
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
certificationGate,
mock(WaybillEnroutePunchMapper.class),
mock(WaybillNodePunchMapper.class),
mock(ExceptionDisposalMapper.class),
tripQueryService);
lenient().when(driverService.getOne(any())).thenReturn(driver());
lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true);
lenient().when(waybillService.update(any())).thenReturn(true);
}
@Test
@DisplayName("运次接单:一次把两张子运单都写成已接单 + 运输中 + 同一接单时刻,司机字段一个不碰")
void tripAcceptWritesWholeTrip() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
requireAcceptChild(SECOND_ID, "running", null));
boolean result = runAsDriver(() -> service.tripAccept(TRIP_ID));
assertTrue(result);
assertEquals(List.of(
"driver_accept_status",
"driver_accept_time",
"driver_accept_driver_id",
"driver_reject_time",
"driver_reject_reason",
"business_status",
"update_time",
"update_user"), updatedColumns(), "只写接单相关的列与刷新痕迹,司机字段一概不动");
assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID, DRIVER_ID)),
"两张子运单与接单司机一起下发:" + capturedUpdateParams());
assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql());
assertEquals(TRIP_ID, capturedQuery().getTripId());
assertEquals(PHONE, capturedQuery().getDriverPhone(), "归属仍按司机手机号收口");
}
@Test
@DisplayName("运次接单的更新时间与接单时刻是同一个 now、更新人是当前操作人")
void tripAcceptRefreshesUpdateTrace() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
runAsDriver(() -> service.tripAccept(TRIP_ID));
List<Date> dates = capturedUpdateDates();
assertEquals(2, dates.size(), "接单时刻与更新时间各占一列");
assertSame(dates.get(0), dates.get(1), "update_time 与接单时刻必须是同一个 now");
assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams());
}
@Test
@DisplayName("没有登录态时不写 update_user,但 update_time 仍随操作刷新")
void tripAcceptWithoutLoginSkipsUpdateUser() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
runWithoutLoginUser(() -> service.tripAccept(TRIP_ID));
assertEquals(List.of(
"driver_accept_status",
"driver_accept_time",
"driver_accept_driver_id",
"driver_reject_time",
"driver_reject_reason",
"business_status",
"update_time"), updatedColumns(), "无登录态时与框架填充口径一致:只刷 update_time");
}
@Test
@DisplayName("运次拒单的更新时间与拒单时刻是同一个 now、更新人是当前操作人")
void tripRejectRefreshesUpdateTrace() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
runAsDriver(() -> service.tripReject(TRIP_ID, "不要了"));
List<Date> dates = capturedUpdateDates();
assertEquals(2, dates.size(), "拒单时刻与更新时间各占一列");
assertSame(dates.get(0), dates.get(1), "update_time 与拒单时刻必须是同一个 now");
assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams());
}
@Test
@DisplayName("任一子单已接单 → 整单已接单,不再重复写")
void tripAcceptRefusesSecondCall() {
stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"),
requireAcceptChild(SECOND_ID, "running", "accepted"));
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
assertEquals("该运单已接单", exception.getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("无接单节点的运次不进接单流程:给出「该运单无需确认接单」")
void tripAcceptWithoutAcceptNodeIsRefused() {
TripVO.TripWaybillVO first = child(PENDING_ID, "running", null, false);
TripVO.TripWaybillVO second = child(SECOND_ID, "running", null, false);
stubTrip(first, second);
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
assertEquals("该运单无需确认接单", exception.getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("接单只写没走完的子单,已完成的子单不回到运输中")
void tripAcceptSkipsTerminalChildren() {
// 已完成的子单(自动完成,没有接单状态)不进写入集合;另一张还在途,整趟车仍可接单
stubTrip(new TripVO.TripWaybillVO[]{
child(PENDING_ID, "completed", null, true),
requireAcceptChild(SECOND_ID, "running", null)});
runAsDriver(() -> service.tripAccept(TRIP_ID));
assertTrue(capturedUpdateParams().contains(SECOND_ID));
assertFalse(capturedUpdateParams().contains(PENDING_ID), "已完成的子单不进写入集合:" + capturedUpdateParams());
}
@Test
@DisplayName("子单全走完的运次不允许接单")
void tripAcceptAllTerminalIsRefused() {
stubTrip(new TripVO.TripWaybillVO[]{
child(PENDING_ID, "completed", "accepted", true),
child(SECOND_ID, "cancelled", null, true)});
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
assertEquals("当前运单状态不允许接单", exception.getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("司机与车辆的认证门槛按整车只校验一次")
void tripAcceptChecksCertificationOnce() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
requireAcceptChild(SECOND_ID, "pending", "pending"));
when(certificationGate.isDriverAvailable(any())).thenReturn(false);
when(certificationGate.driverUnavailableReason()).thenReturn("司机认证未通过");
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
assertEquals("司机认证未通过", exception.getMessage());
verify(certificationGate, times(1)).isDriverAvailable(any());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("运次拒单:两张子运单写同一个原因、同一拒单时刻,接单记录清空,司机字段不动")
void tripRejectWritesWholeTrip() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
requireAcceptChild(SECOND_ID, "running", null));
boolean result = runAsDriver(() -> service.tripReject(TRIP_ID, " 车坏了 "));
assertTrue(result);
assertEquals(List.of(
"driver_accept_status",
"driver_accept_time",
"driver_accept_driver_id",
"driver_reject_time",
"driver_reject_reason",
"business_status",
"update_time",
"update_user"), updatedColumns(), "只写拒单相关的列与刷新痕迹,司机字段一概不动");
assertTrue(capturedUpdateParams().contains("车坏了"), "拒单原因原样下发(去首尾空格):" + capturedUpdateParams());
assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID)));
}
@Test
@DisplayName("拒单原因可空,超 200 字直接拒绝且不写库")
void tripRejectReasonValidation() {
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
assertTrue(runAsDriver(() -> service.tripReject(TRIP_ID, null)));
assertTrue(capturedUpdateParams().stream().noneMatch(""::equals), "空原因按 null 下发");
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "x".repeat(201))));
assertEquals("拒绝原因不能超过200字", exception.getMessage());
}
@Test
@DisplayName("任一子单已接单 → 整单不能拒单")
void tripRejectRefusesAcceptedTrip() {
stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"),
requireAcceptChild(SECOND_ID, "pending", "pending"));
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了")));
assertEquals("该运单已接单,无法拒绝", exception.getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("子单全走完的运次不允许拒单")
void tripRejectAllTerminalIsRefused() {
stubTrip(new TripVO.TripWaybillVO[]{
child(PENDING_ID, "completed", null, true),
child(SECOND_ID, "completed", null, true)});
ServiceException exception = assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了")));
assertEquals("当前运单状态不允许拒绝接单", exception.getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("未配载运单只写它自己")
void unloadedTripWritesItselfOnly() {
TripVO trip = new TripVO();
trip.setId(String.valueOf(PENDING_ID));
trip.setWaybills(List.of(requireAcceptChild(PENDING_ID, "pending", "pending")));
when(tripQueryService.detail(any())).thenReturn(trip);
assertTrue(runAsDriver(() -> service.tripAccept(String.valueOf(PENDING_ID))));
assertTrue(capturedUpdateParams().contains(PENDING_ID));
assertEquals(1, capturedUpdateParams().stream().filter(PENDING_ID::equals).count());
}
@Test
@DisplayName("越权与不存在的运次一律被拒,且不写库")
void foreignTripIsRefused() {
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage());
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripReject(TRIP_ID, null))).getMessage());
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("运次里一张可见子单都没有时不给出写入入口")
void emptyChildrenAreRefused() {
TripVO trip = new TripVO();
trip.setId(TRIP_ID);
when(tripQueryService.detail(any())).thenReturn(trip);
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage());
verify(waybillService, never()).update(any());
}
// ------------------------------------------------------------ 夹具
private <T> T runAsDriver(Supplier<T> action) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
authUtil.when(AuthUtil::getUser).thenReturn(bladeUser());
return action.get();
}
}
/** 框架填充更新人看的是 getUserId() 背后的登录用户:为空就不填,这里把这一口径钉住 */
private <T> T runWithoutLoginUser(Supplier<T> action) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
authUtil.when(AuthUtil::getUser).thenReturn(null);
return action.get();
}
}
private BladeUser bladeUser() {
BladeUser user = new BladeUser();
user.setUserId(USER_ID);
user.setUserName("黄宇富");
return user;
}
private void stubTrip(TripVO.TripWaybillVO... children) {
stubTrip(new TripVO.TripWaybillVO[0], children);
}
private void stubTrip(TripVO.TripWaybillVO[] leading, TripVO.TripWaybillVO... children) {
TripVO trip = new TripVO();
trip.setId(TRIP_ID);
trip.setDriverPhone(PHONE);
List<TripVO.TripWaybillVO> all = new java.util.ArrayList<>(List.of(leading));
all.addAll(List.of(children));
trip.setWaybills(all);
when(tripQueryService.detail(any())).thenReturn(trip);
}
private TripVO.TripWaybillVO requireAcceptChild(Long id, String businessStatus, String acceptStatus) {
return child(id, businessStatus, acceptStatus, true);
}
private TripVO.TripWaybillVO child(Long id, String businessStatus, String acceptStatus, boolean requireAccept) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(String.valueOf(id));
child.setWaybillNo("YD-" + id);
child.setBusinessStatus(businessStatus);
child.setAcceptStatus(acceptStatus);
child.setRequireAccept(requireAccept);
return child;
}
private Driver driver() {
Driver driver = new Driver();
driver.setId(DRIVER_ID);
driver.setUserId(USER_ID);
driver.setDriverName("黄宇富");
driver.setDrivingVehicle("桂A32523");
driver.setMobile(PHONE);
return driver;
}
private TripQueryVO capturedQuery() {
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).detail(captor.capture());
return captor.getValue();
}
@SuppressWarnings("unchecked")
private LambdaUpdateWrapper<Waybill> capturedUpdate() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService, org.mockito.Mockito.atLeastOnce()).update(captor.capture());
return (LambdaUpdateWrapper<Waybill>) captor.getValue();
}
private String capturedWhereSql() {
return capturedUpdate().getTargetSql();
}
/** SET 子句里真正被写的列名:司机字段是否被碰,看这张清单最直白 */
private List<String> updatedColumns() {
return java.util.Arrays.stream(capturedUpdate().getSqlSet().split(","))
.map(part -> part.substring(0, part.indexOf('=')).trim())
.toList();
}
private List<Date> capturedUpdateDates() {
return capturedUpdateParams().stream().filter(Date.class::isInstance).map(Date.class::cast).toList();
}
/** SET 与 WHERE 的参数都是渲染时才进参数表的,取值前先把两段都渲染一遍 */
private Collection<Object> capturedUpdateParams() {
LambdaUpdateWrapper<Waybill> update = capturedUpdate();
update.getSqlSet();
update.getTargetSql();
return update.getParamNameValuePairs().values();
}
}
@@ -0,0 +1,470 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.CertificationGate;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机端运次接口:五个入口切运次口径后的卡片与分页,以及未配载运次照运单详情的只读下发。
* <p>
* 运次侧的取数与聚合在运次服务里,这里只验司机端的映射与收口;运次服务以 mock 给出「库层已经算好的运次」。
*/
class DriverTripCardTest {
private static final Long USER_ID = 7401L;
private static final Long DRIVER_ID = 9001L;
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 = 2101508430981652482L;
private static final String ROUTE_JSON = "["
+ "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\",\"tags\":[\"装1\"]},"
+ "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
+ "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\",\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"]},"
+ "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
+ "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\",\"tags\":[\"卸2\",\"卸3\",\"卸4\"]}]";
private static final String WAYBILL_IDS_JSON =
"[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]";
private IWaybillService waybillService;
private IDriverService driverService;
private IMpUserClient mpUserClient;
private ITripQueryService tripQueryService;
private WaybillEnroutePunchMapper enroutePunchMapper;
private WaybillNodePunchMapper nodePunchMapper;
private DriverWaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
com.baomidou.mybatisplus.core.MybatisConfiguration configuration =
new com.baomidou.mybatisplus.core.MybatisConfiguration();
org.apache.ibatis.builder.MapperBuilderAssistant assistant =
new org.apache.ibatis.builder.MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
driverService = mock(IDriverService.class);
mpUserClient = mock(IMpUserClient.class);
tripQueryService = mock(ITripQueryService.class);
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mpUserClient,
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
mock(ExceptionDisposalMapper.class),
tripQueryService);
lenient().when(driverService.getOne(any())).thenReturn(driver());
lenient().when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
lenient().when(enroutePunchMapper.selectOne(any())).thenReturn(null);
lenient().when(nodePunchMapper.selectList(any())).thenReturn(List.of());
}
@Test
@DisplayName("运次列表按运次分页:一页 10 个运次,卡片带运次号、整趟车起/经/终与站点数")
void tripPageMapsTripCard() {
stubTripPage(1, loadingTrip());
TripQueryVO given = null;
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
given = capturedQuery();
assertEquals(PHONE, given.getDriverPhone());
assertEquals(1, given.getCurrent());
assertEquals(10, given.getSize(), "一页 10 条即 10 个运次");
assertNull(given.getStatus());
assertNull(given.getTripId());
DriverWaybillCardVO card = page.getRecords().get(0);
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
assertEquals(LOADING_NO, card.getLoadingNo());
assertEquals(LOADING_NO, card.getTripNo(), "有配载取配载单号");
assertEquals("load", card.getTransportOrgType());
assertEquals(1, card.getStatus(), "四张子单都在运输中");
assertEquals(Boolean.FALSE, card.getRequireAccept());
assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getFromName());
assertEquals("广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司", card.getViaName());
assertEquals("广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府", card.getToName());
assertEquals(3, card.getStationCount());
assertEquals(List.of("石英砂A", "技嘉4070显卡"), card.getCargoNames());
assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary());
assertEquals("161.84吨", card.getWeight(), "合计重量按同单位相加");
assertEquals("161.84吨", card.getTotalWeight());
assertEquals("桂A32523", card.getVehicleNo());
}
@Test
@DisplayName("运次列表不因配载卡去回查运单,也不写库")
void tripPageDoesNotLookUpWaybillsOrWrite() {
stubTripPage(1, loadingTrip());
runAsDriver(() -> service.tripPage(1, 10, 1, "PZ-20261017"));
verify(waybillService, never()).getById(any());
verify(waybillService, never()).list(anyWrapper());
verify(waybillService, never()).updateById(any());
verify(waybillService, never()).update(any(), any());
verify(waybillService, never()).syncDriverAcceptState(any());
assertEquals(1, capturedQuery().getStatus());
assertEquals("PZ-20261017", capturedQuery().getKeyword());
}
@Test
@DisplayName("运次卡片总数就是运次数:总数与每页条数按运次传下去")
void tripPageTotalCountsTrips() {
Page<TripVO> trips = new Page<>(1, 10, 71);
trips.setRecords(List.of(loadingTrip()));
when(tripQueryService.page(any())).thenReturn(trips);
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
assertEquals(71, page.getTotal(), "总数按运次算");
assertEquals(10, page.getSize());
assertEquals(1, page.getRecords().size());
}
@Test
@DisplayName("未配载运单仍是独立运次:主键是运单主键的字符串形式,未被取整")
void unloadedTripKeepsStringPrimaryKey() {
stubTripPage(1, unloadedTrip());
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
DriverWaybillCardVO card = page.getRecords().get(0);
assertEquals("2101508430981652482", card.getTripId());
assertEquals(19, card.getTripId().length());
assertEquals(Long.valueOf(WAYBILL_ID), card.getId(), "旧字段仍是运单主键");
assertEquals("", card.getLoadingNo());
assertEquals("YD-20260920-0003", card.getTripNo());
assertEquals("common", card.getTransportOrgType());
assertEquals("2026-09-20", card.getPlanShipTime(), "未配载运次的卡片也要带计划时间");
assertEquals("2026-09-22", card.getPlanFinishTime());
}
@Test
@DisplayName("Tab 统计按运次计,全部=待接单+进行中+已完成")
void tripTabCountsArePerTrip() {
TripTabCountsVO counts = new TripTabCountsVO();
counts.setPending(3);
counts.setDoing(24);
counts.setDone(44);
counts.setAll(71);
when(tripQueryService.tabCounts(any())).thenReturn(counts);
DriverWaybillTabCountsVO result = runAsDriver(() -> service.tripTabCounts());
assertEquals(3, result.getPending());
assertEquals(24, result.getDoing());
assertEquals(44, result.getDone());
assertEquals(71, result.getAll());
}
@Test
@DisplayName("首页当前任务取运输中运次,最多一条")
void tripCurrentTaskTakesRunningTrip() {
stubTripPage(1, loadingTrip());
DriverWaybillCardVO card = runAsDriver(() -> service.tripCurrentTask());
assertNotNull(card);
assertEquals(1, capturedQuery().getStatus());
assertEquals(1, capturedQuery().getSize());
Page<TripVO> empty = new Page<>(1, 1, 0);
when(tripQueryService.page(any())).thenReturn(empty);
assertNull(runAsDriver(() -> service.tripCurrentTask()));
}
@Test
@DisplayName("首页待接预览按运次计,总数是待接运次数")
void tripPendingPreviewCountsTrips() {
Page<TripVO> trips = new Page<>(1, 2, 3);
trips.setRecords(List.of(loadingTrip(), unloadedTrip()));
when(tripQueryService.page(any())).thenReturn(trips);
DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null));
assertEquals(3, preview.getTotal());
assertEquals(2, preview.getRecords().size());
assertEquals(0, capturedQuery().getStatus());
assertEquals(2, capturedQuery().getSize(), "不传时预览两条");
}
@Test
@DisplayName("运次详情的站点是整趟车:三站按途经顺序、第二站装卸、货物明细带运单号")
void tripDetailCarriesWholeCarStations() {
stubTripDetail(loadingTrip());
DriverWaybillCardVO card = runAsDriver(() -> service.tripDetail(String.valueOf(LOADING_ID)));
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
assertEquals(3, card.getRoutePoints().size(), "装卸点是整趟车的三站,不再只有本票两点");
assertEquals(3, card.getStations().size());
assertEquals(1, card.getStations().get(0).getIndex());
assertEquals("装货", card.getStations().get(0).getOperationTypeLabel());
assertEquals("装卸", card.getStations().get(1).getOperationTypeLabel());
assertEquals("卸货", card.getStations().get(2).getOperationTypeLabel());
assertEquals("YD-20260923-0008", card.getStations().get(1).getCargos().get(0).getWaybillNo(),
"货物明细里能看到运单号");
assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getRoutePoints().get(0).getName());
verify(waybillService, never()).getById(any());
}
@Test
@DisplayName("未配载运次详情照运单详情下发,且只读:不调用会落库的接单态归一化")
void unloadedTripDetailStaysReadOnly() {
TripVO trip = unloadedTrip();
stubTripDetail(trip);
Waybill waybill = unloadedWaybill();
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
DriverWaybillCardVO first = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID)));
DriverWaybillCardVO second = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID)));
assertEquals("2101508430981652482", first.getTripId());
assertEquals("", first.getLoadingNo());
assertNotNull(first.getProcessJson(), "运单详情的过程配置照旧下发");
assertNotNull(first.getPunchNodes());
assertEquals(2, first.getStations().size());
assertEquals(2, first.getRoutePoints().size());
assertEquals("运单备注", first.getRemark(), "未配载运次的备注仍是运单备注");
assertEquals("2026-09-20", first.getPlanShipTime(), "未配载运次的计划时间仍是运单的计划时间");
assertEquals("在途", first.getCurrentNode(), "未配载运次的当前节点照旧");
assertEquals(first.getStatus(), second.getStatus(), "连读两次状态不漂移");
verify(waybillService, never()).syncDriverAcceptState(any());
verify(waybillService, never()).updateById(any());
verify(waybillService, never()).update(any(), any());
}
@Test
@DisplayName("收口缺失时不放行:空页、零统计、当前任务为空,且不查运次")
void missingPhoneIsRefused() {
when(driverService.getOne(any())).thenReturn(null);
when(mpUserClient.userById(any())).thenReturn(null);
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(null, null, null, null));
DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null));
DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tripTabCounts());
assertEquals(0, page.getTotal());
assertTrue(page.getRecords().isEmpty());
assertEquals(10, page.getSize());
assertEquals(0, preview.getTotal());
assertTrue(preview.getRecords().isEmpty());
assertEquals(0, counts.getAll());
assertNull(runAsDriver(() -> service.tripCurrentTask()));
assertThrows(ServiceException.class, () -> runAsDriver(() -> service.tripDetail("1")));
verify(tripQueryService, never()).page(any());
verify(tripQueryService, never()).tabCounts(any());
verify(tripQueryService, never()).detail(any());
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")
private static com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill> anyWrapper() {
return org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any();
}
private <T> T runAsDriver(Supplier<T> action) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
return action.get();
}
}
private void stubTripDetail(TripVO trip) {
when(tripQueryService.detail(any())).thenReturn(trip);
}
private void stubTripPage(long total, TripVO... trips) { Page<TripVO> page = new Page<>(1, 10, total);
page.setRecords(List.of(trips));
when(tripQueryService.page(any())).thenReturn(page);
}
private TripQueryVO capturedQuery() {
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).page(captor.capture());
return captor.getValue();
}
private Driver driver() {
Driver driver = new Driver();
driver.setId(DRIVER_ID);
driver.setUserId(USER_ID);
driver.setDriverName("黄宇富");
driver.setDrivingVehicle("桂A32523");
driver.setMobile(PHONE);
return driver;
}
/** 本地库实测的 PZ-20261017-001:4 张子单、3 站、状态运输中 */
private TripVO loadingTrip() {
// 回查到的子单按创建时间升序(与服务取子单的顺序一致)
List<Waybill> children = List.of(
tripChild("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"),
tripChild("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"),
tripChild("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17"),
tripChild("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00"));
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setRouteJson(ROUTE_JSON);
loading.setWaybillIdsJson(WAYBILL_IDS_JSON);
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
TripVO trip = new TripVO();
trip.setId(String.valueOf(LOADING_ID));
trip.setLoadingNo(LOADING_NO);
trip.setTripNo(LOADING_NO);
trip.setTransportOrgType("load");
trip.setStatus(1);
trip.setRequireAccept(false);
trip.setSortTime(new Date(1787671422000L));
trip.setDepartureName(TripStationAssembler.departureName(stations));
trip.setTransitName(TripStationAssembler.transitName(stations));
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
trip.setStationCount(stations.size());
trip.setStations(stations);
trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨");
trip.setTotalQuantity(new BigDecimal("161.84"));
trip.setQuantityUnit("吨");
trip.setDriverName("黄宇富");
trip.setDriverPhone(PHONE);
trip.setVehicleNo("桂A32523");
trip.setProjectName("武宣建筑原料运输项目");
trip.setTaskRemark("多点装卸测试");
trip.setWaybills(children.stream().map(this::tripChildVO).toList());
return trip;
}
private TripVO unloadedTrip() {
Waybill waybill = unloadedWaybill();
TripVO trip = new TripVO();
trip.setId(String.valueOf(WAYBILL_ID));
trip.setTripNo("YD-20260920-0003");
trip.setTransportOrgType("common");
trip.setStatus(0);
trip.setRequireAccept(true);
trip.setAcceptStatus("pending");
trip.setSortTime(new Date(1787629629000L));
trip.setEstimatedStartDate(java.time.LocalDate.of(2026, 9, 20));
trip.setEstimatedEndDate(java.time.LocalDate.of(2026, 9, 22));
trip.setDepartureName("南宁武鸣");
trip.setArrivalName("钦州港");
trip.setStationCount(2);
trip.setCargoSummary("石灰石 30吨");
trip.setTotalQuantity(new BigDecimal("30"));
trip.setQuantityUnit("吨");
trip.setDriverName("黄宇富");
trip.setDriverPhone(PHONE);
trip.setVehicleNo("桂A32523");
trip.setWaybills(List.of(tripChildVO(waybill)));
trip.setStations(TripStationAssembler.assemble(null, List.of(waybill)));
return trip;
}
private Waybill unloadedWaybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD-20260920-0003");
waybill.setBusinessStatus("pending");
waybill.setDriverAcceptStatus("pending");
waybill.setDriverPhone(PHONE);
waybill.setVehicleNo("桂A32523");
waybill.setDepartureName("南宁武鸣");
waybill.setDepartureAddress("南宁武鸣");
waybill.setArrivalName("钦州港");
waybill.setArrivalAddress("钦州港");
waybill.setCargoName("石灰石");
waybill.setQuantity(new BigDecimal("30"));
waybill.setQuantityUnit("吨");
waybill.setProcessJson("[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]");
waybill.setCreateTime(new Date(1787629629000L));
waybill.setRemark("运单备注");
waybill.setTaskRemark("任务备注");
waybill.setCurrentProcessNode("在途");
waybill.setEstimatedStartTime(java.time.LocalDate.of(2026, 9, 20));
waybill.setEstimatedEndTime(java.time.LocalDate.of(2026, 9, 22));
return waybill;
}
private Waybill tripChild(String id, String waybillNo, String cargoName, String customerName, String quantity) {
Waybill waybill = new Waybill();
waybill.setId(Long.valueOf(id));
waybill.setWaybillNo(waybillNo);
waybill.setCargoName(cargoName);
waybill.setCustomerName(customerName);
waybill.setQuantity(new BigDecimal(quantity));
waybill.setQuantityUnit("吨");
waybill.setBusinessStatus("running");
return waybill;
}
private TripVO.TripWaybillVO tripChildVO(Waybill waybill) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(String.valueOf(waybill.getId()));
child.setWaybillNo(waybill.getWaybillNo());
child.setCargoName(waybill.getCargoName());
child.setCargoType("矿石");
child.setQuantity(waybill.getQuantity());
child.setQuantityUnit(waybill.getQuantityUnit());
child.setBusinessStatus(waybill.getBusinessStatus());
return child;
}
}
@@ -24,6 +24,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
@@ -100,7 +101,8 @@ class DriverWaybillAutoCompleteTest {
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
nodePunchMapper,
exceptionDisposalMapper);
exceptionDisposalMapper,
mock(ITripQueryService.class));
when(driverService.getOne(any())).thenReturn(driver());
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -20,6 +20,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
@@ -78,7 +79,8 @@ class DriverWaybillCardLocationTest {
mock(CertificationGate.class),
enroutePunchMapper,
nodePunchMapper,
exceptionDisposalMapper);
exceptionDisposalMapper,
mock(ITripQueryService.class));
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
@@ -16,6 +16,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
@@ -60,7 +61,8 @@ class DriverWaybillNodePunchTest {
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
nodePunchMapper,
mock(ExceptionDisposalMapper.class));
mock(ExceptionDisposalMapper.class),
mock(ITripQueryService.class));
}
@Test
@@ -22,6 +22,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -72,7 +73,8 @@ class DriverWaybillOwnershipTest {
mock(CertificationGate.class),
mock(WaybillEnroutePunchMapper.class),
mock(WaybillNodePunchMapper.class),
mock(ExceptionDisposalMapper.class));
mock(ExceptionDisposalMapper.class),
mock(ITripQueryService.class));
}
@Test
@@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.support.CertificationGate;
import java.util.List;
@@ -88,7 +89,8 @@ class DriverWaybillRejectDetachTest {
certificationGate,
mock(WaybillEnroutePunchMapper.class),
mock(WaybillNodePunchMapper.class),
mock(ExceptionDisposalMapper.class));
mock(ExceptionDisposalMapper.class),
mock(ITripQueryService.class));
lenient().when(driverService.getOne(any())).thenReturn(driver());
lenient().when(waybillService.updateById(any())).thenReturn(true);
// syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE;
@@ -0,0 +1,412 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.LoadingManageMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import java.lang.reflect.Field;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 配载提交/编辑/重新派单的准入校验:落库前的一处判据决定这批运单能不能被配载。
* <p>
* 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据,
* 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。
* 暂存只预占运单号、未过判据,故草稿转正式时本单号不构成豁免。
* <p>
* 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。
*/
class LoadingManageAdmissionTest {
private static final Long DEPT_ID = 1001L;
private static final Long PROJECT_A = 2001L;
private static final Long PROJECT_B = 2002L;
private static final Long CONTRACT_ID = 3001L;
private static final Long OTHER_CONTRACT_ID = 3002L;
private static final Long OWN_LOADING_ID = 9001L;
private static final String OWN_LOADING_NO = "PZ20261018-001";
private static final String WAYBILL_NO_A = "YD202610180001";
private static final String WAYBILL_NO_B = "YD202610180002";
private LoadingManageServiceImpl service;
private WaybillMapper waybillMapper;
private LoadingManageMapper loadingManageMapper;
private IContractManageService contractManageService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
TableInfoHelper.initTableInfo(assistant, ContractManage.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
loadingManageMapper = mock(LoadingManageMapper.class);
contractManageService = mock(IContractManageService.class);
service = new LoadingManageServiceImpl();
inject("baseMapper", loadingManageMapper);
inject("waybillMapper", waybillMapper);
inject("contractManageService", contractManageService);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
}
@AfterEach
void tearDown() {
authUtil.close();
}
// ------------------------------------------------------------ 逐张准入
@Test
@DisplayName("非公路运单被拒,错误含运单号与原因")
void nonRoadWaybillRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", null, PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
}
@Test
@DisplayName("状态不是待执行一律被拒:进行中、已完成、已取消、草稿")
void nonPendingStatusRejected() {
for (String status : List.of("running", "completed", "cancelled", "draft")) {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", status, null, PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))), status);
assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage(), status);
}
}
@Test
@DisplayName("已挂在其它的配载单上的运单被拒")
void waybillOnAnotherManifestRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", "PZ20261018-999", PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "已关联其他配载单", exception.getMessage());
}
@Test
@DisplayName("已拒单但未配载的公路待执行运单可通过提交:接单事实由配载写入路径清除,新司机由此看到它")
void rejectedWaybillIsAdmitted() {
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A);
rejected.setDriverAcceptStatus("rejected");
stubWaybills(rejected);
stubContract(CONTRACT_ID, PROJECT_A);
assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L))));
}
// ------------------------------------------------------------ 本单既有子运单
@Test
@DisplayName("编辑本单:子运单挂的就是本单号,不算冲突")
void editingOwnChildrenPasses() throws Exception {
stubExistingManifest();
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.submit(payload));
}
@Test
@DisplayName("不需接单项目的子运单提交后即为进行中;重新提交本单不得因此被挡")
void editingOwnRunningChildrenPasses() throws Exception {
stubExistingManifest();
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.submit(payload));
}
@Test
@DisplayName("本单既有子运单放行,但同一次请求新塞进来的进行中运单仍被拒")
void newlyAddedRunningWaybillIsStillRejected() throws Exception {
stubExistingManifest();
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "road", "running", null, PROJECT_A));
LoadingManage payload = submitPayload(List.of(1L, 2L));
payload.setId(OWN_LOADING_ID);
ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(payload));
assertEquals("运单" + WAYBILL_NO_B + "当前状态不是待执行,不可配载", exception.getMessage());
}
@Test
@DisplayName("重新派单:本单被拒的子运单仍挂在本单号上,不因拒单被挡——重新派单本就是为它而设")
void reassignKeepsRejectedOwnChild() throws Exception {
stubExistingManifest();
when(waybillMapper.selectCount(anyWaybillWrapper())).thenReturn(1L);
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A);
rejected.setDriverAcceptStatus("rejected");
stubWaybills(rejected);
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L));
payload.setId(OWN_LOADING_ID);
assertDoesNotThrow(() -> service.reassign(payload));
}
@Test
@DisplayName("交叉判据同时命中时按 公路 → 待执行 → 已关联其它配载单 取第一条")
void firstFailingConditionWins() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", "PZ20261018-999", PROJECT_A));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
}
// ------------------------------------------------------------ 草稿预占
@Test
@DisplayName("暂存只预占运单号:草稿里那张进行中的票在草稿转正式时被拒")
void draftReservedRunningWaybillRejected() {
stubManifest("draft");
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(existingPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage());
}
@Test
@DisplayName("暂存只预占运单号:草稿里那张非公路的票在草稿转正式时被拒")
void draftReservedNonRoadWaybillRejected() {
stubManifest("draft");
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(existingPayload(List.of(1L))));
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
}
@Test
@DisplayName("暂存预占的票本身合格时,草稿转正式照常通过")
void draftReservationStaysAdmissibleOnSubmit() {
stubManifest("draft");
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
assertDoesNotThrow(() -> service.submit(existingPayload(List.of(1L))));
}
// ------------------------------------------------------------ 跨行判据
@Test
@DisplayName("跨项目的子运单集合被拒,错误点名与其它子运单不同项目的运单号")
void crossProjectChildrenRejected() {
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_B));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L, 2L))));
assertEquals("配载单的子运单必须属于同一项目,运单" + WAYBILL_NO_B + "与其它子运单不属于同一项目",
exception.getMessage());
}
@Test
@DisplayName("合同不属于本项目:与本项目无可选合同区分开")
void contractOfAnotherProjectRejected() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_B);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("所选承运商合同不属于运单" + WAYBILL_NO_A + "的项目", exception.getMessage());
}
@Test
@DisplayName("合同本身不可用(未审核通过或已失效)仍报原有文案,不与「不属于本项目」混同")
void unavailableContractKeepsExistingMessage() {
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of());
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submit(submitPayload(List.of(1L))));
assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage());
}
// ------------------------------------------------------------ 放行
@Test
@DisplayName("公路 + 待执行 + 未配载 + 同项目 + 合同属于本项目:通过并带出合同乙方")
void legalRequestPasses() {
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "公路运输", "pending", "", PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
LoadingManage payload = submitPayload(List.of(1L, 2L));
assertDoesNotThrow(() -> service.submit(payload));
assertEquals("甲承运商", payload.getCarrierName());
}
@Test
@DisplayName("暂存不受准入校验影响:跨项目、非公路、进行中的票照旧落草稿")
void draftIsNotGated() {
LoadingManage draft = new LoadingManage();
draft.setDeptId(DEPT_ID);
draft.setLoadingSubNos(WAYBILL_NO_A);
draft.setCarrierType("自运");
draft.setWaybillIdsJson(waybillIdsJson(List.of(1L, 2L)));
assertDoesNotThrow(() -> service.saveDraft(draft));
verify(waybillMapper, never()).selectList(anyWaybillWrapper());
}
// ------------------------------------------------------------ 夹具
private Waybill waybill(Long id, String waybillNo, String transportType, String businessStatus,
String loadingNo, Long projectId) {
Waybill waybill = new Waybill();
waybill.setId(id);
waybill.setWaybillNo(waybillNo);
waybill.setTransportType(transportType);
waybill.setBusinessStatus(businessStatus);
waybill.setLoadingNo(loadingNo);
waybill.setProjectId(projectId);
waybill.setDeptId(DEPT_ID);
waybill.setIsDeleted(0);
return waybill;
}
private LoadingManage submitPayload(List<Long> waybillIdList) {
LoadingManage loadingManage = new LoadingManage();
loadingManage.setDeptId(DEPT_ID);
loadingManage.setLoadingSubNos(WAYBILL_NO_A);
loadingManage.setTransportType("road");
loadingManage.setCarrierType("承运商");
loadingManage.setCarrierContractId(CONTRACT_ID);
loadingManage.setVehicleNo("苏A12345");
loadingManage.setDepartureAddress("南京");
loadingManage.setArrivalAddress("上海");
loadingManage.setWaybillIdsJson(waybillIdsJson(waybillIdList));
return loadingManage;
}
private LoadingManage existingPayload(List<Long> waybillIdList) {
LoadingManage loadingManage = submitPayload(waybillIdList);
loadingManage.setId(OWN_LOADING_ID);
return loadingManage;
}
private static String waybillIdsJson(List<Long> waybillIdList) {
return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]"));
}
private void stubExistingManifest() {
stubManifest("pending");
}
private void stubManifest(String businessStatus) {
LoadingManage existing = new LoadingManage();
existing.setId(OWN_LOADING_ID);
existing.setLoadingNo(OWN_LOADING_NO);
existing.setDeptId(DEPT_ID);
existing.setBusinessStatus(businessStatus);
when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing);
}
private void stubWaybills(Waybill... waybills) {
when(waybillMapper.selectList(anyWaybillWrapper())).thenReturn(List.of(waybills));
}
private void stubContract(Long contractId, Long projectId) {
ContractManage contract = new ContractManage();
contract.setId(contractId);
contract.setProjectId(projectId);
contract.setPartyB(contractId.equals(OTHER_CONTRACT_ID) ? "乙承运商" : "甲承运商");
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of(contract));
}
private static Wrapper<Waybill> anyWaybillWrapper() {
return ArgumentMatchers.<Wrapper<Waybill>>any();
}
private static Wrapper<ContractManage> anyContractWrapper() {
return ArgumentMatchers.<Wrapper<ContractManage>>any();
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("找不到字段:" + field);
}
}
@@ -0,0 +1,477 @@
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.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 配载提交 / 重新派单写入子运单状态:状态逐张按各票自己项目的实时过程配置推导,
* 并把这批票的接单事实清成「待接单」,使新司机能看到它们。
* <p>
* 推导必须与运单读路径的状态归一化同口径(同一份实时配置、同一个归一化函数),
* 否则写完再读会漂移。
*/
class LoadingManageChildStatusWriteTest {
/** 当前登录组织,与夹具运单的 deptId 一致 */
private static final String DEPT_ID = "1123598813738675201";
private static final Long DEPT_ID_LONG = 1123598813738675201L;
private static final String LOADING_NO = "PZ-20261018-001";
private static final Long LOADING_ID = 1800000000000000001L;
private static final String DRIVER_PHONE = "13800138000";
private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L;
private static final Long NO_ACCEPT_WAYBILL_ID = 3102L;
private static final Long NEED_ACCEPT_PROJECT_ID = 2101L;
private static final Long NO_ACCEPT_PROJECT_ID = 2102L;
/** 接单节点「是否确认=是」→ 需要司机接单确认 */
private static final String NEED_ACCEPT_JSON =
"[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
/** 接单节点「无需确认接单」→ 不需要接单 */
private static final String NO_ACCEPT_JSON =
"[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
/** 配载写入给子运单落的全部配载字段,逐条都不能因为状态要分批写而漏掉 */
private static final List<String> SYNC_COLUMNS = List.of(
"loading_no",
"business_status",
"carrier_type",
"carrier_contract_id",
"carrier_name",
"driver_name",
"driver_phone",
"vehicle_no",
"trailer_vehicle_no",
"escort_name",
"escort_phone",
"mileage",
"estimated_start_time",
"estimated_end_time",
"task_remark",
"task_info_json",
"current_process_node");
private static final List<String> ACCEPT_CLEAR_COLUMNS = List.of(
"driver_accept_status",
"driver_accept_time",
"driver_accept_driver_id",
"driver_reject_time",
"driver_reject_reason");
private WaybillMapper waybillMapper;
private IProcessConfigService processConfigService;
private IContractManageService contractManageService;
private IReceivablePayableDetailService receivablePayableDetailService;
private LoadingManageServiceImpl service;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper / LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
}
@BeforeEach
void setUp() {
waybillMapper = mock(WaybillMapper.class);
processConfigService = mock(IProcessConfigService.class);
contractManageService = mock(IContractManageService.class);
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
service = spy(new LoadingManageServiceImpl());
inject(service, "waybillMapper", waybillMapper);
inject(service, "processConfigService", processConfigService);
inject(service, "contractManageService", contractManageService);
inject(service, "receivablePayableDetailService", receivablePayableDetailService);
doReturnTrue(service);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("不需接单的项目:提交时子单直接落进行中,不留待执行等读路径去提升")
void submitWritesRunningForProjectWithoutAcceptNode() {
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
assertEquals("running", statusUpdate.setValue("business_status"));
assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), statusUpdate.whereIds());
assertEquals(SYNC_COLUMNS, statusUpdate.columns(), "配载字段一张不少地随状态一起写下去");
}
@Test
@DisplayName("需接单的项目:提交时子单落待执行,等新司机接单")
void submitWritesPendingForProjectWithAcceptNode() {
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status"));
}
@Test
@DisplayName("跨项目的子单逐张按各自项目推导,互不牵连,配置只查一次")
void deriveEachWaybillStatusFromItsOwnProject() throws Exception {
stubConfigs(
config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON),
config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
// 提交与重新派单在准入阶段就拦下跨项目的子单,故直接测逐张推导本身
assertEquals(Map.of(
NEED_ACCEPT_WAYBILL_ID, "pending",
NO_ACCEPT_WAYBILL_ID, "running"),
deriveBusinessStatuses(
waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID),
waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)));
verify(processConfigService, times(1)).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
}
@Test
@DisplayName("项目生效配置优先:项目不需要接单时,运单自己的历史快照说需要接单也不采信")
void liveConfigWinsOverWaybillSnapshot() {
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
assertEquals("running", statusSyncUpdates().get(0).setValue("business_status"));
}
@Test
@DisplayName("没有项目也没有快照:视为不需要接单,写进行中")
void waybillWithoutProjectAndSnapshotWritesRunning() {
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, null));
stubConfigs();
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
assertEquals("running", statusSyncUpdates().get(0).setValue("business_status"));
}
@Test
@DisplayName("项目为空的运单:配置取不到时按运单快照兜底,与读路径的归一化保持一致")
void waybillWithoutProjectFallsBackToSnapshot() {
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, null, NEED_ACCEPT_JSON));
stubConfigs();
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status"));
}
@Test
@DisplayName("提交清除本次合进来子单的接单事实:置为待接单,接单/拒单痕迹一并清空")
void submitClearsAcceptRecordOfWaybillsTakenIn() {
// 同一配载单的子单必须同项目,故两张票同项目,接单事实按整批清
stubWaybills(
waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID),
waybill(NO_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)));
CapturedUpdate clearUpdate = acceptClearUpdates().get(0);
assertEquals(ACCEPT_CLEAR_COLUMNS, clearUpdate.columns());
assertEquals("pending", clearUpdate.setValue("driver_accept_status"));
assertNull(clearUpdate.setValue("driver_accept_time"));
assertNull(clearUpdate.setValue("driver_accept_driver_id"));
assertNull(clearUpdate.setValue("driver_reject_time"));
assertNull(clearUpdate.setValue("driver_reject_reason"));
assertEquals(
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
Set.copyOf(clearUpdate.whereIds()));
}
@Test
@DisplayName("重新派单:与提交同一套逐张推导,并同样清除接单事实;先写状态后清事实")
void reassignDerivesStatusAndClearsAcceptRecord() {
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID));
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
stubRejectedWaybillExists();
LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID);
assertTrue(service.reassign(form));
List<CapturedUpdate> updates = waybillUpdates();
assertEquals("pending", updates.get(0).setValue("business_status"));
assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), updates.get(1).whereIds());
assertEquals(ACCEPT_CLEAR_COLUMNS, updates.get(1).columns(), "清接单事实紧跟状态写入之后");
}
@Test
@DisplayName("编辑配载单移除运单:被移除的票照旧退回待执行,不参与本次的状态推导与清接单事实")
void removedWaybillIsReleasedWithoutDerivation() {
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
assertTrue(service.submit(editForm(LOADING_ID, NO_ACCEPT_WAYBILL_ID)));
assertEquals(
Map.of(NO_ACCEPT_WAYBILL_ID, "running"),
statusByWaybillId(),
"留下的票按自己项目推导,被移除的票不出现在状态写入里");
CapturedUpdate releaseUpdate = waybillUpdates().stream()
.filter(update -> !update.columns().contains("driver_name"))
.findFirst()
.orElseThrow();
assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), releaseUpdate.whereIds(), "被移除的票退回未配载");
assertEquals("pending", releaseUpdate.setValue("business_status"));
assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), acceptClearUpdates().get(0).whereIds());
}
@Test
@DisplayName("回归:开始配载仍整批置进行中,不按项目逐张推导")
void startStillWritesRunningForWholeBatch() {
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
assertTrue(service.start(LOADING_ID));
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
assertEquals("running", statusUpdate.setValue("business_status"));
assertEquals(
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
Set.copyOf(statusUpdate.whereIds()));
verify(processConfigService, never()).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
}
@Test
@DisplayName("回归:完成配载仍整批置已完成,结算触发照旧")
void completeStillWritesCompletedForWholeBatch() {
stubLoadingRecord(loadingRecord("running", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
assertTrue(service.complete(LOADING_ID));
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
assertEquals("completed", statusUpdate.setValue("business_status"));
assertEquals(
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
Set.copyOf(statusUpdate.whereIds()));
verify(receivablePayableDetailService).generateForCompletedLoading(
List.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), null, LOADING_NO);
verify(processConfigService, never()).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
}
// ------------------------------------------------------------ 夹具
private LoadingManage loadingForm(Long... waybillIds) {
return fillForm(new LoadingManage(), List.of(waybillIds));
}
private LoadingManage editForm(Long loadingId, Long... waybillIds) {
LoadingManage form = new LoadingManage();
form.setId(loadingId);
return fillForm(form, List.of(waybillIds));
}
private LoadingManage fillForm(LoadingManage form, List<Long> ids) {
form.setLoadingNo(LOADING_NO);
form.setDeptId(DEPT_ID_LONG);
form.setDeptName("测试组织");
form.setWaybillIdsJson(JsonUtil.toJson(ids));
form.setLoadingSubNos(ids.stream().map(id -> "YD-" + id).reduce((left, right) -> left + "," + right).orElse(""));
form.setTransportType("公路运输");
form.setCarrierType("自运");
form.setVehicleNo("桂A12345");
form.setDriverName("张三");
form.setDriverPhone(DRIVER_PHONE);
form.setDepartureAddress("南宁");
form.setArrivalAddress("广州");
return form;
}
private static LoadingManage loadingRecord(String businessStatus, Long... waybillIds) {
LoadingManage record = new LoadingManage();
record.setId(LOADING_ID);
record.setLoadingNo(LOADING_NO);
record.setDeptId(DEPT_ID_LONG);
record.setDeptName("测试组织");
record.setCarrierType("自运");
record.setBusinessStatus(businessStatus);
record.setWaybillIdsJson(JsonUtil.toJson(List.of(waybillIds)));
return record;
}
private void stubLoadingRecord(LoadingManage record) {
doReturn(record).when(service).getById(LOADING_ID);
}
/** 重新派单只对「有被拒子单」的配载单开放 */
private void stubRejectedWaybillExists() {
when(waybillMapper.selectCount(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(1L);
}
private static Waybill waybill(Long id, Long projectId) {
return waybill(id, projectId, null);
}
private static Waybill waybill(Long id, Long projectId, String processJson) {
Waybill waybill = new Waybill();
waybill.setId(id);
waybill.setWaybillNo("YD-" + id);
waybill.setProjectId(projectId);
waybill.setProcessJson(processJson);
waybill.setDeptId(DEPT_ID_LONG);
// 可配载只放行公路 + 待执行 + 未配载,夹具的票都是这种票
waybill.setTransportType("公路运输");
waybill.setBusinessStatus("pending");
return waybill;
}
private void stubWaybills(Waybill... waybills) {
when(waybillMapper.selectList(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(waybills));
}
private void stubConfigs(ProcessConfig... configs) {
when(processConfigService.list(ArgumentMatchers.<Wrapper<ProcessConfig>>any())).thenReturn(List.of(configs));
}
private static ProcessConfig config(Long projectId, String nodeConfigJson) {
ProcessConfig config = new ProcessConfig();
config.setProjectIds(String.valueOf(projectId));
config.setNodeConfigJson(nodeConfigJson);
return config;
}
/** 提交路径上 mock 掉框架自带的落库与回查,只留业务写入可断言 */
private static void doReturnTrue(LoadingManageServiceImpl target) {
doReturn(true).when(target).saveOrUpdate(any(LoadingManage.class));
doReturn(true).when(target).updateById(any(LoadingManage.class));
}
/** 配载字段 + 状态那条批量更新;退回未配载的那条只写 loading_no/合同/状态,靠 driver_name 区分 */
private List<CapturedUpdate> statusSyncUpdates() {
return waybillUpdates().stream().filter(update -> update.columns().contains("driver_name")).toList();
}
/** 状态写入的 where 只有运单 id,故一条更新对应一张票 */
private Map<Long, String> statusByWaybillId() {
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
statusSyncUpdates().forEach(update -> statusByWaybillId.put(
update.whereIds().get(0), String.valueOf(update.setValue("business_status"))));
return statusByWaybillId;
}
/** 逐张推导的结果:提交/重新派单在准入阶段就拦下跨项目子单,跨项目推导只能直接测这个函数 */
private Map<Long, String> deriveBusinessStatuses(Waybill... waybills) throws Exception {
Method method = LoadingManageServiceImpl.class
.getDeclaredMethod("deriveWaybillBusinessStatuses", List.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
Map<Long, String> statusByWaybillId = (Map<Long, String>) method.invoke(service, List.of(waybills));
return statusByWaybillId;
}
private List<CapturedUpdate> acceptClearUpdates() {
return waybillUpdates().stream()
.filter(update -> update.columns().contains("driver_accept_status"))
.toList();
}
private List<CapturedUpdate> waybillUpdates() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillMapper, atLeastOnce()).update(ArgumentMatchers.<Waybill>isNull(), captor.capture());
return captor.getAllValues().stream().map(CapturedUpdate::of).toList();
}
private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) {
try {
Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("注入测试替身失败:" + fieldName, exception);
}
}
/** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */
private record CapturedUpdate(String sqlSet, String whereSql, Map<String, Object> params, List<String> columns) {
@SuppressWarnings("unchecked")
static CapturedUpdate of(Wrapper<Waybill> wrapper) {
LambdaUpdateWrapper<Waybill> update = (LambdaUpdateWrapper<Waybill>) wrapper;
String sqlSet = update.getSqlSet();
// SET 与 WHERE 的参数都是渲染时才进参数表,取值前先把两段都渲染一遍
String whereSql = update.getTargetSql();
List<String> columns = Arrays.stream(sqlSet.split(","))
.map(part -> part.substring(0, part.indexOf('=')).trim())
.toList();
return new CapturedUpdate(sqlSet, whereSql, update.getParamNameValuePairs(), columns);
}
Object setValue(String column) {
for (String part : sqlSet.split(",")) {
int equalsIndex = part.indexOf('=');
if (!column.equals(part.substring(0, equalsIndex).trim())) {
continue;
}
String reference = part.substring(equalsIndex + 1).trim();
if (!reference.startsWith("#{")) {
return reference;
}
String key = reference.substring(reference.lastIndexOf('.') + 1, reference.length() - 1);
return params.get(key);
}
return null;
}
List<Long> whereIds() {
return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList();
}
}
}
@@ -2,22 +2,27 @@ 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.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import java.time.LocalDateTime;
@@ -32,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -49,10 +55,14 @@ class ManageAdminHomeFeedTest {
private static final Long EXCEPTION_ID = 8801L;
private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001";
private static final String REJECTED_LOADING_NO = "PZ-20261017-001";
private static final String ROLE_ORDINARY = "yewu";
private static final String DEPT_ID = "1123598813738675201";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private IExceptionDisposalService exceptionService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
@@ -72,11 +82,22 @@ class ManageAdminHomeFeedTest {
mock(IDriverService.class),
exceptionService,
mock(IRiskDisposalService.class),
mock(IDriverWaybillService.class));
mock(IDriverWaybillService.class),
mock(ITripQueryService.class),
mock(ILoadingManageService.class));
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
.thenReturn(new ArrayList<>());
// 重派待办的库层查询要带可见组织;没有请求上下文时按普通用户 + 本组织给出
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@@ -141,6 +162,43 @@ class ManageAdminHomeFeedTest {
"拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前");
}
@Test
@DisplayName("整单拒绝的多张子单按运次收成一条:同一配载单只出一条重新派单待办")
void rejectedLoadingChildrenCollapseToOneTodo() {
// 库层按运次分组后,一张配载单无论几张子单被拒都只回一行(分组键见下面的 SQL 断言)
Waybill grouped = rejectedWaybill(7210L, "黄大富", "车辆不符");
grouped.setLoadingNo(REJECTED_LOADING_NO);
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(grouped));
List<AdminTodoItemVO> feed = service.buildAdminFeed();
assertEquals(1, feed.size(), "整单拒绝只应出一条待办");
assertEquals(REJECTED_LOADING_NO, feed.get(0).getWaybillNo(), "配载运次的待办展示配载单号");
assertEquals(Long.valueOf(7210L), feed.get(0).getId(), "跳转参数仍是运单主键:重新派单按运单写入");
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).list(captor.capture());
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("GROUP BY"), "去重要落在库层分组上,否则多张被拒子单会各出一条:" + sql);
assertTrue(sql.contains("CONCAT('W', id)"),
"分组键与运次读服务同源:有配载按配载单号、未配载每张运单自成一组:" + sql);
}
@Test
@DisplayName("两张未配载运单被拒仍是两条待办:未配载运单各自就是一趟运次")
void twoLoneRejectedWaybillsStayTwoTodos() {
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(
rejectedWaybill(7221L, "黄大富", null),
rejectedWaybill(7222L, "黄宇富", null)));
List<AdminTodoItemVO> feed = service.buildAdminFeed();
assertEquals(2, feed.size(), "未配载运单不合并");
assertEquals(List.of(7221L, 7222L),
feed.stream().map(AdminTodoItemVO::getId).sorted().collect(Collectors.toList()));
}
@Test
@DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与")
void reassignQueryExcludesTerminalStatus() {
@@ -148,7 +206,7 @@ class ManageAdminHomeFeedTest {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).list(captor.capture());
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) captor.getValue();
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql);
@@ -0,0 +1,338 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Date;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 调度端列表与详情的运次口径:一卡一运次、整趟车的站点与货物。
* <p>
* 卡片上的状态、站点与货物汇总都出自共享运次读服务,本类只做字段裁剪;这里钉住的是
* 「运次字段有没有被摆到卡片与详情上」,以及取不到运次时按运单主键与配载单主键的回落路径。
*/
class ManageTripListDetailTest {
private static final String LOADING_NO = "PZ-20261017-001";
private static final Long LOADING_ID = 2111350784070275074L;
private static final Long CHILD_A = 2111350784070275123L;
private static final Long CHILD_B = 2102780956493299713L;
private static final String ROLE_ORDINARY = "yewu";
private static final String DEPT_ID = "1123598813738675201";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private IExceptionDisposalService exceptionDisposalService;
private ITripQueryService tripQueryService;
private ILoadingManageService loadingManageService;
private MockedStatic<AuthUtil> authUtil;
private IDriverWaybillService driverWaybillService;
private static java.time.LocalDate toLocalDate(Date date) {
return java.time.Instant.ofEpochMilli(date.getTime()).atZone(java.time.ZoneId.systemDefault()).toLocalDate();
}
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
exceptionDisposalService = mock(IExceptionDisposalService.class);
tripQueryService = mock(ITripQueryService.class);
loadingManageService = mock(ILoadingManageService.class);
driverWaybillService = mock(IDriverWaybillService.class);
service = new ManageWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
exceptionDisposalService,
mock(IRiskDisposalService.class),
driverWaybillService,
tripQueryService,
loadingManageService);
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
// 列表与待处理都要先算出可见组织;没有请求上下文时按普通用户 + 本组织给出
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("列表一卡一运次:单号、组织类型、起经终、站点数、货物汇总都取运次字段")
void pageListShowsOneCardPerTrip() {
when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
IPage<AdminWaybillCardVO> page = service.pageList(1, 10, null, null, null, null, null, null);
assertEquals(1, page.getTotal());
assertEquals(1, page.getRecords().size(), "四张子单只出一条卡");
AdminWaybillCardVO card = page.getRecords().get(0);
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
assertEquals(LOADING_NO, card.getLoadingNo());
assertEquals(LOADING_NO, card.getTripNo());
assertEquals("load", card.getTransportType(), "有配载单即为公路配载");
assertEquals(2, card.getStatus());
assertEquals(4, card.getWaybillCount());
assertEquals(3, card.getStationCount());
assertEquals("南宁吴圩机场", card.getFromName());
assertEquals("新福兴硅科技有限公司", card.getViaName());
assertEquals("宾阳", card.getToName());
assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary());
assertEquals("161.84吨", card.getTotalWeight());
assertEquals("2026-09-23 23:23:42", card.getCreateTime(), "卡片时间取运次排序键");
assertEquals(Long.valueOf(CHILD_A), card.getId(), "旧 id 仍是运单主键,打卡订正等按它进入");
assertEquals("YD-20260825-0006", card.getWaybillNo());
assertFalse(card.getNeedReassign());
}
@Test
@DisplayName("列表筛选走同一次共享取数:组织类型、异常与创建时间随条件一起交给运次读服务")
void pageListFiltersGoThroughSharedQuery() {
when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
service.pageList(2, 20, "PZ-2026", "0", "normal", "load", "2026-09-01", "2026-09-30");
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, times(1)).page(captor.capture());
TripQueryVO query = captor.getValue();
assertEquals("load", query.getTransportOrgType());
assertEquals("normal", query.getException());
assertEquals(0, query.getStatus());
assertEquals("PZ-2026", query.getKeyword());
assertEquals(2, query.getCurrent());
assertEquals(20, query.getSize());
assertEquals(java.time.LocalDate.of(2026, 9, 1), toLocalDate(query.getCreatedFrom()));
assertEquals(java.time.LocalDate.of(2026, 9, 30), toLocalDate(query.getCreatedTo()));
verify(waybillService, never()).page(any(), any());
verify(tripQueryService, never()).detail(any());
}
@Test
@DisplayName("待处理列表:两档与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路")
void pendingListUsesSharedQuery() {
when(tripQueryService.page(any())).thenReturn(pageOf(0));
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
service.pendingList(1, 10, null, false);
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, times(1)).page(captor.capture());
assertEquals(List.of(0, 1), captor.getValue().getStatuses(), "待处理只要待接单与进行中两档");
assertTrue(captor.getValue().isExcludeRejectedTrips(), "未拒单也交给共享取数");
service.pendingList(1, 10, null, true);
verify(tripQueryService, times(1)).page(any());
verify(waybillService, times(1)).page(any(), any());
}
@Test
@DisplayName("详情按运次主键取:配载单号、组织类型、站点 ①②③ 与每站货物都带运单号")
void detailShowsWholeTripStations() {
when(tripQueryService.detail(any())).thenReturn(trip());
AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
assertEquals(LOADING_NO, detail.getLoadingNo());
assertEquals(LOADING_NO, detail.getTripNo());
assertEquals("load", detail.getTransportOrgType());
assertEquals(2, detail.getStatus());
assertEquals(3, detail.getStationCount());
assertEquals(3, detail.getStations().size());
assertEquals(3, detail.getRoutePoints().size(), "点位列与站点同源");
assertEquals("161.84吨", detail.getTotalWeight());
TripStationVO first = detail.getStations().get(0);
assertEquals(1, first.getIndex());
assertEquals("装货", first.getOperationTypeLabel());
assertEquals("2102780956493299713", first.getCargos().get(0).getWaybillId(), "货物条目带所属子运单主键");
assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo());
TripStationVO second = detail.getStations().get(1);
assertEquals("装卸", second.getOperationTypeLabel());
assertEquals(2, second.getCargos().size());
assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo(), "同一站可卸一张、装另一张");
assertEquals("2131150784070275011", second.getCargos().get(1).getWaybillId());
}
@Test
@DisplayName("取不到运次时按运单主键与配载单主键回落:整单被拒的配载单仍能打开并重新派单")
void detailFallsBackWhenTripIsNotReachable() {
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
when(waybillService.getById(CHILD_A)).thenReturn(null);
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
assertEquals(LOADING_NO, detail.getLoadingNo());
assertEquals("load", detail.getTransportOrgType());
assertTrue(detail.getNeedReassign(), "整单被拒时按配载单回落,标记待重新派单");
assertEquals(3, detail.getStationCount(), "子单为空时站点退回配载单自己的发货/途经/到货");
assertEquals("南宁吴圩机场", detail.getFromName());
assertEquals("宾阳", detail.getToName());
}
@Test
@DisplayName("运次主键不是数字时直接拒绝,不去查库")
void detailRefusesUnparsableTripId() {
assertThrows(ServiceException.class, () -> service.detail("not-a-number"));
assertThrows(ServiceException.class, () -> service.detail(null));
}
// ------------------------------------------------------------ 夹具
private Page<TripVO> pageOf(long total, TripVO... trips) {
Page<TripVO> page = new Page<>(1, 10, total);
page.setRecords(List.of(trips));
return page;
}
/**
* 一趟 4 张子单的配载运次:站点与货物按本库真实样本同形(三站,第二站装卸)
*/
private TripVO trip() {
TripVO trip = new TripVO();
trip.setId(String.valueOf(LOADING_ID));
trip.setTripNo(LOADING_NO);
trip.setLoadingNo(LOADING_NO);
trip.setTransportOrgType("load");
trip.setStatus(2);
trip.setRequireAccept(false);
trip.setWaybillCount(4);
trip.setDriverName("黄宇富");
trip.setVehicleNo("桂A32523");
trip.setDepartureName("南宁吴圩机场");
trip.setTransitName("新福兴硅科技有限公司");
trip.setArrivalName("宾阳");
trip.setStationCount(3);
trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨");
trip.setTotalQuantity(new BigDecimal("161.84"));
trip.setQuantityUnit("吨");
trip.setSortTime(at(1790177022000L));
trip.setEstimatedStartDate(LocalDate.of(2026, 8, 25));
trip.setEstimatedEndDate(LocalDate.of(2026, 9, 23));
trip.setWaybills(List.of(
tripWaybill(CHILD_A, "YD-20260825-0006"),
tripWaybill(2111350784070275124L, "YD-20260825-0007"),
tripWaybill(2111350784070275125L, "YD-20260825-0008"),
tripWaybill(CHILD_B, "YD-20260923-0008")));
trip.setStations(List.of(
station(1, "南宁吴圩机场", "load", "装货", cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30")),
station(2, "武宣", "load_unload", "装卸",
cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30"),
cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10")),
station(3, "宾阳", "unload", "卸货", cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10"))));
return trip;
}
private TripVO.TripWaybillVO tripWaybill(Long id, String waybillNo) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(String.valueOf(id));
child.setWaybillNo(waybillNo);
child.setBusinessStatus("running");
return child;
}
private TripStationVO station(int index, String name, String operationType, String label,
TripStationVO.TripCargoVO... cargos) {
TripStationVO station = new TripStationVO();
station.setIndex(index);
station.setName(name);
station.setAddress(name);
station.setOperationType(operationType);
station.setOperationTypeLabel(label);
station.setTags(new ArrayList<>());
station.setCargos(List.of(cargos));
return station;
}
private TripStationVO.TripCargoVO cargo(String waybillId, String waybillNo, String cargoName, String quantity) {
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(waybillNo);
cargo.setCargoName(cargoName);
cargo.setQuantity(new BigDecimal(quantity));
cargo.setQuantityUnit("吨");
return cargo;
}
private LoadingManage loading() {
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setDriverName("黄宇富");
loading.setDriverPhone("17587421208");
loading.setVehicleNo("桂A32523");
loading.setDepartureAddress("南宁吴圩机场");
loading.setTransitAddress("武宣");
loading.setArrivalAddress("宾阳");
loading.setCargoName("石英砂A");
return loading;
}
private Date at(long millis) {
return new Date(millis);
}
}
@@ -0,0 +1,226 @@
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.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
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.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 调度端的组织可见范围:普通用户只出自己所属组织,超级管理员不过滤。
* <p>
* 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现
* 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。
* <p>
* 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。
*/
class ManageWaybillOrgScopeTest {
private static final String DEPT_ID = "1123598813738675202";
private static final Long DEPT = 1123598813738675202L;
private static final String ROLE_ORDINARY = "yewu";
private static final String ROLE_ADMINISTRATOR = "administrator";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private ITripQueryService tripQueryService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
tripQueryService = mock(ITripQueryService.class);
IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class);
service = new ManageWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
exceptionDisposalService,
mock(IRiskDisposalService.class),
mock(IDriverWaybillService.class),
tripQueryService,
mock(ILoadingManageService.class));
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
// 待重派那次分页查询回它收到的 Page(总数从入参上读,回 null 会空指针)
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
// 运次读服务的两次取数:本类只看传给它的条件,行集合给空页即可
when(tripQueryService.page(any())).thenReturn(emptyTripPage());
when(tripQueryService.tabCounts(any())).thenReturn(new TripTabCountsVO());
authUtil = mockStatic(AuthUtil.class);
asOrdinaryUser();
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("普通用户:列表把当前组织交给运次读服务,行级收口落在库层")
void pageListScopesCurrentDept() {
service.pageList(1, 10, null, null, null, null, null, null);
TripQueryVO query = capturedPageQuery();
assertEquals(List.of(DEPT), query.getDeptIds(), "列表只出本组织");
assertTrue(query.isAllDrivers(), "组织收口不影响司机收口口径");
}
@Test
@DisplayName("普通用户:待处理列表与列表同一条组织规则")
void pendingListScopesCurrentDept() {
service.pendingList(1, 10, null, false);
assertEquals(List.of(DEPT), capturedPageQuery().getDeptIds());
}
@Test
@DisplayName("普通用户:首页三档统计与列表同一口径,否则统计与列表的数对不上")
void statsScopesCurrentDept() {
service.stats();
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, atLeastOnce()).tabCounts(captor.capture());
assertEquals(List.of(DEPT), captor.getValue().getDeptIds());
}
@Test
@DisplayName("超级管理员:三处都不带组织,看全租户")
void administratorSeesEveryDept() {
asAdministrator();
service.pageList(1, 10, null, null, null, null, null, null);
ArgumentCaptor<TripQueryVO> pageCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).page(pageCaptor.capture());
assertTrue(pageCaptor.getValue().getDeptIds().isEmpty(), "超管不过滤组织");
service.stats();
ArgumentCaptor<TripQueryVO> statsCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, atLeastOnce()).tabCounts(statsCaptor.capture());
assertTrue(statsCaptor.getValue().getDeptIds().isEmpty(), "超管的统计也不过过滤组织");
}
@Test
@DisplayName("待重新派单:库层分组查询也带组织,否则重派列表与角标会出别的组织的单")
void reassignQueryScopesCurrentDept() {
service.pendingList(1, 10, null, true);
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).page(any(), captor.capture());
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
String sql = String.valueOf(wrapper.getTargetSql());
assertTrue(sql.contains("dept_id"), "重派查询要带上组织:" + sql);
Set<Object> values = new HashSet<>(wrapper.getParamNameValuePairs().values());
assertTrue(values.contains(DEPT), "组织值要作为参数下发:" + values);
}
@Test
@DisplayName("待重新派单:超管的库层分组查询不带组织")
void reassignQuerySkipsScopeForAdministrator() {
asAdministrator();
service.pendingList(1, 10, null, true);
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).page(any(), captor.capture());
String sql = String.valueOf(captor.getValue().getTargetSql());
assertFalse(sql.contains("dept_id"), "超管不按组织收口:" + sql);
}
@Test
@DisplayName("详情不按组织收口:按主键进入,不带组织条件")
void detailIsNotScoped() {
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
when(waybillService.getById(any())).thenReturn(null);
assertThrows(ServiceException.class, () -> service.detail("7201"));
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).detail(captor.capture());
assertTrue(captor.getValue().getDeptIds() == null || captor.getValue().getDeptIds().isEmpty(),
"详情与后台同口径:不按组织收口");
}
@Test
@DisplayName("组织取不到时报错,不退回不带组织条件")
void blankDeptFailsClosed() {
authUtil.when(AuthUtil::getDeptId).thenReturn("");
assertThrows(ServiceException.class,
() -> service.pageList(1, 10, null, null, null, null, null, null),
"没有组织的账号不能拿到全租户运单");
}
// ------------------------------------------------------------ 夹具
private IPage<TripVO> emptyTripPage() {
Page<TripVO> page = new Page<>(1, 10, 0);
page.setRecords(List.of());
return page;
}
private TripQueryVO capturedPageQuery() {
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).page(captor.capture());
return captor.getValue();
}
private void asOrdinaryUser() {
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
private void asAdministrator() {
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ADMINISTRATOR);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
}
@@ -18,6 +18,7 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.service.ITransportVehicleService;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
@@ -239,6 +240,15 @@ class PersonCertRoundTripTest {
if (patch.getIdCardNo() != null) {
target.setIdCardNo(patch.getIdCardNo());
}
if (patch.getBirthday() != null) {
target.setBirthday(patch.getBirthday());
}
if (patch.getAddressRegion() != null) {
target.setAddressRegion(patch.getAddressRegion());
}
if (patch.getAddress() != null) {
target.setAddress(patch.getAddress());
}
if (patch.getIdCardFront() != null) {
target.setIdCardFront(patch.getIdCardFront());
}
@@ -287,6 +297,9 @@ class PersonCertRoundTripTest {
@DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致")
void approveWritesCertContentBackToDriver() {
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
request.setBirthday(LocalDate.of(1990, 1, 1));
request.setAddressRegion("广西壮族自治区柳州市柳北区");
request.setAddress("跃进路88号");
request.setHeadPhoto("http://example.com/head.jpg");
request.setDriverClass("A2");
request.setDriverFileNo("450100769875");
@@ -302,6 +315,9 @@ class PersonCertRoundTripTest {
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
assertEquals(LocalDate.of(1990, 1, 1), driver.getBirthday());
assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion());
assertEquals("跃进路88号", driver.getAddress());
assertEquals("http://example.com/head.jpg", driver.getHeadPhoto());
assertEquals("A2", driver.getDrivingType());
assertEquals("450100769875", driver.getDrivingLicenseNo());
@@ -313,6 +329,25 @@ class PersonCertRoundTripTest {
assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack());
}
@Test
@DisplayName("审核通过:住址留空不清空管理端已填的住址")
void approveKeepsAddressWhenSnapshotBlank() {
driver.setAddressRegion("广西壮族自治区柳州市柳北区");
driver.setAddress("跃进路88号");
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
request.setAddressRegion(" ");
request.setAddress("");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, true, null);
// 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉
assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(),
"区划是选填项,没选就保持档案原值");
assertEquals("跃进路88号", driver.getAddress(),
"详细地址是选填项,没填就保持档案原值");
}
@Test
@DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话")
void approveOverwritesIdentity() {
@@ -0,0 +1,204 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 运次四档状态、接单需求合并与运次键口径。
* <p>
* 四档只在这一处推导演算,Tab 过滤与卡片状态都用它:这里断言的就是分档与卡片共用的那份口径。
*/
class TripAggregationTest {
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
private static final String ACCEPT_YES_DISABLED = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":false,\"confirmMode\":\"yes\"}]";
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
@Test
@DisplayName("子单全部完成 → 已完成")
void allCompleted() {
List<Waybill> children = List.of(waybill("completed", null, null), waybill("completed", "accepted", null));
assertEquals("completed", TripAggregation.statusOf(children, false, false));
assertEquals(2, TripAggregation.appStatus(TripAggregation.statusOf(children, false, false)));
}
@Test
@DisplayName("子单全部取消 → 已取消")
void allCancelled() {
List<Waybill> children = List.of(waybill("cancelled", null, null), waybill("cancelled", null, null));
String status = TripAggregation.statusOf(children, false, false);
assertEquals("cancelled", status);
assertEquals(3, TripAggregation.appStatus(status));
}
@Test
@DisplayName("子单全是终态且含已完成 → 已完成(不看取消的那张)")
void mixedTerminalKeepsCompleted() {
List<Waybill> children = List.of(waybill("completed", null, null), waybill("cancelled", null, null));
assertEquals("completed", TripAggregation.statusOf(children, false, false));
}
@Test
@DisplayName("需接单且未接单 → 待接单")
void requireAcceptWithoutAcceptance() {
List<Waybill> children = List.of(waybill("pending", "pending", ACCEPT_YES));
String status = TripAggregation.statusOf(children, true, false);
assertEquals("pending", status);
assertEquals(0, TripAggregation.appStatus(status));
}
@Test
@DisplayName("不需接单 → 运输中")
void withoutAcceptRequirement() {
List<Waybill> children = List.of(waybill("pending", null, ACCEPT_NO));
String status = TripAggregation.statusOf(children, false, false);
assertEquals("running", status);
assertEquals(1, TripAggregation.appStatus(status));
}
@Test
@DisplayName("需接单且已接单 → 运输中")
void requireAcceptAccepted() {
List<Waybill> children = List.of(waybill("pending", "accepted", ACCEPT_YES));
assertEquals("running", TripAggregation.statusOf(children, true, true));
}
@Test
@DisplayName("子单还有没走完的且需接单 → 待接单")
void pendingChildKeepsTripPending() {
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES), waybill("dispatching", null, null));
assertEquals("pending", TripAggregation.statusOf(children, true, false));
}
@Test
@DisplayName("子单集合为空不抛异常,状态无可判定为空")
void emptyChildrenDoNotThrow() {
List<Waybill> children = List.of();
assertNull(TripAggregation.statusOf(children, true, false));
assertNull(TripAggregation.appStatus(null));
assertEquals(0, TripAggregation.count(children).total());
assertNull(TripAggregation.acceptStatus(children));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("跨项目运次按 OR 合并接单需求:一张要接单整趟车都要接单")
void crossProjectRequirementOr() {
List<Waybill> children = List.of(
waybill("running", null, null),
waybill("running", null, ACCEPT_YES));
assertTrue(TripAggregation.requiresAccept(children));
assertEquals("pending", TripAggregation.statusOf(children, true, false));
}
@Test
@DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
void crossProjectWithoutAnyRequirement() {
List<Waybill> children = List.of(
waybill("running", null, null),
waybill("running", null, ACCEPT_NO));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("停用的接单节点不算需要接单")
void disabledAcceptNodeIsNotRequirement() {
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES_DISABLED));
assertFalse(TripAggregation.requiresAccept(children));
}
@Test
@DisplayName("接单态:任一子单已接单即整单已接单,否则待接单,无从判定为空")
void acceptStatusAggregation() {
assertEquals("accepted", TripAggregation.acceptStatus(List.of(
waybill("running", "pending", ACCEPT_YES), waybill("running", "accepted", ACCEPT_YES))));
assertEquals("pending", TripAggregation.acceptStatus(List.of(
waybill("running", "pending", ACCEPT_YES), waybill("running", null, ACCEPT_YES))));
assertNull(TripAggregation.acceptStatus(List.of(waybill("completed", null, null))));
}
@Test
@DisplayName("四档都由同一份推导给出:待接单 / 进行中 / 已完成 / 已取消")
void tiersComeFromTheSameDerivation() {
// 需接单且未接单 → 待接单档
assertEquals(0, tier(List.of(waybill("pending", "pending", ACCEPT_YES))));
// 需接单且已接单 → 进行中档
assertEquals(1, tier(List.of(waybill("running", "accepted", ACCEPT_YES))));
// 子单全完成 → 已完成档
assertEquals(2, tier(List.of(waybill("completed", "accepted", ACCEPT_YES))));
// 子单全取消 → 已取消档
assertEquals(3, tier(List.of(waybill("cancelled", null, ACCEPT_YES))));
// 子单无从判定(空集合)时不落任何档,不会混进「全部」
assertNull(tier(List.of()));
}
@Test
@DisplayName("库层业务状态与过程配置不一致时按推导分档,不按库值")
void tiersFollowDerivationNotStoredStatus() {
// 库里落的是 pending(惰性校正没跑过),但该项目不需要接单 → 推导为进行中,不进待接单档
assertEquals(1, tier(List.of(waybill("pending", "pending", ACCEPT_NO))));
// 反过来:库里落的是 running,但项目要接单且没接 → 推导为待接单
assertEquals(0, tier(List.of(waybill("running", "pending", ACCEPT_YES))));
}
@Test
@DisplayName("拼载的多张子单按 OR 合并接单需求:任一要接单且未接单就落在待接单档")
void mixedTripFollowsOrRule() {
assertEquals(0, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "pending", ACCEPT_YES))));
assertEquals(1, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "accepted", ACCEPT_YES))));
}
@Test
@DisplayName("运次键:有配载取配载单号,未配载取 W + 运单主键")
void tripKeyFollowsLoadingNo() {
assertEquals("PZ-20260919-001", TripAggregation.keyOf("PZ-20260919-001", 123L));
assertEquals("W123", TripAggregation.keyOf(null, 123L));
assertEquals("W123", TripAggregation.keyOf("", 123L));
}
private Integer tier(List<Waybill> children) {
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = org.springblade.transport.support.WaybillProcessSupport.ACCEPT_ACCEPTED
.equals(TripAggregation.acceptStatus(children));
return TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted));
}
@Test
@DisplayName("草稿子单不进运次,已取消子单要进(否则全取消会被算成运输中)")
void childStatusScope() {
assertFalse(TripAggregation.CHILD_STATUSES.contains("draft"));
assertTrue(TripAggregation.CHILD_STATUSES.contains("cancelled"));
assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES));
}
private Waybill waybill(String businessStatus, String acceptStatus, String processJson) {
Waybill waybill = new Waybill();
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setProcessJson(processJson);
return waybill;
}
}
@@ -0,0 +1,710 @@
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.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IWaybillService;
import java.time.LocalDate;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 运次读服务的取数与聚合输出。
* <p>
* 库层只负责取回运单行:分组、分档、筛选、排序与分页都在服务层,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 IProcessConfigService processConfigService;
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);
processConfigService = mock(IProcessConfigService.class);
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
}
@Test
@DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里")
void rowsAreFetchedWithoutDatabaseGrouping() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(1);
query.setCurrent(1);
query.setSize(10);
service.page(query);
String sql = capturedRowSql();
assertFalse(sql.contains("GROUP BY"), "库层不再分组:" + sql);
assertFalse(sql.contains("HAVING"), "库层不再分档:" + sql);
assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql);
assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql);
assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql);
assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql);
assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"),
"未配载段按运单司机收口,且容忍手机号带空格:" + sql);
assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql);
assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql);
}
@Test
@DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来")
void onlyNeededColumnsAreSelected() {
service.page(TripQueryVO.forDriver(PHONE));
String select = String.valueOf(capturedRowQuery().getSqlSelect());
assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select);
assertTrue(select.contains("create_time"), "排序键要用:" + select);
assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select);
assertTrue(select.contains("estimated_end_time"), select);
assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select);
assertTrue(select.contains("goods_json"), "货物明细只能从 goods_json 拿,必须随行取回:" + select);
assertFalse(select.contains("task_info_json"), select);
assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select);
}
@Test
@DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算")
void deptScopeIsAppliedOnRows() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L));
service.page(query);
String sql = capturedRowSql();
assertTrue(sql.contains("dept_id IN"), "组织收口要下发到库层:" + sql);
Set<Object> values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values());
assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L),
"组织值按参数下发:" + values);
assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效");
}
@Test
@DisplayName("统计四档时组织收口不能丢:去掉的只有 Tab 条件,其余条件照搬")
void tabCountsKeepsDeptScope() {
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(List.of(1123598813738675202L));
service.tabCounts(query);
assertTrue(capturedRowSql().contains("dept_id IN"),
"统计与列表必须是同一份可见范围,否则首页三张卡与列表的数对不上:" + capturedRowSql());
}
@Test
@DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理")
void deptScopeIsOptional() {
service.page(TripQueryVO.forDriver(PHONE));
assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql());
}
@Test
@DisplayName("Tab 在服务层按推导分档过滤,每张卡的 status 与该档一致")
void tabFilterHappensOnDerivedTier() {
stubRows(
waybill(1L, "YD-1", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO));
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
doing.setStatus(1);
IPage<TripVO> doingPage = service.page(doing);
assertEquals(2, doingPage.getTotal(), "只有两趟车在进行中");
assertEquals(List.of("1", "3"), doingPage.getRecords().stream().map(TripVO::getId).toList(),
"排序键是子单 create_time 最大值,倒序(YD-1 的 3000 > YD-3 的 1000)");
assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())),
"每张卡的 status 都要等于这一档");
TripQueryVO done = TripQueryVO.forDriver(PHONE);
done.setStatus(2);
assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList());
TripQueryVO all = TripQueryVO.forDriver(PHONE);
all.setStatus(null);
assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)");
}
@Test
@DisplayName("库里落的状态与过程配置不一致时按推导分档,不按库值")
void tierFollowsDerivationNotStoredStatus() {
// 一张库值 pending 但项目不需要接单(推导为进行中),一张库值 running 但项目要接单且没接(推导为待接单)
stubRows(
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO));
TripQueryVO pending = TripQueryVO.forDriver(PHONE);
pending.setStatus(0);
assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(),
"库值 running 但要接单未接 → 落待接单档");
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
doing.setStatus(1);
assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(),
"库值 pending 但不需要接单 → 落进行中档");
}
@Test
@DisplayName("关键字按运次匹配:任一子单或配载单号命中即整趟车命中,不缩小这一趟车的子单集合")
void keywordMatchesWholeTrip() {
stubRowsBySql(
List.of(waybill(9L, "YD-9", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)),
null,
List.of(
waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES),
waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setKeyword("PZ-20261017");
TripVO trip = service.page(query).getRecords().get(0);
String keywordSql = capturedKeywordSql();
assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql);
assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql);
assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小");
assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql());
}
@Test
@DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式")
void unloadedWaybillIsItsOwnTrip() {
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertEquals(1, 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());
verify(waybillService, times(1)).list(anyWaybillWrapper());
}
@Test
@DisplayName("未配载运次的状态与接单需求来自段一取回的那张运单")
void unloadedTripStateComesFromFetchedWaybill() {
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
assertEquals(Boolean.TRUE, trip.getRequireAccept());
assertEquals("pending", trip.getAcceptStatus());
assertEquals(0, trip.getStatus());
assertEquals(1, trip.getWaybillCount());
assertEquals("YD-20260920-0003", trip.getTripNo());
}
@Test
@DisplayName("未配载运次的计划时间取自这张运单:没有配载单可依,不能留空")
void unloadedTripPlanTimeComesFromWaybill() {
Waybill row = waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO);
row.setEstimatedStartTime(LocalDate.of(2026, 8, 7));
row.setEstimatedEndTime(LocalDate.of(2026, 8, 24));
stubRows(row);
TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补");
assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate());
when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null);
when(waybillService.getById(WAYBILL_ID)).thenReturn(row);
TripVO detailed = service.detail(tripQuery(String.valueOf(WAYBILL_ID)));
assertEquals(LocalDate.of(2026, 8, 7), detailed.getEstimatedStartDate(), "管理端详情走这条路,不留空");
assertEquals(LocalDate.of(2026, 8, 24), detailed.getEstimatedEndDate());
}
@Test
@DisplayName("配载单在两次查询之间被删掉时,不给出没有主键的运次")
void loadingMissingIsDropped() {
stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车");
assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在");
}
@Test
@DisplayName("配载运次:主键取配载单主键,子单就是段一取回的行,状态与排序键按子单聚合")
void loadingTripAggregatesChildren() {
Date earlier = at(1787657033000L);
Date latest = at(1787671422000L);
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
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() {
stubRows(
waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单");
assertEquals("pending", trip.getAcceptStatus());
assertEquals(0, trip.getStatus());
}
@Test
@DisplayName("同一运次连读两次状态与接单态不漂移,聚合路径零写库")
void repeatedReadsDoNotDriftAndNeverWrite() {
stubOneLoadedTrip();
TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
assertEquals(first.getStatus(), second.getStatus());
assertEquals(first.getAcceptStatus(), second.getAcceptStatus());
assertEquals(first.getRequireAccept(), second.getRequireAccept());
assertEquals(first.getSortTime(), second.getSortTime());
assertEquals(first.getWaybillCount(), second.getWaybillCount());
verify(waybillService, never()).updateById(any());
verify(waybillService, never()).update(any(), any());
verify(waybillService, never()).saveOrUpdate(any());
verify(waybillService, never()).syncDriverAcceptState(any());
verify(loadingManageService, never()).updateById(any());
verify(loadingManageService, never()).update(any(), any());
}
@Test
@DisplayName("收口缺失时不放行:返回空页、零统计,详情直接拒绝,且不查库")
void missingScopeIsRefused() {
TripQueryVO blank = new TripQueryVO();
IPage<TripVO> page = service.page(blank);
TripTabCountsVO counts = service.tabCounts(blank);
assertEquals(0, page.getTotal());
assertTrue(page.getRecords().isEmpty());
assertEquals(0, counts.getAll());
assertThrows(ServiceException.class, () -> service.detail(blank));
verify(waybillService, never()).list(anyWaybillWrapper());
verify(waybillService, never()).page(any(), any());
}
@Test
@DisplayName("分页落在过滤后的完整运次序列上:总数即运次数,换每页条数不串页")
void pagingIsPerTrip() {
stubRows(
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_YES),
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
TripQueryVO firstPage = TripQueryVO.forDriver(PHONE);
firstPage.setCurrent(1);
firstPage.setSize(2);
assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数");
assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList());
TripQueryVO secondPage = TripQueryVO.forDriver(PHONE);
secondPage.setCurrent(2);
secondPage.setSize(2);
assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(),
"第 2 页接着第 1 页,不重复不遗漏");
TripQueryVO bySeven = TripQueryVO.forDriver(PHONE);
bySeven.setCurrent(1);
bySeven.setSize(7);
assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变");
assertTrue(service.page(bySeven).getCurrent() == 1);
}
@Test
@DisplayName("Tab 统计一次取数算清四档,全部=待接单+进行中+已完成")
void tabCountsPerTrip() {
stubRows(
waybill(4L, "YD-4", null, "pending", "pending", at(4000L), ACCEPT_YES),
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO));
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE));
assertEquals(1, counts.getPending());
assertEquals(2, counts.getDoing());
assertEquals(1, counts.getDone());
assertEquals(4, counts.getAll(), "全部不含已取消档");
verify(waybillService, times(1)).list(anyWaybillWrapper());
}
@Test
@DisplayName("待处理两档与「未拒单」都走同一次取数:多档筛选与已拒运次排除")
void multiTierAndRejectedExclusion() {
stubRowsBySql(null,
// 被拒子单的运次键:只有 YD-9 那一趟
List.of(waybill(9L, "YD-9", null, "pending", "rejected", at(500L), ACCEPT_NO)),
List.of(
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
waybill(2L, "YD-2", null, "pending", "pending", at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO)));
TripQueryVO query = TripQueryVO.forAdmin();
query.setStatuses(List.of(0, 1));
query.setExcludeRejectedTrips(true);
List<String> ids = service.page(query).getRecords().stream().map(TripVO::getId).toList();
assertEquals(List.of("3", "2"), ids, "只要待接单与进行中两档");
assertEquals(2, service.page(query).getTotal());
}
@Test
@DisplayName("管理端筛选只决定哪些运次在范围内,分档仍看整趟车的子单")
void filtersDoNotShrinkTheTieringChildSet() {
// 一趟车两张子单:一张已完成(命中异常子单,也落在创建时间区间内)、一张还在运输中
stubRows(
waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripQueryVO query = TripQueryVO.forAdmin();
query.setException("exception");
query.setExceptionWaybillIds(java.util.Set.of(2L));
query.setCreatedFrom(at(1900L));
query.setCreatedTo(at(2100L));
TripVO trip = service.page(query).getRecords().get(0);
assertEquals(2, trip.getWaybillCount(), "筛选不缩小子单集合");
assertEquals(1, trip.getStatus(), "整趟车还在运输中,不能因为命中的是那张已完成的子单就归到已完成档");
}
@Test
@DisplayName("管理端筛选:组织类型按子单行筛(同一趟车里是同一个值),异常与创建时间不进 SQL")
void adminFiltersStayAtTripGranularity() {
TripQueryVO query = TripQueryVO.forAdmin();
query.setTransportOrgType("common");
query.setException("normal");
query.setExceptionWaybillIds(java.util.Set.of(7L, 8L));
query.setCreatedFrom(at(1000L));
query.setCreatedTo(at(2000L));
service.page(query);
String sql = capturedRowSql();
assertTrue(sql.contains("IFNULL(loading_no, '') = ''"), "普通运输在库层按子单行筛:" + sql);
assertFalse(sql.contains("id NOT IN ("), "异常要看整趟车,不能在库层缩小子单集合:" + sql);
assertFalse(sql.contains("create_time"), "创建时间按运次排序键在服务层判:" + sql);
assertFalse(sql.contains("blade_loading_manage"), "管理端不按司机收口:" + sql);
}
@Test
@DisplayName("创建时间按子单 create_time 的最大值(卡片上显示的就是它)判区间")
void createdRangeUsesTripSortKey() {
// 两张子单:一张在区间外、一张在区间内,运次的排序键取后创建的那张
stubRows(
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_NO));
TripQueryVO inside = TripQueryVO.forAdmin();
inside.setCreatedFrom(at(1900L));
inside.setCreatedTo(at(2100L));
assertEquals(1, service.page(inside).getTotal(), "排序键 2000 落在区间内 → 这趟车在范围内");
TripQueryVO outside = TripQueryVO.forAdmin();
outside.setCreatedFrom(at(1100L));
outside.setCreatedTo(at(1900L));
assertEquals(0, service.page(outside).getTotal(), "排序键 2000 不在区间内 → 这趟车不在范围内");
}
@Test
@DisplayName("详情与列表同口径:子单快照没有接单节点、项目生效配置要求接单时,详情也算待接单")
void detailUsesLiveProcessConfig() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
at(2000L), ACCEPT_NO);
child.setProjectId(7001L);
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child));
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES)));
TripVO trip = service.detail(tripQuery(String.valueOf(LOADING_ID)));
assertEquals(0, trip.getStatus(), "生效配置要求接单且未接 → 待接单档,与列表一致");
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求按生效配置判,不看快照");
assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept());
assertEquals("pending", trip.getAcceptStatus());
}
@Test
@DisplayName("详情仍是只读取数:生效配置只写回内存里的行,不落库")
void detailStaysReadOnly() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
at(2000L), ACCEPT_NO);
child.setProjectId(7001L);
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child));
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES)));
service.detail(tripQuery(String.valueOf(LOADING_ID)));
verify(waybillService, never()).updateById(any());
verify(waybillService, never()).update(any(), any());
verify(waybillService, never()).syncDriverAcceptState(any());
assertEquals(ACCEPT_YES, child.getProcessJson(), "生效配置只写回内存里的行,库里的快照列不动");
}
@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, at(1000L), ACCEPT_YES));
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, at(1000L), ACCEPT_YES));
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() {
// 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在
stubRows();
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次");
assertEquals(0, page.getTotal());
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")
private static Wrapper<Waybill> anyWaybillWrapper() {
return ArgumentMatchers.<Wrapper<Waybill>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<LoadingManage> anyLoadingWrapper() {
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
/** 段一取回的可见运单行 */
private void stubRows(Waybill... rows) {
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(rows));
}
/**
* 关键字先查一次命中的运次键、再查一次运单行,两次都走 list:按 SQL 里有没有 LIKE 分流
*/
private void stubRowsBySql(List<Waybill> keywordRows, List<Waybill> rejectedRows, List<Waybill> rows) {
when(waybillService.list(anyWaybillWrapper())).thenAnswer(invocation -> {
Wrapper<Waybill> wrapper = invocation.getArgument(0);
String sql = String.valueOf(wrapper.getTargetSql());
if (sql.contains("LIKE")) {
return keywordRows;
}
// 被拒运次那次按等值找已拒子单;段一那次用 IS NULL 兼容为空
return sql.contains("IS NULL") ? rows : rejectedRows;
});
}
private void stubOneLoadedTrip() {
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
}
private void stubChildren() {
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO)));
}
private ProcessConfig processConfig(Long projectId, String nodeConfigJson) {
ProcessConfig config = new ProcessConfig();
config.setProjectIds(String.valueOf(projectId));
config.setNodeConfigJson(nodeConfigJson);
config.setStatus(1);
config.setIsDeleted(0);
return config;
}
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 static Date at(long millis) {
return new Date(millis);
}
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<LambdaQueryWrapper<Waybill>> rowCaptor() {
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
}
/** 段一的运单行查询:按可见口径那次(带 IS NULL)取,关键字与已拒运次那两次另算 */
@SuppressWarnings("unchecked")
private LambdaQueryWrapper<Waybill> capturedRowQuery() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService, org.mockito.Mockito.atLeastOnce()).list(captor.capture());
return (LambdaQueryWrapper<Waybill>) captor.getAllValues().stream()
.filter(wrapper -> String.valueOf(wrapper.getTargetSql()).contains("IS NULL"))
.findFirst()
.orElseThrow(() -> new AssertionError("没有发出段一的运单行查询"));
}
private String capturedRowSql() {
return String.valueOf(capturedRowQuery().getTargetSql());
}
/** 关键字那次查询:带 LIKE 的那次调用 */
private String capturedKeywordSql() {
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService, times(2)).list(captor.capture());
return captor.getAllValues().stream()
.map(wrapper -> String.valueOf(wrapper.getTargetSql()))
.filter(sql -> sql.contains("LIKE"))
.findFirst()
.orElseThrow(() -> new AssertionError("没有发出关键字查询"));
}
}
@@ -0,0 +1,379 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.ArgumentMatchers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IWaybillService;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.when;
/**
* 运次读服务的聚合边界:四档状态、排序键、空子单集合与主键下发形态。
* <p>
* 断言都从服务出口读({@code page} / {@code detail}),不直接调判定函数:随卡片下发的
* {@code status} 与 {@code requireAccept} 是两个字段,只验静态判定看不出服务有没有把子单真身取回来——
* 库层分组行只投影主键、分组键与排序键,business_status 是 NULL。
*/
class TripReadServiceBoundaryTest {
private static final String PHONE = "17587421208";
private static final String LOADING_NO = "PZ-20261017-001";
/** 19 位雪花号,按数字下发会被双精度取整 */
private static final Long LOADING_ID = 2111350784070275074L;
private static final Long CHILD_A = 2111350784070275123L;
private static final Long CHILD_B = 2102780956493299713L;
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 IProcessConfigService processConfigService;
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);
processConfigService = mock(IProcessConfigService.class);
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
}
@Test
@DisplayName("子单全部完成 → 运次已完成(2)")
void allChildrenCompleted() {
stubLoadedTrip(
child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
child(CHILD_B, "completed", "accepted", ACCEPT_NO, at(1787671422000L)));
TripVO trip = onlyTrip();
assertEquals(2, trip.getStatus(), "终态且含已完成为已完成档");
assertEquals(2, trip.getWaybillCount());
assertEquals("accepted", trip.getAcceptStatus(), "任一子单已接单即整单已接单");
}
@Test
@DisplayName("子单全部取消 → 运次已取消(3),不是运输中")
void allChildrenCancelled() {
stubLoadedTrip(
child(CHILD_A, "cancelled", null, ACCEPT_NO, at(1787657033000L)),
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
// 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setStatus(3);
TripVO trip = onlyTrip(query);
assertEquals(3, trip.getStatus(), "已取消子单必须进运次,否则全取消会被算成运输中");
}
@Test
@DisplayName("混合终态且含 completed → 运次已完成(2),不看取消的那张")
void mixedTerminalWithCompleted() {
stubLoadedTrip(
child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
TripVO trip = onlyTrip();
assertEquals(2, trip.getStatus());
}
@Test
@DisplayName("需接单且未接单 → 待接单(0)")
void requireAcceptWithoutAcceptance() {
Waybill required = child(CHILD_A, "pending", "pending", ACCEPT_YES, at(1787657033000L));
required.setProjectId(1L);
required.setProjectName("MK项目测试-1");
stubLoadedTrip(required);
TripVO trip = onlyTrip();
assertEquals(Boolean.TRUE, trip.getRequireAccept());
assertEquals("pending", trip.getAcceptStatus());
assertEquals(0, trip.getStatus());
}
@Test
@DisplayName("不需接单 → 运输中(1)")
void withoutAcceptRequirement() {
Waybill notRequired = child(CHILD_A, "pending", null, ACCEPT_NO, at(1787657033000L));
notRequired.setProjectId(2L);
notRequired.setProjectName("武宣建筑原料运输项目");
stubLoadedTrip(notRequired);
TripVO trip = onlyTrip();
assertEquals(Boolean.FALSE, trip.getRequireAccept());
assertEquals(1, trip.getStatus());
}
@Test
@DisplayName("跨项目子单的接单需求按 OR 合并:一张要接单整趟车都要接单")
void crossProjectRequirementMergesWithOr() {
Waybill required = child(CHILD_A, "running", null, ACCEPT_YES, at(1787657033000L));
required.setProjectId(1L);
required.setProjectName("MK项目测试-1");
Waybill notRequired = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
notRequired.setProjectId(2L);
notRequired.setProjectName("武宣建筑原料运输项目");
stubLoadedTrip(required, notRequired);
TripVO trip = onlyTrip();
assertEquals(Boolean.TRUE, trip.getRequireAccept());
assertEquals(0, trip.getStatus());
assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept());
assertEquals(Boolean.FALSE, trip.getWaybills().get(1).getRequireAccept());
}
@Test
@DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
void crossProjectWithoutAnyRequirement() {
Waybill first = child(CHILD_A, "running", null, null, at(1787657033000L));
first.setProjectId(1L);
Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
second.setProjectId(2L);
stubLoadedTrip(first, second);
TripVO trip = onlyTrip();
assertEquals(Boolean.FALSE, trip.getRequireAccept());
assertEquals(1, trip.getStatus());
}
@Test
@DisplayName("排序键取子单 create_time 的最大值,不取第一张也不取最后一张")
void sortTimeIsMaxChildCreateTime() {
Date earliest = at(1787657033000L);
Date latest = at(1787671422000L);
Date middle = at(1787663000000L);
stubLoadedTrip(
child(CHILD_A, "running", null, ACCEPT_NO, middle),
child(2111350784070275999L, "running", null, ACCEPT_NO, latest),
child(CHILD_B, "running", null, ACCEPT_NO, earliest));
TripVO trip = onlyTrip();
assertEquals(latest, trip.getSortTime());
assertEquals(3, trip.getWaybillCount());
assertEquals(earliest, trip.getWaybills().get(0).getCreateTime(), "子单按创建时间升序摆");
}
@Test
@DisplayName("子单集合为空不抛异常:列表不给这张卡,详情直接拒绝而不是 NPE")
void emptyChildrenDoNotThrow() {
stubLoadedTrip();
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次");
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
assertThrows(ServiceException.class, () -> service.detail(detailQuery()),
"子单全被拒单的配载运次对司机不可见");
}
@Test
@DisplayName("子单状态为空时不抛异常:读不出状态按没走完兜底,不崩也不误判成终态")
void nullBusinessStatusDoesNotThrow() {
Waybill blank = new Waybill();
blank.setId(CHILD_A);
blank.setWaybillNo("YD-20260920-0003");
blank.setLoadingNo(LOADING_NO);
blank.setBusinessStatus(null);
stubLoadedTrip(blank);
TripVO trip = onlyTrip();
assertEquals(1, trip.getStatus(), "读不出状态按没走完处理,不是终态也不抛异常");
assertEquals(1, trip.getWaybillCount());
}
@Test
@DisplayName("未配载运次的状态与接单需求取自段一取回的那张运单")
void loneTripReadsStateFromFetchedWaybill() {
Waybill bare = new Waybill();
bare.setId(CHILD_A);
bare.setWaybillNo("YD-20260920-0003");
bare.setBusinessStatus("pending");
bare.setDriverAcceptStatus("pending");
bare.setProcessJson(ACCEPT_YES);
bare.setDriverPhone(PHONE);
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare));
TripVO trip = onlyTrip();
assertEquals("2111350784070275123", trip.getId());
assertNull(trip.getLoadingNo());
assertEquals(19, trip.getId().length(), "19 位雪花号的末位不被取整");
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求取自回查到的真身");
assertEquals(0, trip.getStatus());
assertEquals(1, trip.getWaybillCount());
}
@Test
@DisplayName("运次主键按字符串下发:19 位雪花号的末位不被双精度取整")
void tripIdIsSentAsString() {
stubLoadedTrip(child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)));
TripVO trip = onlyTrip();
assertNotNull(trip.getId());
assertEquals("2111350784070275074", trip.getId());
assertEquals(19, trip.getId().length());
assertEquals("2111350784070275123", trip.getWaybills().get(0).getId());
assertEquals(19, trip.getWaybills().get(0).getId().length());
assertTrue(trip.getId() instanceof String);
}
@Test
@DisplayName("货物与合计重量按整趟车算:同名同单位合并,单位不一致时合计留空")
void cargoSummarySpansWholeTrip() {
Waybill first = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
first.setCargoName("石英砂A");
first.setQuantity(new BigDecimal("42.10"));
first.setQuantityUnit("吨");
Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
second.setCargoName("石英砂A");
second.setQuantity(new BigDecimal("47.57"));
second.setQuantityUnit("吨");
stubLoadedTrip(first, second);
TripVO trip = onlyTrip();
assertEquals("石英砂A 89.67吨", trip.getCargoSummary());
assertEquals(0, new BigDecimal("89.67").compareTo(trip.getTotalQuantity()));
assertEquals("吨", trip.getQuantityUnit());
}
@Test
@DisplayName("一张运单录了多条货物时,货名与汇总都要全带上,不能只取主表首行")
void multiCargoWaybillKeepsEveryCargo() {
Waybill multi = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
// 主表只落了首行:货名与单位都是第一行的,数量却是两行的合计
multi.setCargoName("阿什顿");
multi.setCargoType("服饰");
multi.setQuantity(new BigDecimal("70.00"));
multi.setQuantityUnit("堆");
multi.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"cargoType\":\"服饰\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"},"
+ "{\"cargoName\":\"显卡050\",\"cargoType\":\"显卡\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]");
stubLoadedTrip(multi);
TripVO trip = onlyTrip();
assertEquals(List.of("阿什顿", "显卡050"), trip.getCargoNames(), "两种货物都要在,不能只剩主表首行");
assertEquals(List.of("阿什顿", "显卡050"), trip.getWaybills().get(0).getCargoNames(), "子单也要带上完整货名");
assertEquals("阿什顿 20堆、显卡050 50方", trip.getCargoSummary());
assertNull(trip.getTotalQuantity(), "两种货物单位不同,不能把 20堆+50方 说成 70堆");
assertNull(trip.getQuantityUnit());
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")
private static Wrapper<Waybill> anyWaybillWrapper() {
return ArgumentMatchers.<Wrapper<Waybill>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<LoadingManage> anyLoadingWrapper() {
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
}
@SuppressWarnings("unchecked")
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
/** 配载运次:段一给一行分组行,段二给配载单与这一组的子单 */
private void stubLoadedTrip(Waybill... children) {
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading()));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(children));
}
private TripVO onlyTrip() {
return onlyTrip(TripQueryVO.forDriver(PHONE));
}
private TripVO onlyTrip(TripQueryVO query) {
IPage<TripVO> page = service.page(query);
assertEquals(1, page.getRecords().size(), "本页应只有一个运次");
return page.getRecords().get(0);
}
private TripQueryVO detailQuery() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setTripId(String.valueOf(LOADING_ID));
return query;
}
private LoadingManage loading() {
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setDriverPhone(PHONE);
loading.setDriverName("黄宇富");
loading.setVehicleNo("桂A32523");
loading.setProjectName("武宣建筑原料运输项目");
return loading;
}
private Waybill child(Long id, String businessStatus, String acceptStatus, String processJson, Date createTime) {
Waybill waybill = new Waybill();
waybill.setId(id);
waybill.setWaybillNo("YD-" + id);
waybill.setLoadingNo(LOADING_NO);
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setDriverPhone(PHONE);
waybill.setProcessJson(processJson);
waybill.setCreateTime(createTime);
return waybill;
}
private Date at(long millis) {
return new Date(millis);
}
}
@@ -0,0 +1,218 @@
package org.springblade.transport.service.impl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripStationVO;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 运次站点拼装:配载单路线信息 → 站点 ①②③,站点标记指向配载单运单集合里的第 N 张子运单。
* <p>
* 夹具用的是本地库实测的配载单数据(路线信息、运单集合顺序与四张子运单的货名/货主/数量),
* 单元测试里没有库,所以「第 N 张」只能按运单集合的位次断言。
*/
class TripStationAssemblerTest {
/** PZ-20261017-001 的路线信息:三站,标记分别是 装1 / 卸1,装2,装3,装4 / 卸2,卸3,卸4 */
private static final String ROUTE_JSON = "["
+ "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\","
+ "\"tags\":[\"装1\"],\"locked\":false},"
+ "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
+ "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
+ "\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"],\"locked\":false},"
+ "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
+ "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
+ "\"tags\":[\"卸2\",\"卸3\",\"卸4\"],\"locked\":false}]";
private static final String WAYBILL_IDS_JSON =
"[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]";
@Test
@DisplayName("路线信息成三站:第一站装货、第二站卸货并装货、第三站卸货,货物条目取子运单")
void stationsFollowRouteTags() {
LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON);
List<Waybill> children = List.of(
child("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00"),
child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"),
child("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"),
child("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17"));
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
assertEquals(3, stations.size());
assertEquals(List.of(1, 2, 3), stations.stream().map(TripStationVO::getIndex).toList());
TripStationVO first = stations.get(0);
assertEquals("广西壮族自治区南宁市南宁吴圩机场", first.getName(), "站名取展示地址");
assertEquals("装货", first.getOperationTypeLabel());
assertEquals("load", first.getOperationType());
assertEquals(List.of("装1"), first.getTags());
assertEquals(1, first.getCargos().size());
assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo());
assertEquals("技嘉4070显卡", first.getCargos().get(0).getCargoName());
assertEquals("9/20测试mk001", first.getCargos().get(0).getCustomerName());
assertEquals(0, new BigDecimal("30.00").compareTo(first.getCargos().get(0).getQuantity()));
assertEquals("load", first.getCargos().get(0).getOperation());
TripStationVO second = stations.get(1);
assertEquals("装卸", second.getOperationTypeLabel(), "同一站既卸又装");
assertEquals(4, second.getCargos().size(), "卸 1 张 + 装 3 张");
assertEquals("unload", second.getCargos().get(0).getOperation());
assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo());
assertEquals(List.of("YD-20260825-0006", "YD-20260825-0007", "YD-20260825-0008"),
second.getCargos().subList(1, 4).stream().map(TripStationVO.TripCargoVO::getWaybillNo).toList());
assertEquals(List.of("42.10", "47.57", "42.17"),
second.getCargos().subList(1, 4).stream()
.map(cargo -> cargo.getQuantity().toPlainString()).toList());
assertTrue(second.getCargos().subList(1, 4).stream()
.allMatch(cargo -> "load".equals(cargo.getOperation())));
TripStationVO third = stations.get(2);
assertEquals("卸货", third.getOperationTypeLabel());
assertEquals(3, third.getCargos().size());
assertTrue(third.getCargos().stream().allMatch(cargo -> "unload".equals(cargo.getOperation())));
assertEquals(first.getName(), TripStationAssembler.departureName(stations));
assertEquals(second.getName(), TripStationAssembler.transitName(stations), "中间站按顺序连接");
assertEquals(third.getName(), TripStationAssembler.arrivalName(stations));
}
@Test
@DisplayName("站点标记的第 N 张按配载单运单集合的位次算,被拒单看不到的子单只是缺一条明细")
void tagOrderFollowsWaybillIdsNotVisibleChildren() {
LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON);
// 第 1 张子单(2102780956493299713)在当前可见子单里不存在,但它仍占运单集合的第 1 位
List<Waybill> children = List.of(
child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"));
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
assertTrue(stations.get(0).getCargos().isEmpty(), "标记指向的子单不可见时该站没有明细");
assertEquals("load", stations.get(1).getCargos().get(0).getOperation());
assertEquals("YD-20260825-0006", stations.get(1).getCargos().get(0).getWaybillNo(),
"装2 仍指向运单集合的第 2 张");
assertEquals(1, stations.get(2).getCargos().size());
assertEquals("unload", stations.get(2).getCargos().get(0).getOperation());
assertEquals("YD-20260825-0006", stations.get(2).getCargos().get(0).getWaybillNo(),
"卸2 也指向运单集合的第 2 张");
}
@Test
@DisplayName("没有路线信息时回退子运单起讫点去重成站,保留首次出现顺序")
void fallsBackToWaybillPoints() {
LoadingManage loading = loading(null, WAYBILL_IDS_JSON);
List<Waybill> children = List.of(
points("2102786022868598785", "YD-20260923-0010", "广西壮族自治区南宁市宾阳县", "广西壮族自治区来宾市武宣县"),
points("2102786023208337409", "YD-20260923-0011", "广西壮族自治区来宾市武宣县", "福建省三明市三明沙县机场"),
points("2102786023485161474", "YD-20260923-0012", "福建省三明市三明沙县机场", "广西壮族自治区北海市合浦县白沙镇"));
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
assertEquals(List.of(
"广西壮族自治区南宁市宾阳县",
"广西壮族自治区来宾市武宣县",
"福建省三明市三明沙县机场",
"广西壮族自治区北海市合浦县白沙镇"),
stations.stream().map(TripStationVO::getName).toList());
assertEquals("装货", stations.get(0).getOperationTypeLabel());
assertEquals("装卸", stations.get(1).getOperationTypeLabel(), "上一张的终点=下一张的起点");
assertEquals("卸货", stations.get(3).getOperationTypeLabel());
assertEquals(2, stations.get(1).getCargos().size());
}
@Test
@DisplayName("起讫点没有名称时用地址,名称与地址都没有才退到配载单自己的地址")
void fallsBackToAddressThenLoadingAddress() {
LoadingManage loading = loading(null, WAYBILL_IDS_JSON);
loading.setDepartureAddress("配载单发货地址");
loading.setTransitAddress("配载单途经地址");
loading.setArrivalAddress("配载单到货地址");
List<Waybill> addressOnly = List.of(points("1", "YD-1", null, null));
List<TripStationVO> fromLoading = TripStationAssembler.assemble(loading, addressOnly);
assertEquals(List.of("配载单发货地址", "配载单途经地址", "配载单到货地址"),
fromLoading.stream().map(TripStationVO::getName).toList());
assertNull(fromLoading.get(0).getOperationType(), "退到配载单地址时无从判定站点操作");
assertTrue(fromLoading.get(0).getCargos().isEmpty());
}
@Test
@DisplayName("站点、起讫点、货物全为空时不抛异常,下发空站点数组")
void emptyEverythingStaysEmpty() {
List<TripStationVO> stations = TripStationAssembler.assemble(loading(null, null), List.of(points("1", "YD-1", null, null)));
assertTrue(stations.isEmpty());
assertEquals("", TripStationAssembler.departureName(stations));
assertEquals("", TripStationAssembler.transitName(stations));
assertEquals("", TripStationAssembler.arrivalName(stations));
assertEquals("", TripStationAssembler.transitName(TripStationAssembler.assemble(null, List.of())));
}
@Test
@DisplayName("未配载运单成两点:起点装货、终点卸货")
void unloadedWaybillBecomesTwoPoints() {
List<Waybill> children = List.of(points("2101508430981652482", "YD-20260920-0003", "南宁武鸣", "钦州港"));
List<TripStationVO> stations = TripStationAssembler.assemble(null, children);
assertEquals(2, stations.size());
assertEquals("南宁武鸣", stations.get(0).getName());
assertEquals("装货", stations.get(0).getOperationTypeLabel());
assertEquals("钦州港", stations.get(1).getName());
assertEquals("卸货", stations.get(1).getOperationTypeLabel());
assertNotNull(stations.get(0).getCargos());
}
@Test
@DisplayName("路线信息坏掉时按没有路线信息处理,不抛异常")
void brokenRouteJsonFallsBack() {
List<Waybill> children = List.of(points("1", "YD-1", "起点", "终点"));
List<TripStationVO> stations = TripStationAssembler.assemble(loading("{不是数组", WAYBILL_IDS_JSON), children);
assertEquals(2, stations.size());
assertEquals("起点", stations.get(0).getName());
}
private LoadingManage loading(String routeJson, String waybillIdsJson) {
LoadingManage loading = new LoadingManage();
loading.setRouteJson(routeJson);
loading.setWaybillIdsJson(waybillIdsJson);
return loading;
}
private Waybill child(String id, String waybillNo, String cargoName, String customerName, String quantity) {
Waybill waybill = new Waybill();
waybill.setId(Long.valueOf(id));
waybill.setWaybillNo(waybillNo);
waybill.setCargoName(cargoName);
waybill.setCustomerName(customerName);
waybill.setQuantity(new BigDecimal(quantity));
waybill.setQuantityUnit("吨");
waybill.setProjectName("武宣建筑原料运输项目");
return waybill;
}
/** 只有起讫点的子运单(名称与地址都取自参数) */
private Waybill points(String id, String waybillNo, String departure, String arrival) {
Waybill waybill = new Waybill();
waybill.setId(Long.valueOf(id));
waybill.setWaybillNo(waybillNo);
waybill.setDepartureName(departure);
waybill.setDepartureAddress(departure);
waybill.setArrivalName(arrival);
waybill.setArrivalAddress(arrival);
return waybill;
}
}
@@ -0,0 +1,229 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import java.lang.reflect.Field;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。
* <p>
* 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、
* 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。
*/
class WaybillConfirmReturnTest {
private static final Long WAYBILL_ID = 5701L;
private static final String DEPT_ID = "1123598813738675201";
private static final String RETURN_NEED_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
private static final String RETURN_NO_CONFIRM_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
""";
/** 带在途节点:在途是频次打卡节点、不是必经一步,没打不该拦住确认回单 */
private static final String WITH_TRANSIT_JSON = """
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
""";
private WaybillServiceImpl service;
private WaybillMapper waybillMapper;
private WaybillNodePunchMapper nodePunchMapper;
private ExceptionDisposalMapper exceptionDisposalMapper;
private ILoadingManageService loadingManageService;
private IReceivablePayableDetailService receivablePayableDetailService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
loadingManageService = mock(ILoadingManageService.class);
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
IProcessConfigService processConfigService = mock(IProcessConfigService.class);
when(processConfigService.list(org.mockito.ArgumentMatchers.<Wrapper<ProcessConfig>>any())).thenReturn(List.of());
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
inject("waybillNodePunchMapper", nodePunchMapper);
inject("waybillEnroutePunchMapper", mock(WaybillEnroutePunchMapper.class));
inject("exceptionDisposalMapper", exceptionDisposalMapper);
inject("processConfigService", processConfigService);
inject("loadingManageService", loadingManageService);
inject("receivablePayableDetailService", receivablePayableDetailService);
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
when(exceptionDisposalMapper.selectCount(any())).thenReturn(0L);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单")
void confirmsReturnAndCompletesWaybill() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load", "sign");
boolean result = service.confirmReturnReceipt(WAYBILL_ID);
assertEquals(true, result);
verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.<Waybill>argThat(waybill ->
"completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId())));
verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID));
verify(loadingManageService).completeIfAllWaybillsCompleted(any());
}
@Test
@DisplayName("在途节点没打卡也放行:它不是必经一步")
void transitNodeDoesNotBlockConfirmation() {
stubWaybill(WITH_TRANSIT_JSON);
stubPunches("load", "sign");
assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID));
}
@Test
@DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态")
void unfinishedPunchNodeBlocksConfirmation() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load");
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单还有未打卡的节点:签收", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
@Test
@DisplayName("有未了结异常:拒绝,不改状态")
void unresolvedExceptionBlocksConfirmation() {
stubWaybill(RETURN_NEED_CONFIRM_JSON);
stubPunches("load", "sign");
when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单存在未处理异常,处理完成后才能确认回单", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
@Test
@DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成")
void returnWithoutConfirmationIsRejected() {
stubWaybill(RETURN_NO_CONFIRM_JSON);
stubPunches("load", "sign");
ServiceException exception = assertThrows(ServiceException.class,
() -> service.confirmReturnReceipt(WAYBILL_ID));
assertEquals("该运单的回单不需要业务员确认", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
// ------------------------------------------------------------ 夹具
private void stubWaybill(String processJson) {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610180002");
waybill.setDeptId(Long.valueOf(DEPT_ID));
waybill.setProjectId(7001L);
waybill.setProcessJson(processJson);
waybill.setBusinessStatus("running");
waybill.setLoadingNo("PZ-20261018-001");
waybill.setIsDeleted(0);
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
}
private void stubPunches(String... nodeCodes) {
List<WaybillNodePunch> punches = java.util.Arrays.stream(nodeCodes).map(code -> {
WaybillNodePunch punch = new WaybillNodePunch();
punch.setWaybillId(WAYBILL_ID);
punch.setNodeCode(code);
return punch;
}).toList();
when(nodePunchMapper.selectList(any())).thenReturn(punches);
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
@SuppressWarnings("unused")
private static Wrapper<Waybill> anyWaybillWrapper() {
return org.mockito.ArgumentMatchers.any();
}
}
@@ -0,0 +1,171 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.support.LoadableAdmissionSupport;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 运单列表的可配载取数:开关一次带上整条判据,另支持按主键集合回查。
* <p>
* 候选列表与「公路配载」按钮预检都走这次查询,所以断言下在生成的 SQL 片段上——
* 开关不启用时默认列表不能被顺手改口径,那是全部运单入口共用的查询。
*/
class WaybillLoadableQueryTest {
private WaybillMapper waybillMapper;
private WaybillServiceImpl service;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
when(waybillMapper.selectPage(any(), any())).thenReturn(new Page<Waybill>(1, 10));
// 组织过滤只看当前用户;超级管理员不带组织条件,断言里就只剩本用例关心的条件
authUtil = mockStatic(AuthUtil.class);
when(AuthUtil.getUserRole()).thenReturn("administrator");
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("开关开启:公路词表、待执行、未配载一次全部进 wrapper,接单状态不参与")
void loadableSwitchAppliesWholePredicate() {
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(loadableQuery());
String sql = String.valueOf(wrapper.getTargetSql());
Collection<Object> params = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("transport_type"), "公路判定要落在运单自己的运输方式上:" + sql);
for (String keyword : LoadableAdmissionSupport.ROAD_TRANSPORT_KEYWORDS) {
assertTrue(params.contains("%" + keyword + "%"),
"公路词表要整份进条件,只写 road 会漏掉中文取值的运单:" + params);
}
assertTrue(sql.contains("business_status =") && params.contains("pending"),
"只放行待执行:" + sql + " " + params);
assertFalse(params.contains("running"),
"进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params);
assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="),
"未配载要同时覆盖 NULL 与空串:" + sql);
assertFalse(sql.contains("driver_accept_status"),
"接单状态不进可配载判据:拒单票照样可被重新配载:" + sql);
}
@Test
@DisplayName("开关开启 + 主键集合:收窄到所选运单,用于按钮预检")
void loadableSwitchNarrowsBySelectedIds() {
WaybillVO query = loadableQuery();
query.setIds(List.of(11L, 22L));
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
Collection<Object> params = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
assertTrue(params.contains(11L) && params.contains(22L), "所选运单主键要进条件:" + params);
assertTrue(sql.contains("transport_type"), "回查仍要带整条判据:" + sql);
}
@Test
@DisplayName("只传主键集合:按主键收窄,不附带可配载判据")
void idsNarrowWithoutTheSwitch() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
query.setIds(List.of(33L));
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
assertTrue(wrapper.getParamNameValuePairs().values().contains(33L), sql);
assertFalse(sql.contains("transport_type"), "没开可配载开关就不加公路条件:" + sql);
}
@Test
@DisplayName("开关未开启:运单管理默认列表条件与展示口径不变")
void withoutSwitchTheDefaultListIsUntouched() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
String sql = String.valueOf(wrapper.getTargetSql());
assertFalse(sql.contains("transport_type"), sql);
assertFalse(sql.contains("business_status"), sql);
assertFalse(sql.contains("loading_no"), sql);
assertFalse(sql.contains("driver_accept_status"), sql);
}
// ------------------------------------------------------------ 夹具
private WaybillVO loadableQuery() {
WaybillVO query = new WaybillVO();
query.setAllDept(1);
query.setLoadableOnly(1);
return query;
}
private LambdaQueryWrapper<Waybill> capturedQuery(WaybillVO query) {
IPage<WaybillVO> page = service.selectWaybillPage(new Page<>(1, 10), query);
assertTrue(page.getRecords().isEmpty());
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(waybillMapper).selectPage(any(), captor.capture());
return captor.getValue();
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("找不到字段:" + field);
}
}
@@ -0,0 +1,158 @@
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.ArgumentMatchers;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.service.IProcessConfigService;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 生效配置的取法与「生效配置 vs 快照」二选一的唯一实现处。
* <p>
* 两条规则必须分得清:接单需求与节点门禁读 {@code resolve}(生效配置是权威,不能因为快照里有节点被顶掉),
* 司机端打卡页读 {@code resolveForPunch}(生效配置被改坏成没有可打卡节点时回退快照,避免打卡页空白)。
* 排序由 SQL 负责(更新时间倒序、创建时间倒序),这里按「已排好序的列表」构造输入。
*/
class LiveProcessConfigSupportTest {
/** 有可打卡节点(装货,punch=是) */
private static final String PUNCH_JSON =
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
/** 一条可打卡节点都没有(装货被关掉 punch) */
private static final String NO_PUNCH_JSON =
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":false}]";
/** 另一个项目用的、与 PUNCH_JSON 可区分的一份 */
private static final String OTHER_PUNCH_JSON =
"[{\"key\":\"unload\",\"name\":\"卸货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
private IProcessConfigService processConfigService;
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
}
@BeforeEach
void setUp() {
processConfigService = mock(IProcessConfigService.class);
}
private void stubConfigs(ProcessConfig... configs) {
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(configs));
}
private static Wrapper<ProcessConfig> anyConfigWrapper() {
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
}
private static ProcessConfig config(String projectIds, String nodeConfigJson) {
ProcessConfig config = new ProcessConfig();
config.setProjectIds(projectIds);
config.setNodeConfigJson(nodeConfigJson);
return config;
}
@Test
@DisplayName("单项目:命中项目的第一条生效配置(列表已按更新时间倒序)")
void singleProjectTakesFirstHit() {
stubConfigs(config("999", OTHER_PUNCH_JSON), config("1234,5678", PUNCH_JSON));
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("单项目:项目 id 按逗号分段精确比对,1234 不命中 12345")
void singleProjectMatchesExactId() {
stubConfigs(config("12345", PUNCH_JSON));
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("单项目:没有生效配置、或项目为空时返回 null")
void singleProjectWithoutConfig() {
stubConfigs();
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
assertNull(LiveProcessConfigSupport.byProject(processConfigService, null));
assertNull(LiveProcessConfigSupport.byProject(null, 1234L));
}
@Test
@DisplayName("单项目:节点配置为空的配置跳过,取后面那条有内容的")
void singleProjectSkipsEmptyNodeJson() {
stubConfigs(config("1234", ""), config("1234", PUNCH_JSON));
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
}
@Test
@DisplayName("批量:一次查全后按项目分别命中,未命中的项目不进结果")
void batchByProject() {
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L, 4321L));
assertEquals(2, live.size());
assertEquals(PUNCH_JSON, live.get(1234L));
assertEquals(OTHER_PUNCH_JSON, live.get(5678L));
assertTrue(live.containsKey(1234L));
}
@Test
@DisplayName("resolve:生效配置优先,取不到才用快照")
void resolvePrefersLive() {
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolve(PUNCH_JSON, NO_PUNCH_JSON));
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolve(null, NO_PUNCH_JSON));
assertNull(LiveProcessConfigSupport.resolve(null, null));
}
@Test
@DisplayName("resolveForPunch:生效配置没有可打卡节点时才回退快照,两边都没有节点仍用生效配置")
void resolveForPunchFallsBackToSnapshot() {
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(PUNCH_JSON, NO_PUNCH_JSON),
"生效配置有可打卡节点:用生效配置");
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, PUNCH_JSON),
"生效配置没有可打卡节点、快照有:回退快照");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, NO_PUNCH_JSON),
"两边都没有可打卡节点:用生效配置");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(null, NO_PUNCH_JSON),
"项目没有生效配置:用快照");
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, null),
"快照为空:用生效配置");
}
@Test
@DisplayName("生效配置只查一次:批量取法与单项目取法都不逐条回查项目")
void configQueryIsInvokedOncePerCall() {
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L));
verify(processConfigService, times(1)).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
verify(processConfigService, never()).getById(any());
}
}
@@ -0,0 +1,116 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.entity.Waybill;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 可配载准入判据:公路词表与单张运单的准入检查。
* <p>
* 判据是读侧候选列表与写侧提交/编辑/重新派单校验共用的同一份定义。
*/
class LoadableAdmissionSupportTest {
// ------------------------------------------------------------ 公路词表
@Test
@DisplayName("公路:road 与中文同义写法都命中")
void roadVocabularyHits() {
assertTrue(LoadableAdmissionSupport.isRoadTransport("road"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("ROAD"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("道路运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("汽车运输"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("陆运"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("highway"));
assertTrue(LoadableAdmissionSupport.isRoadTransport("gl"));
}
@Test
@DisplayName("非公路与空值一律不命中——未知运输方式按非公路拒绝,不能放行")
void nonRoadVocabularyMisses() {
assertFalse(LoadableAdmissionSupport.isRoadTransport("railway"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("water"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("air"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("river"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("水路运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("铁路运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport("航空运输"));
assertFalse(LoadableAdmissionSupport.isRoadTransport(""));
assertFalse(LoadableAdmissionSupport.isRoadTransport(" "));
assertFalse(LoadableAdmissionSupport.isRoadTransport(null));
}
// ------------------------------------------------------------ 单张运单准入
@Test
@DisplayName("公路 + 待执行 + 未配载才可配载")
void loadableOnlyWhenAllConditionsHold() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"), null));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null), null));
}
@Test
@DisplayName("已拒单的票照样可配载:接单事实由配载提交清除,不靠把拒单票挡在候选之外")
void rejectedWaybillIsLoadable() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), null));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), "PZ20260101-001"));
}
@Test
@DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝")
void eachConditionRejectsOnItsOwn() {
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending"), null),
"非公路不可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null),
"进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null), null));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null), null));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null), null));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), null),
"已挂在其它配载单上的票不可再配载");
assertFalse(LoadableAdmissionSupport.isLoadable(null, null));
}
@Test
@DisplayName("不可配载原因按 公路 → 待执行 → 未关联其它配载单 取第一条")
void unloadableReasonFollowsPredicateOrder() {
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_ROAD,
LoadableAdmissionSupport.unloadableReason(
waybill("railway", "running", "PZ20260101-002", null), "PZ20260101-001"));
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_PENDING,
LoadableAdmissionSupport.unloadableReason(
waybill("road", "running", "PZ20260101-002", null), "PZ20260101-001"));
assertEquals(LoadableAdmissionSupport.UnloadableReason.ON_OTHER_MANIFEST,
LoadableAdmissionSupport.unloadableReason(
waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"));
assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "pending", null, null), null));
assertNull(LoadableAdmissionSupport.unloadableReason(
waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
}
@Test
@DisplayName("编辑/重新派单:loadingNo 等于本配载单号时不算冲突,其它配载单号仍拒绝")
void ownLoadingNoIsNotAConflict() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, null), "PZ20260101-001"));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"),
"挂在别的配载单上时即使正在编辑本单也不能合进来");
}
private Waybill waybill(String transportType, String businessStatus, String loadingNo, String driverAcceptStatus) {
Waybill waybill = new Waybill();
waybill.setTransportType(transportType);
waybill.setBusinessStatus(businessStatus);
waybill.setLoadingNo(loadingNo);
waybill.setDriverAcceptStatus(driverAcceptStatus);
return waybill;
}
}
@@ -124,6 +124,8 @@ class OcrFieldSupportTest {
{
"words_result": {
"姓名": {"words": "黄宇富"},
"出生": {"words": "1979年11月17日"},
"住址": {"words": "广西壮族自治区柳州市柳北区跃进路88号"},
"公民身份号码": {"words": "452730197911172318"},
"签发日期": {"words": "2015-03-12"},
"失效日期": {"words": "2035-03-12"}
@@ -133,6 +135,8 @@ class OcrFieldSupportTest {
Map<String, Object> person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard);
assertEquals("黄宇富", person.get("realName"));
assertEquals("452730197911172318", person.get("idCardNo"));
assertEquals("1979-11-17", person.get("birthday"), "「出生」是一串中文日期,要归一成 yyyy-MM-dd 才能填进日期框");
assertEquals("广西壮族自治区柳州市柳北区跃进路88号", person.get("address"));
assertEquals("2015-03-12", person.get("idCardValidStart"));
assertEquals("2035-03-12", person.get("idCardValidEnd"));
@@ -123,6 +123,24 @@ class WaybillProcessSupportTest {
"「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)");
}
@Test
@DisplayName("顺序门禁只点名最先未完成的那一个前置节点,不把没打的前置全列出来")
void namesOnlyTheEarliestUnfinishedPrerequisite() {
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
TRANSPORT_JSON, Collections.emptySet(), "arrival");
assertEquals(keys("arrive_scene", "load", "dispatch"), unfinished, "到货前面三个打卡节点都没打");
assertEquals("到场", WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, unfinished),
"只报司机现在能去打卡的那一个:装货、发货自己也还打不了,列出来不是可执行的下一步");
}
@Test
@DisplayName("没有未完成的前置节点时不点名")
void namesNothingWhenNothingIsUnfinished() {
assertEquals("",
WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, Collections.emptySet()));
}
@Test
@DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集")
void transitIsExemptFromOrderGate() {
@@ -0,0 +1,30 @@
-- 收票登记:删除冗余的 kingdee_bill_no / kingdee_status 两列
-- 这两列由收票登记旧版金蝶同步链路写入,同步功能已下线,代码中已无任何读写。
-- 收票登记的金蝶状态以 kingdee_push_status 为唯一状态源(未推送/已保存收票单/已指定应付/推送失败);
-- 金蝶单据号可经 kingdee_invoice_pool_id 关联 blade_kingdee_invoice_pool 取 serial_no,
-- 两列的取值集合也与新模型对不上(旧值 pushed / assigned 在新模型里不存在),故不搬运数据。
-- 基线建表脚本 blade_invoice_receipt_20260821.sql 仍声明这两列,
-- 而 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作,存量库只能靠 ALTER 收敛。
-- 以下语句可重复执行。
SET @ddl_sql = (
SELECT IF(COUNT(*) > 0,
'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_bill_no`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_bill_no'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 索引 idx_invoice_receipt_kingdee 建在 kingdee_status 上,随该列一并删除,无需单独 DROP INDEX
SET @ddl_sql = (
SELECT IF(COUNT(*) > 0,
'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_status`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_status'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
@@ -1,42 +0,0 @@
-- 收票登记:补金蝶单据字段
-- 已有环境里的 blade_invoice_receipt 由更早一版的建表语句创建,缺 kingdee_bill_no /
-- kingdee_status;建表语句用的是 CREATE TABLE IF NOT EXISTS,对已存在的表是空操作,
-- 所以只跑建表脚本补不上这两列,必须走 ALTER。
-- 以下语句可重复执行。
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据编号'' AFTER `current_processor`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_bill_no'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 存量行取默认值 unsynced;收票记录只有 synced / failed / unsynced 三种取值
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_status` varchar(30) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''金蝶同步状态:unsynced未同步、synced已同步、failed同步失败'' AFTER `kingdee_bill_no`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_status'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_invoice_receipt` ADD KEY `idx_invoice_receipt_kingdee` (`kingdee_status`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND INDEX_NAME = 'idx_invoice_receipt_kingdee'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- 旧列 kingdee_received_invoice_no / kingdee_received_invoice_id / kingdee_push_status /
-- kingdee_push_msg / kingdee_push_time 在代码里已无任何引用,且取值集合与新模型对不上
-- (旧值 pushed / assigned 在新模型里不存在),故不搬运数据,保持原样不动。
@@ -0,0 +1,61 @@
-- ----------------------------
-- 调度端运单列表按组织过滤:dept_id 缺失的存量运单订正回它所属的组织
-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 2 节校验。可重复执行。
-- ----------------------------
--
-- 为什么要订正:
-- 调度端(小程序管理端)运单列表的可见范围是「本组织」,超管看全租户。
-- dept_id 为 NULL 或 -1 的运单不属于任何组织,普通用户再也看不到它们。
-- 本机库实测 42 张:34 张的 dept_id = -1、8 张为 NULL,全部是 2026-10-15 / 10-16
-- 由 admin 在 1123598813738675201 下建的(-1 是客户端把「未选组织」当 -1 传上来的哨兵值,
-- 服务端写入前的兜底只判空、拦不住负数)。
--
-- 处置口径:取该运单的 create_dept(创建人所属组织)作为它的组织。
-- 42 张的 create_dept 都是 1123598813738675201,与它们的业务归属一致;
-- 按 create_dept 取而不是写死这个 id,其它环境执行时同样成立。
-- dept_name 取组织表当前的名字:新建运单写的就是这个名字,不跟随存量行里的历史短名
-- (「桂物物流集团」这类短名是历史写法,本次只补缺失值,不复位已存在的值)。
-- 只动 dept_id 为空或非正数的行,且要求 create_dept 为正数:重复执行不再改动任何行。
-- update_time 不在 SET 里,保留原值。
-- ----------------------------
-- 0. 备份(必须在订正之前执行,仅首次需要)
-- ----------------------------
-- CREATE TABLE blade_waybill_dept_bak AS
-- SELECT id, waybill_no, dept_id, dept_name, create_dept, update_time
-- FROM blade_waybill
-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0);
START TRANSACTION;
-- ----------------------------
-- 1. 订正组织
-- ----------------------------
UPDATE `blade_waybill`
SET `dept_id` = `create_dept`,
`dept_name` = (SELECT `dept_name` FROM `blade_dept` WHERE `id` = `blade_waybill`.`create_dept` LIMIT 1)
WHERE `is_deleted` = 0
AND (`dept_id` IS NULL OR `dept_id` <= 0)
AND `create_dept` IS NOT NULL
AND `create_dept` > 0;
COMMIT;
-- ----------------------------
-- 2. 校验(执行后必看,两个都必须返回 0)
-- ----------------------------
-- -- 仍不属于任何组织的可见运单
-- SELECT COUNT(*) FROM blade_waybill
-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0) AND create_dept > 0;
--
-- -- 本次订正过的行,组织 ID 与组织名都必须跟组织表一致
-- SELECT COUNT(*) FROM blade_waybill w
-- JOIN blade_waybill_dept_bak b ON b.id = w.id
-- JOIN blade_dept d ON d.id = w.dept_id
-- WHERE w.dept_id <> w.create_dept OR w.dept_name <> d.dept_name;
-- ----------------------------
-- 3. 回退(订正后发现问题时执行)
-- ----------------------------
-- UPDATE blade_waybill w
-- JOIN blade_waybill_dept_bak b ON b.id = w.id
-- SET w.dept_id = b.dept_id, w.dept_name = b.dept_name;