diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java index 323db157..cf2c7652 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java @@ -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 routePoints; + @Schema(description = "站点任务安排(详情返回,按实际途经顺序,第 1 站即 ①;有配载时含每站装卸与货物条目)") + private List stations; + @Schema(description = "过程配置 JSON(详情返回,供前端兜底推导打卡节点)") private String processJson; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java new file mode 100644 index 00000000..7b795898 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java @@ -0,0 +1,105 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 运次站点(任务安排) + *

+ * 有配载单时按配载单的路线信息给出实际途经顺序,第 1 站即 ①;站点上的「装N / 卸N」指向配载单运单集合里的第 N 张子运单, + * 同一站可以既卸又装(装卸)。没有路线信息时回退到子运单的起讫点去重成站。 + *

+ * 站点上的货物条目取子运单,不取配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。 + */ +@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 tags; + + @Schema(description = "操作类型:load 装货 / unload 卸货 / load_unload 装卸,无从判定时为空") + private String operationType; + + @Schema(description = "操作类型文案:装货 / 卸货 / 装卸") + private String operationTypeLabel; + + @Schema(description = "该站涉及的货物条目") + private List 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; + + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java index ed84cc0a..9e52e08d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java @@ -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 stations; + @Schema(description = "子运单(可见子单,按创建时间升序)") private List waybills; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index f966601d..bb9b4693 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -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 tripCurrentTask() { + return R.data(driverWaybillService.tripCurrentTask()); + } + + @GetMapping("/task/pending-preview") + @ApiOperationSupport(order = 22) + @Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)") + public R 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 tripCounts() { + return R.data(driverWaybillService.tripTabCounts()); + } + + @GetMapping("/task/page") + @ApiOperationSupport(order = 24) + @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌") + public R> 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 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)") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index 8f9cd52d..9ad5f543 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -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 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 轨迹。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 57c8ceb4..3c598aa2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -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 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 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 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 trips = tripQueryService.page(query); + Page 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 children = tripChildren(tripId); + assertTripAcceptable(children); + List pendingIds = writeCandidateIds(children, "当前运单状态不允许接单"); + Date now = new Date(); + return waybillService.update(Wrappers.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 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 pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单"); + Date now = new Date(); + // 整单拒绝落在每张未终态子运单上,一次一条语句写完,接单时刻与拒单时刻全趟车一致 + return waybillService.update(Wrappers.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)); + } + + /** + * 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单), + * 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。 + *

+ * 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。 + */ + private List tripChildren(String tripId) { + TripQueryVO query = TripQueryVO.forDriver(currentUserPhone()); + query.setTripId(tripId); + TripVO trip = tripQueryService.detail(query); + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + if (children.isEmpty()) { + throw new ServiceException("运次不存在或无权操作"); + } + return children; + } + + /** + * 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单 + */ + private void assertTripAcceptable(List 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 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 writeCandidateIds(List children, String emptyMessage) { + List 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); } + /** + * 运次卡片 + *

+ * 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在), + * 只是状态按运次口径只读推导,不走会落库的接单态归一化。 + * 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空—— + * 一趟车里每张子单各有各的项目配置与打卡节点。 + */ + 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 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 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 toTripRoutePoints(List 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 ""; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java index 32fc9d13..75bb8989 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java @@ -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++; } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java index d1f7bd2c..5a0888ad 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -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 children = loadChildren(Set.of(loading.getLoadingNo())) + List 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 { /** * 把这一页的运次行组装成运次:先批量取配载单与子运单,再逐行拼装 + *

+ * 页里的行只带分组键与排序键,子运单要按主键/配载单号回查一次:未配载的运次就是那张运单本身, + * 状态、接单需求与货物都得从那行真身来,不能拿分组行凑。 */ private List assemble(List 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 loneIds = rows.stream() + .filter(row -> Func.isEmpty(row.getLoadingNo())) + .map(Waybill::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); Map loadings = loadLoadings(loadingNos); - Map> children = loadChildren(loadingNos); + Map> children = loadChildren(loadingNos, loneIds); + Map loneWaybills = new LinkedHashMap<>(); + children.values().forEach(group -> group.forEach(child -> loneWaybills.put(child.getId(), child))); List trips = new ArrayList<>(rows.size()); for (Waybill row : rows) { String loadingNo = Func.toStr(row.getLoadingNo(), ""); if (Func.isEmpty(loadingNo)) { - trips.add(waybillTrip(row, false)); + 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 { } /** - * 段二:按本页运次的配载单号一次取回子运单,不逐条查 + * 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查 *

* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系, * 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。 * 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。 */ - private Map> loadChildren(Set loadingNos) { - if (loadingNos.isEmpty()) { + private Map> loadChildren(Set loadingNos, Set loneIds) { + if (loadingNos.isEmpty() && loneIds.isEmpty()) { return Map.of(); } List children = waybillService.list(Wrappers.lambdaQuery() - .in(Waybill::getLoadingNo, loadingNos) .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) - .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))); + .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> 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 children, boolean withPayload) { + List 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 children) { + Map> grouped = new LinkedHashMap<>(); + for (Waybill child : children) { + String name = Func.toStrWithEmpty(child.getCargoName(), "货物"); + String unit = Func.toStr(child.getQuantityUnit(), ""); + Map byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>()); + BigDecimal quantity = child.getQuantity() == null ? BigDecimal.ZERO : child.getQuantity(); + byUnit.merge(unit, quantity, BigDecimal::add); + } + List 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 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())); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java new file mode 100644 index 00000000..62735c22 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java @@ -0,0 +1,342 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import org.springblade.core.tool.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; + +/** + * 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来 + *

+ * 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。 + * 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址; + * 都没有则给空站点数组,前端按空站点渲染而不是崩。 + *

+ * 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。 + */ +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 assemble(LoadingManage loading, List children) { + List 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 stations) { + return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), ""); + } + + /** + * 终点名称:末站站名,没有站点时为空 + */ + static String arrivalName(List stations) { + return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), ""); + } + + /** + * 途经站点:中间各站按顺序连接,不足三站为空 + */ + static String transitName(List stations) { + if (stations.size() < 3) { + return ""; + } + List 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 fromRoute(LoadingManage loading, List children) { + if (loading == null) { + return new ArrayList<>(); + } + List> route = objectList(loading.getRouteJson()); + if (route.isEmpty()) { + return new ArrayList<>(); + } + List waybillIds = stringList(loading.getWaybillIdsJson()); + Map byId = new LinkedHashMap<>(); + for (Waybill child : children) { + if (child.getId() != null) { + byId.put(String.valueOf(child.getId()), child); + } + } + List stations = new ArrayList<>(); + for (Map 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 tags = stringList(element.get("tags")); + station.setTags(tags); + List 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 fromWaybillPoints(List children) { + Map stations = new LinkedHashMap<>(); + Map 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 stations, Map 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 fromLoadingAddresses(LoadingManage loading) { + List 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 waybillIds, Map 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 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> objectList(String json) { + if (Func.isEmpty(json)) { + return new ArrayList<>(); + } + try { + Object parsed = JsonUtil.parse(json, List.class); + if (parsed instanceof List list) { + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + Map 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 stringList(Object value) { + List 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(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index 12d00939..703070cb 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -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()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index ed1e5495..67c127fb 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -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); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java new file mode 100644 index 00000000..588e05fe --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -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; + +/** + * 运次级接单与拒单:一次写完整趟车、写不上已完成/已取消的子单、司机字段一概不动。 + *

+ * 运次的解析与收口在运次读服务里,这里以 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 runAsDriver(Supplier action) { + try (MockedStatic 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 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 captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).detail(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private LambdaUpdateWrapper capturedUpdate() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).update(captor.capture()); + return (LambdaUpdateWrapper) captor.getValue(); + } + + private String capturedWhereSql() { + return capturedUpdate().getTargetSql(); + } + + /** SET 子句里真正被写的列名:司机字段是否被碰,看这张清单最直白 */ + private List updatedColumns() { + return java.util.Arrays.stream(capturedUpdate().getSqlSet().split(",")) + .map(part -> part.substring(0, part.indexOf('=')).trim()) + .toList(); + } + + /** SET 与 WHERE 的参数都是渲染时才进参数表的,取值前先把两段都渲染一遍 */ + private Collection capturedUpdateParams() { + LambdaUpdateWrapper update = capturedUpdate(); + update.getSqlSet(); + update.getTargetSql(); + return update.getParamNameValuePairs().values(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java new file mode 100644 index 00000000..612bb192 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -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; + +/** + * 司机端运次接口:五个入口切运次口径后的卡片与分页,以及未配载运次照运单详情的只读下发。 + *

+ * 运次侧的取数与聚合在运次服务里,这里只验司机端的映射与收口;运次服务以 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 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 trips = new Page<>(1, 10, 71); + trips.setRecords(List.of(loadingTrip())); + when(tripQueryService.page(any())).thenReturn(trips); + + IPage 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 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 empty = new Page<>(1, 1, 0); + when(tripQueryService.page(any())).thenReturn(empty); + assertNull(runAsDriver(() -> service.tripCurrentTask())); + } + + @Test + @DisplayName("首页待接预览按运次计,总数是待接运次数") + void tripPendingPreviewCountsTrips() { + Page 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 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 anyWrapper() { + return org.mockito.ArgumentMatchers.>any(); + } + + private T runAsDriver(Supplier action) { + try (MockedStatic 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 page = new Page<>(1, 10, total); + page.setRecords(List.of(trips)); + when(tripQueryService.page(any())).thenReturn(page); + } + + private TripQueryVO capturedQuery() { + ArgumentCaptor 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 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 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; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index 91ecf020..e83ef7ba 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -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)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 86033d39..35e8dc58 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -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()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index 483ed637..83e8475e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -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 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 035a2db1..a96f086f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -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 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 10f6ccbb..6fb9e1c5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -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; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java index 7473bd21..b2d4ad45 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java @@ -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 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 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); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java new file mode 100644 index 00000000..4df658c9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java @@ -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 张子运单。 + *

+ * 夹具用的是本地库实测的配载单数据(路线信息、运单集合顺序与四张子运单的货名/货主/数量), + * 单元测试里没有库,所以「第 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 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 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 children = List.of( + child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10")); + + List 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 children = List.of( + points("2102786022868598785", "YD-20260923-0010", "广西壮族自治区南宁市宾阳县", "广西壮族自治区来宾市武宣县"), + points("2102786023208337409", "YD-20260923-0011", "广西壮族自治区来宾市武宣县", "福建省三明市三明沙县机场"), + points("2102786023485161474", "YD-20260923-0012", "福建省三明市三明沙县机场", "广西壮族自治区北海市合浦县白沙镇")); + + List 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 addressOnly = List.of(points("1", "YD-1", null, null)); + + List 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 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 children = List.of(points("2101508430981652482", "YD-20260920-0003", "南宁武鸣", "钦州港")); + + List 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 children = List.of(points("1", "YD-1", "起点", "终点")); + + List 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; + } + +}