✨ 司机端小程序接口按运次下发列表与详情,并支持整单接单与拒单

新增 /waybill/task/{current,pending-preview,counts,page,detail,accept,reject}:
列表一页若干个运次,详情按是否配载单下发整趟车站点(route_json 顺序,
装N/卸N 指向配载单运单集合里的第 N 张子运单,每站货物取子运单);
一次接单把该运次未走完的子运单一起置为已接单,一次拒单整单拒绝。
旧的运单级端点与写入口语义不变。
This commit is contained in:
2026-10-17 18:13:58 +08:00
parent 9a43863c09
commit 1de48db233
20 changed files with 2139 additions and 20 deletions
@@ -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,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;
}
}
@@ -28,6 +28,7 @@ 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;
@@ -107,12 +108,45 @@ public class TripVO implements Serializable {
@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;
@@ -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)")
@@ -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>
@@ -59,10 +59,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 +135,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 +148,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 +262,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 +642,118 @@ 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, "当前运单状态不允许接单");
Date now = new Date();
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)
.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();
// 整单拒绝落在每张未终态子运单上,一次一条语句写完,接单时刻与拒单时刻全趟车一致
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)
.in(Waybill::getId, pendingIds));
}
/**
* 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单),
* 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。
* <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);
}
@@ -828,6 +1025,126 @@ 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 (Func.isNotEmpty(card.getLoadingNo())) {
card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate()));
card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate()));
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());
// 货物名可能是一格里的多个名字,按既有拆分口径摊平后再去重
List<String> cargoNames = children.stream()
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
.distinct()
.collect(Collectors.toList());
card.setCargoNames(cargoNames);
card.setCargoName(String.join("、", cargoNames));
card.setCargoCategory(children.stream()
.map(TripVO.TripWaybillVO::getCargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("、")));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = trip.getTotalQuantity() == null
? card.getCargoSummary()
: 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 "";
@@ -135,13 +135,13 @@ final class TripAggregation {
for (Waybill child : children) {
String status = child == null ? null : child.getBusinessStatus();
total++;
if (TERMINAL_STATUSES.contains(status)) {
if (status != null && TERMINAL_STATUSES.contains(status)) {
terminal++;
}
if (COMPLETED.equals(status)) {
completed++;
}
if (PENDING_STATUSES.contains(status)) {
if (status != null && PENDING_STATUSES.contains(status)) {
pending++;
}
}
@@ -32,6 +32,7 @@ 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;
@@ -40,6 +41,7 @@ import org.springblade.transport.service.IWaybillService;
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;
@@ -129,7 +131,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
LoadingManage loading = visibleLoading(id, condition);
if (loading != null) {
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()))
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
.getOrDefault(loading.getLoadingNo(), List.of());
if (children.isEmpty()) {
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
@@ -218,6 +220,9 @@ public class TripQueryServiceImpl implements ITripQueryService {
/**
* 把这一页的运次行组装成运次:先批量取配载单与子运单,再逐行拼装
* <p>
* 页里的行只带分组键与排序键,子运单要按主键/配载单号回查一次:未配载的运次就是那张运单本身,
* 状态、接单需求与货物都得从那行真身来,不能拿分组行凑。
*/
private List<TripVO> assemble(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
@@ -227,13 +232,25 @@ public class TripQueryServiceImpl implements ITripQueryService {
.map(Waybill::getLoadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = rows.stream()
.filter(row -> Func.isEmpty(row.getLoadingNo()))
.map(Waybill::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos, loneIds);
Map<Long, Waybill> loneWaybills = new LinkedHashMap<>();
children.values().forEach(group -> group.forEach(child -> loneWaybills.put(child.getId(), child)));
List<TripVO> trips = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
if (Func.isEmpty(loadingNo)) {
trips.add(waybillTrip(row, false));
Waybill waybill = loneWaybills.get(row.getId());
if (waybill == null) {
// 运单在两次查询之间被删或被拒单:这一页不给出没有子单的运次
continue;
}
trips.add(waybillTrip(waybill, false));
continue;
}
LoadingManage loading = loadings.get(loadingNo);
@@ -265,24 +282,26 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 段二:按本页运次的配载单号一次取回子运单,不逐条查
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
* <p>
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
*/
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
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::getLoadingNo, loadingNos)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)));
.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) {
grouped.computeIfAbsent(child.getLoadingNo(), key -> new ArrayList<>()).add(child);
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()))));
@@ -310,11 +329,15 @@ public class TripQueryServiceImpl implements ITripQueryService {
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;
}
@@ -336,11 +359,13 @@ public class TripQueryServiceImpl implements ITripQueryService {
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
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;
}
@@ -359,6 +384,70 @@ public class TripQueryServiceImpl implements ITripQueryService {
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);
}
trip.setCargoSummary(cargoSummary(children));
fillTotalQuantity(trip, children);
}
/**
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
*/
private String cargoSummary(List<Waybill> children) {
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
String name = Func.toStrWithEmpty(child.getCargoName(), "货物");
String unit = Func.toStr(child.getQuantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = child.getQuantity() == null ? BigDecimal.ZERO : child.getQuantity();
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);
}
/**
* 合计重量:只有全部子运单同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
*/
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (Waybill child : children) {
String childUnit = Func.toStr(child.getQuantityUnit(), "");
if (child.getQuantity() == null || Func.isEmpty(childUnit)) {
return;
}
if (unit == null) {
unit = childUnit;
} else if (!unit.equals(childUnit)) {
return;
}
total = total.add(child.getQuantity());
}
if (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()));
@@ -0,0 +1,342 @@
/**
* 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 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));
}
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());
boolean exists = cargos.stream().anyMatch(cargo ->
Objects.equals(cargo.getWaybillId(), waybillId) && Objects.equals(cargo.getOperation(), operation));
if (exists) {
return;
}
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName(child.getCargoName());
cargo.setCustomerName(child.getCustomerName());
cargo.setQuantity(child.getQuantity());
cargo.setQuantityUnit(child.getQuantityUnit());
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();
}
}
@@ -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());
@@ -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,371 @@
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.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.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.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"), 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("任一子单已接单 → 整单已接单,不再重复写")
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"), 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);
return action.get();
}
}
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();
}
/** SET 与 WHERE 的参数都是渲染时才进参数表的,取值前先把两段都渲染一遍 */
private Collection<Object> capturedUpdateParams() {
LambdaUpdateWrapper<Waybill> update = capturedUpdate();
update.getSqlSet();
update.getTargetSql();
return update.getParamNameValuePairs().values();
}
}
@@ -0,0 +1,466 @@
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());
}
@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.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;
@@ -132,7 +132,10 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式")
void unloadedWaybillIsItsOwnTrip() {
stubPage(71, waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null));
// 页里的分组行只投影出主键与分组键,子运单按主键回查后才是真身
stubPage(71, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper()))
.thenReturn(List.of(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, null)));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
@@ -150,6 +153,33 @@ class TripQueryServiceImplTest {
assertEquals("2110690671695380513", trip.getWaybills().get(0).getId());
}
@Test
@DisplayName("未配载运次的状态与接单需求取回查到的运单真身,分组行里没有状态列")
void unloadedTripStateComesFromFetchedWaybill() {
stubPage(1, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", null, ACCEPT_YES)));
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 loneWaybillMissingInSecondQueryIsDropped() {
stubPage(1, waybill(WAYBILL_ID, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
assertTrue(page.getRecords().isEmpty());
}
@Test
@DisplayName("配载运次:主键取配载单主键,子单一次批量取回,状态与排序键按子单聚合")
void loadingTripAggregatesChildren() {
@@ -234,9 +264,13 @@ class TripQueryServiceImplTest {
@DisplayName("分页即分运次:总数与顺序照库层给的来,服务端不重排")
void pagingIsPerTrip() {
stubPage(71,
waybill(3L, null, null, null, null, null),
waybill(2L, null, null, null, null, null),
waybill(1L, null, null, null, null, null));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
waybill(3L, "YD-3", null, "running", null, null),
waybill(2L, "YD-2", null, "completed", null, null),
waybill(1L, "YD-1", null, "running", null, null));
waybill(1L, "YD-1", null, "running", null, null)));
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setCurrent(2);
query.setSize(10);
@@ -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;
}
}