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

新增 /waybill/task/{current,pending-preview,counts,page,detail,accept,reject}:
列表一页若干个运次,详情按是否配载单下发整趟车站点(route_json 顺序,
装N/卸N 指向配载单运单集合里的第 N 张子运单,每站货物取子运单);
一次接单把该运次未走完的子运单一起置为已接单,一次拒单整单拒绝。
旧的运单级端点与写入口语义不变。
This commit is contained in:
2026-10-17 18:13:58 +08:00
parent 9a43863c09
commit 1de48db233
20 changed files with 2139 additions and 20 deletions
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.detail(id));
}
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
public R<DriverWaybillCardVO> tripCurrentTask() {
return R.data(driverWaybillService.tripCurrentTask());
}
@GetMapping("/task/pending-preview")
@ApiOperationSupport(order = 22)
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
public R<DriverWaybillPreviewVO> tripPendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.tripPendingPreview(size));
}
@GetMapping("/task/counts")
@ApiOperationSupport(order = 23)
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
public R<DriverWaybillTabCountsVO> tripCounts() {
return R.data(driverWaybillService.tripTabCounts());
}
@GetMapping("/task/page")
@ApiOperationSupport(order = 24)
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
public R<IPage<DriverWaybillCardVO>> tripPage(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
}
@GetMapping("/task/detail")
@ApiOperationSupport(order = 25)
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
public R<DriverWaybillCardVO> tripDetail(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.data(driverWaybillService.tripDetail(id));
}
@PostMapping("/task/accept")
@ApiOperationSupport(order = 26)
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
public R tripAccept(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.status(driverWaybillService.tripAccept(id));
}
@PostMapping("/task/reject")
@ApiOperationSupport(order = 27)
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
public R tripReject(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
return R.status(driverWaybillService.tripReject(id, reason));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
*/
DriverWaybillCardVO detail(Long id);
/**
* 当前登录司机的运输中运次(一趟车,最多一条)
*/
DriverWaybillCardVO tripCurrentTask();
/**
* 当前登录司机的待接运次预览
*
* @param size 预览条数,默认 2
*/
DriverWaybillPreviewVO tripPendingPreview(Integer size);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
*/
DriverWaybillTabCountsVO tripTabCounts();
/**
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
*
* @param current 当前页,从 1 开始
* @param size 每页条数(按运次,默认 10)
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
*/
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
/**
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
*/
DriverWaybillCardVO tripDetail(String tripId);
/**
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
*
* @param tripId 运次主键(配载单主键或运单主键)
*/
boolean tripAccept(String tripId);
/**
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
*
* @param tripId 运次主键(配载单主键或运单主键)
* @param reason 拒绝原因,可空,最长 200 字
*/
boolean tripReject(String tripId, String reason);
/**
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
* <p>
@@ -59,10 +59,15 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
@@ -130,6 +135,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
/** 被异常阻断时的不可打卡原因(司机端展示) */
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
/** 运次状态码:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消,与运单侧同一套 */
private static final int TRIP_STATUS_PENDING = 0;
private static final int TRIP_STATUS_RUNNING = 1;
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IMpUserClient mpUserClient;
@@ -139,6 +148,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final WaybillEnroutePunchMapper enroutePunchMapper;
private final WaybillNodePunchMapper nodePunchMapper;
private final ExceptionDisposalMapper exceptionDisposalMapper;
private final ITripQueryService tripQueryService;
@Override
public DriverWaybillCardVO currentTask() {
@@ -252,6 +262,81 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO tripCurrentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return null;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(TRIP_STATUS_RUNNING);
query.setCurrent(1);
query.setSize(1);
IPage<TripVO> page = tripQueryService.page(query);
return page.getRecords().isEmpty() ? null : toTripCard(page.getRecords().get(0), false);
}
@Override
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return preview;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(TRIP_STATUS_PENDING);
query.setCurrent(1);
query.setSize(normalizePreviewSize(size));
IPage<TripVO> page = tripQueryService.page(query);
preview.setTotal(page.getTotal());
preview.setRecords(page.getRecords().stream()
.map(trip -> toTripCard(trip, false))
.collect(Collectors.toList()));
return preview;
}
@Override
public DriverWaybillTabCountsVO tripTabCounts() {
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return counts;
}
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
counts.setPending(tripCounts.getPending());
counts.setDoing(tripCounts.getDoing());
counts.setDone(tripCounts.getDone());
counts.setAll(tripCounts.getAll());
return counts;
}
@Override
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
return new Page<>(normalizePageNo(current), normalizePageSize(size));
}
TripQueryVO query = TripQueryVO.forDriver(phone);
query.setStatus(status);
query.setKeyword(keyword);
query.setCurrent(current);
query.setSize(size);
IPage<TripVO> trips = tripQueryService.page(query);
Page<DriverWaybillCardVO> page = new Page<>(trips.getCurrent(), trips.getSize(), trips.getTotal());
page.setRecords(trips.getRecords().stream()
.map(trip -> toTripCard(trip, false))
.collect(Collectors.toList()));
return page;
}
@Override
public DriverWaybillCardVO tripDetail(String tripId) {
requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
query.setTripId(tripId);
return toTripCard(tripQueryService.detail(query), true);
}
@Override
public WaybillTrackVO track(Long id) {
requireCurrentDriver();
@@ -557,6 +642,118 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tripAccept(String tripId) {
Driver driver = requireCurrentDriver();
// 认证门槛按整车一次过:一次接单只校验一次司机与车辆
assertCertified(driver);
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
assertTripAcceptable(children);
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许接单");
Date now = new Date();
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)
.set(Waybill::getDriverAcceptTime, now)
.set(Waybill::getDriverAcceptDriverId, driver.getId())
.set(Waybill::getDriverRejectTime, null)
.set(Waybill::getDriverRejectReason, null)
.set(Waybill::getBusinessStatus, STATUS_RUNNING)
.in(Waybill::getId, pendingIds));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tripReject(String tripId, String reason) {
requireCurrentDriver();
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
assertTripRejectable(children);
String rejectReason = Func.isEmpty(reason) ? null : reason.trim();
if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) {
throw new ServiceException("拒绝原因不能超过200字");
}
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单");
Date now = new Date();
// 整单拒绝落在每张未终态子运单上,一次一条语句写完,接单时刻与拒单时刻全趟车一致
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
.set(Waybill::getDriverAcceptTime, null)
.set(Waybill::getDriverAcceptDriverId, null)
.set(Waybill::getDriverRejectTime, now)
.set(Waybill::getDriverRejectReason, rejectReason)
.set(Waybill::getBusinessStatus, STATUS_PENDING)
.in(Waybill::getId, pendingIds));
}
/**
* 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单),
* 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。
* <p>
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
*/
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
query.setTripId(tripId);
TripVO trip = tripQueryService.detail(query);
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
if (children.isEmpty()) {
throw new ServiceException("运次不存在或无权操作");
}
return children;
}
/**
* 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单
*/
private void assertTripAcceptable(List<TripVO.TripWaybillVO> children) {
if (children.stream().allMatch(this::isTerminalChild)) {
throw new ServiceException("当前运单状态不允许接单");
}
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
throw new ServiceException("该运单无需确认接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
throw new ServiceException("该运单已接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isRejected(child.getAcceptStatus()))) {
throw new ServiceException("该运单已拒单,请联系调度重新派单");
}
}
/**
* 拒单校验按整车:有子单没走完才可拒绝,任一子单已接单则整单不能拒绝
*/
private void assertTripRejectable(List<TripVO.TripWaybillVO> children) {
if (children.stream().allMatch(this::isTerminalChild)) {
throw new ServiceException("当前运单状态不允许拒绝接单");
}
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
throw new ServiceException("该运单无需确认接单");
}
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
throw new ServiceException("该运单已接单,无法拒绝");
}
}
/**
* 可写入的子运单:已走完的子单不动——把已完成/已取消的子单改回运输中或待执行等于回退历史状态
*/
private List<Long> writeCandidateIds(List<TripVO.TripWaybillVO> children, String emptyMessage) {
List<Long> ids = children.stream()
.filter(child -> !isTerminalChild(child))
.map(child -> parseId(child.getId()))
.filter(Objects::nonNull)
.toList();
if (ids.isEmpty()) {
throw new ServiceException(emptyMessage);
}
return ids;
}
private boolean isTerminalChild(TripVO.TripWaybillVO child) {
return WaybillProcessSupport.isTerminalBusinessStatus(child.getBusinessStatus());
}
private Waybill loadDriverWaybill(Long id) {
return loadDriverWaybill(id, false);
}
@@ -828,6 +1025,126 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return List.of(load, unload);
}
/**
* 运次卡片
* <p>
* 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在),
* 只是状态按运次口径只读推导,不走会落库的接单态归一化。
* 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空——
* 一趟车里每张子单各有各的项目配置与打卡节点。
*/
private DriverWaybillCardVO toTripCard(TripVO trip, boolean withDetail) {
DriverWaybillCardVO card = Func.isEmpty(trip.getLoadingNo()) && withDetail
? unloadedTripDetail(trip)
: new DriverWaybillCardVO();
fillTripFields(card, trip, withDetail);
return card;
}
/**
* 未配载运次的详情照运单详情拼,只读:不调用 normalizeAcceptStatus
*/
private DriverWaybillCardVO unloadedTripDetail(TripVO trip) {
Long waybillId = parseId(trip.getId());
Waybill waybill = waybillId == null ? null : waybillService.getById(waybillId);
return waybill == null ? new DriverWaybillCardVO() : toCard(waybill, true);
}
private void fillTripFields(DriverWaybillCardVO card, TripVO trip, boolean withDetail) {
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
card.setTripId(trip.getId());
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
// 旧字段 id / waybillNo 保留原语义:未配载就是这张运单,配载取下第一张可见子运单(运次操作请用 tripId)
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
card.setStatus(trip.getStatus());
card.setRequireAccept(trip.getRequireAccept());
card.setAcceptStatus(trip.getAcceptStatus());
card.setTransportOrgType(trip.getTransportOrgType());
card.setTransportType(toTransportTypeLabel(trip.getTransportType()));
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
card.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
card.setCreateTime(formatDateTime(trip.getSortTime()));
card.setPublishTime(card.getCreateTime());
// 计划时间与备注:配载运次取配载单,未配载的照运单卡片自己的值,不回填空
if (Func.isNotEmpty(card.getLoadingNo())) {
card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate()));
card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate()));
card.setRemark(Func.toStr(trip.getTaskRemark(), ""));
}
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
card.setFromAddress(Func.toStrWithEmpty(trip.getDepartureAddress(), card.getFromName()));
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
card.setToAddress(Func.toStrWithEmpty(trip.getArrivalAddress(), card.getToName()));
card.setViaName(Func.toStr(trip.getTransitName(), ""));
card.setStationCount(trip.getStationCount());
card.setPickupAddress(card.getFromAddress());
card.setUnloadAddress(card.getToAddress());
// 货物名可能是一格里的多个名字,按既有拆分口径摊平后再去重
List<String> cargoNames = children.stream()
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
.distinct()
.collect(Collectors.toList());
card.setCargoNames(cargoNames);
card.setCargoName(String.join("、", cargoNames));
card.setCargoCategory(children.stream()
.map(TripVO.TripWaybillVO::getCargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("、")));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = trip.getTotalQuantity() == null
? card.getCargoSummary()
: formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
card.setWeight(weight);
card.setCargoQuantity(weight);
card.setTotalWeight(weight);
if (withDetail) {
card.setStations(trip.getStations());
card.setRoutePoints(toTripRoutePoints(trip.getStations()));
}
}
/**
* 详情里的点位列:整趟车的全部站点,站序即途经顺序
*/
private List<DriverWaybillCardVO.DriverRoutePointVO> toTripRoutePoints(List<TripStationVO> stations) {
if (Func.isEmpty(stations)) {
return List.of();
}
return stations.stream().map(station -> {
DriverWaybillCardVO.DriverRoutePointVO point = new DriverWaybillCardVO.DriverRoutePointVO();
point.setName(Func.toStr(station.getName(), ""));
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
point.setStatus("pending");
return point;
}).collect(Collectors.toList());
}
private Long parseId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
private int normalizePageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
private int normalizePageSize(Integer size) {
if (size == null || size < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(size, MAX_PAGE_SIZE);
}
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
@@ -135,13 +135,13 @@ final class TripAggregation {
for (Waybill child : children) {
String status = child == null ? null : child.getBusinessStatus();
total++;
if (TERMINAL_STATUSES.contains(status)) {
if (status != null && TERMINAL_STATUSES.contains(status)) {
terminal++;
}
if (COMPLETED.equals(status)) {
completed++;
}
if (PENDING_STATUSES.contains(status)) {
if (status != null && PENDING_STATUSES.contains(status)) {
pending++;
}
}
@@ -32,6 +32,7 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
@@ -40,6 +41,7 @@ import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
@@ -129,7 +131,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
LoadingManage loading = visibleLoading(id, condition);
if (loading != null) {
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()))
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
.getOrDefault(loading.getLoadingNo(), List.of());
if (children.isEmpty()) {
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
@@ -218,6 +220,9 @@ public class TripQueryServiceImpl implements ITripQueryService {
/**
* 把这一页的运次行组装成运次:先批量取配载单与子运单,再逐行拼装
* <p>
* 页里的行只带分组键与排序键,子运单要按主键/配载单号回查一次:未配载的运次就是那张运单本身,
* 状态、接单需求与货物都得从那行真身来,不能拿分组行凑。
*/
private List<TripVO> assemble(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
@@ -227,13 +232,25 @@ public class TripQueryServiceImpl implements ITripQueryService {
.map(Waybill::getLoadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = rows.stream()
.filter(row -> Func.isEmpty(row.getLoadingNo()))
.map(Waybill::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos);
Map<String, List<Waybill>> children = loadChildren(loadingNos, loneIds);
Map<Long, Waybill> loneWaybills = new LinkedHashMap<>();
children.values().forEach(group -> group.forEach(child -> loneWaybills.put(child.getId(), child)));
List<TripVO> trips = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
if (Func.isEmpty(loadingNo)) {
trips.add(waybillTrip(row, false));
Waybill waybill = loneWaybills.get(row.getId());
if (waybill == null) {
// 运单在两次查询之间被删或被拒单:这一页不给出没有子单的运次
continue;
}
trips.add(waybillTrip(waybill, false));
continue;
}
LoadingManage loading = loadings.get(loadingNo);
@@ -265,24 +282,26 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 段二:按本页运次的配载单号一次取回子运单,不逐条查
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
* <p>
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
*/
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
return Map.of();
}
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.in(Waybill::getLoadingNo, loadingNos)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)));
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
grouped.computeIfAbsent(child.getLoadingNo(), key -> new ArrayList<>()).add(child);
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
}
grouped.values().forEach(group -> group.sort(
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
@@ -310,11 +329,15 @@ public class TripQueryServiceImpl implements ITripQueryService {
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(loading.getRouteJson());
trip.setGoodsJson(loading.getGoodsJson());
}
fillAggregation(trip, children);
fillRouteAndCargo(trip, loading, children, withPayload);
return trip;
}
@@ -336,11 +359,13 @@ public class TripQueryServiceImpl implements ITripQueryService {
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(waybill.getRouteJson());
trip.setGoodsJson(waybill.getGoodsJson());
}
fillAggregation(trip, List.of(waybill));
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
return trip;
}
@@ -359,6 +384,70 @@ public class TripQueryServiceImpl implements ITripQueryService {
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
}
/**
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
*/
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
trip.setDepartureName(TripStationAssembler.departureName(stations));
trip.setTransitName(TripStationAssembler.transitName(stations));
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
trip.setStationCount(stations.size());
if (withPayload) {
trip.setStations(stations);
}
trip.setCargoSummary(cargoSummary(children));
fillTotalQuantity(trip, children);
}
/**
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
*/
private String cargoSummary(List<Waybill> children) {
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
String name = Func.toStrWithEmpty(child.getCargoName(), "货物");
String unit = Func.toStr(child.getQuantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = child.getQuantity() == null ? BigDecimal.ZERO : child.getQuantity();
byUnit.merge(unit, quantity, BigDecimal::add);
}
List<String> parts = new ArrayList<>();
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
return String.join("、", parts);
}
/**
* 合计重量:只有全部子运单同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
*/
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (Waybill child : children) {
String childUnit = Func.toStr(child.getQuantityUnit(), "");
if (child.getQuantity() == null || Func.isEmpty(childUnit)) {
return;
}
if (unit == null) {
unit = childUnit;
} else if (!unit.equals(childUnit)) {
return;
}
total = total.add(child.getQuantity());
}
if (unit == null) {
return;
}
trip.setTotalQuantity(total);
trip.setQuantityUnit(unit);
}
private String formatQuantity(BigDecimal quantity, String unit) {
String value = quantity.stripTrailingZeros().toPlainString();
return Func.isEmpty(unit) ? value : value + unit;
}
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(tripIdOf(waybill.getId()));
@@ -0,0 +1,342 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripStationVO;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来
* <p>
* 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。
* 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址;
* 都没有则给空站点数组,前端按空站点渲染而不是崩。
* <p>
* 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
*/
final class TripStationAssembler {
private static final String LOAD = "load";
private static final String UNLOAD = "unload";
private static final String LOAD_UNLOAD = "load_unload";
/** 途经站点之间的连接符 */
private static final String TRANSIT_JOINER = " → ";
/** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */
private static final String KEY_SEPARATOR = "\u0000";
private TripStationAssembler() {
}
/**
* 拼装整趟车的站点,站序从 1 开始
*
* @param loading 配载单,未配载传 null
* @param children 该运次的可见子运单
*/
static List<TripStationVO> assemble(LoadingManage loading, List<Waybill> children) {
List<TripStationVO> stations = fromRoute(loading, children);
if (stations.isEmpty()) {
stations = fromWaybillPoints(children);
}
if (stations.isEmpty() && loading != null) {
stations = fromLoadingAddresses(loading);
}
for (int i = 0; i < stations.size(); i++) {
stations.get(i).setIndex(i + 1);
}
return stations;
}
/**
* 起点名称:首站站名,没有站点时为空
*/
static String departureName(List<TripStationVO> stations) {
return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), "");
}
/**
* 终点名称:末站站名,没有站点时为空
*/
static String arrivalName(List<TripStationVO> stations) {
return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), "");
}
/**
* 途经站点:中间各站按顺序连接,不足三站为空
*/
static String transitName(List<TripStationVO> stations) {
if (stations.size() < 3) {
return "";
}
List<String> names = new ArrayList<>();
for (int i = 1; i < stations.size() - 1; i++) {
String name = Func.toStr(stations.get(i).getName(), "");
if (Func.isNotEmpty(name)) {
names.add(name);
}
}
return String.join(TRANSIT_JOINER, names);
}
/**
* 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取
*/
private static List<TripStationVO> fromRoute(LoadingManage loading, List<Waybill> children) {
if (loading == null) {
return new ArrayList<>();
}
List<Map<String, Object>> route = objectList(loading.getRouteJson());
if (route.isEmpty()) {
return new ArrayList<>();
}
List<String> waybillIds = stringList(loading.getWaybillIdsJson());
Map<String, Waybill> byId = new LinkedHashMap<>();
for (Waybill child : children) {
if (child.getId() != null) {
byId.put(String.valueOf(child.getId()), child);
}
}
List<TripStationVO> stations = new ArrayList<>();
for (Map<String, Object> element : route) {
String address = stringValue(element.get("address"));
String display = stringValue(element.get("displayAddress"));
TripStationVO station = new TripStationVO();
station.setName(Func.toStrWithEmpty(display, address));
station.setAddress(Func.toStrWithEmpty(address, station.getName()));
List<String> tags = stringList(element.get("tags"));
station.setTags(tags);
List<TripStationVO.TripCargoVO> cargos = new ArrayList<>();
boolean load = false;
boolean unload = false;
for (String tag : tags) {
boolean loadTag = tag.contains("装");
boolean unloadTag = tag.contains("卸");
if (!loadTag && !unloadTag) {
continue;
}
Waybill child = childOfTag(tag, waybillIds, byId);
if (child == null) {
continue;
}
load |= loadTag;
unload |= unloadTag;
addCargo(cargos, child, loadTag ? LOAD : UNLOAD);
if (loadTag && unloadTag) {
addCargo(cargos, child, UNLOAD);
}
}
fillOperation(station, load, unload);
station.setCargos(cargos);
stations.add(station);
}
return stations;
}
/**
* 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现
*/
private static List<TripStationVO> fromWaybillPoints(List<Waybill> children) {
Map<String, TripStationVO> stations = new LinkedHashMap<>();
Map<String, boolean[]> operations = new LinkedHashMap<>();
for (Waybill child : children) {
String fromName = pointName(child.getDepartureName(), child.getDepartureAddress());
String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName);
String toName = pointName(child.getArrivalName(), child.getArrivalAddress());
String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName);
point(stations, operations, fromName, fromAddress, true, child);
point(stations, operations, toName, toAddress, false, child);
}
operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1]));
return new ArrayList<>(stations.values());
}
private static void point(Map<String, TripStationVO> stations, Map<String, boolean[]> operations,
String name, String address, boolean load, Waybill child) {
if (Func.isEmpty(name) && Func.isEmpty(address)) {
return;
}
String key = name + KEY_SEPARATOR + address;
TripStationVO station = stations.get(key);
if (station == null) {
station = new TripStationVO();
station.setName(name);
station.setAddress(address);
station.setTags(new ArrayList<>());
station.setCargos(new ArrayList<>());
stations.put(key, station);
operations.put(key, new boolean[2]);
}
boolean[] flags = operations.get(key);
flags[0] |= load;
flags[1] |= !load;
addCargo(station.getCargos(), child, load ? LOAD : UNLOAD);
}
/**
* 站点再退到配载单自己的发货 / 途经 / 到货地址
*/
private static List<TripStationVO> fromLoadingAddresses(LoadingManage loading) {
List<TripStationVO> stations = new ArrayList<>();
for (String address : List.of(
Func.toStr(loading.getDepartureAddress(), ""),
Func.toStr(loading.getTransitAddress(), ""),
Func.toStr(loading.getArrivalAddress(), ""))) {
if (Func.isEmpty(address)) {
continue;
}
TripStationVO station = new TripStationVO();
station.setName(address);
station.setAddress(address);
station.setTags(new ArrayList<>());
station.setCargos(new ArrayList<>());
stations.add(station);
}
return stations;
}
/**
* 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次
*/
private static Waybill childOfTag(String tag, List<String> waybillIds, Map<String, Waybill> byId) {
String digits = tag.replaceAll("\\D+", "");
if (Func.isEmpty(digits)) {
return null;
}
int order = Func.toInt(digits, 0);
if (order < 1 || order > waybillIds.size()) {
return null;
}
return byId.get(waybillIds.get(order - 1));
}
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
if (child == null) {
return;
}
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
boolean exists = cargos.stream().anyMatch(cargo ->
Objects.equals(cargo.getWaybillId(), waybillId) && Objects.equals(cargo.getOperation(), operation));
if (exists) {
return;
}
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName(child.getCargoName());
cargo.setCustomerName(child.getCustomerName());
cargo.setQuantity(child.getQuantity());
cargo.setQuantityUnit(child.getQuantityUnit());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
}
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
if (load && unload) {
station.setOperationType(LOAD_UNLOAD);
station.setOperationTypeLabel("装卸");
} else if (load) {
station.setOperationType(LOAD);
station.setOperationTypeLabel("装货");
} else if (unload) {
station.setOperationType(UNLOAD);
station.setOperationTypeLabel("卸货");
} else {
station.setOperationType(null);
station.setOperationTypeLabel("");
}
}
private static String pointName(String name, String address) {
return Func.toStrWithEmpty(name, Func.toStr(address, ""));
}
private static List<Map<String, Object>> objectList(String json) {
if (Func.isEmpty(json)) {
return new ArrayList<>();
}
try {
Object parsed = JsonUtil.parse(json, List.class);
if (parsed instanceof List<?> list) {
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map<?, ?> map) {
Map<String, Object> element = new LinkedHashMap<>();
map.forEach((key, value) -> element.put(String.valueOf(key), value));
result.add(element);
}
}
return result;
}
} catch (Exception ignored) {
// 路线信息坏了不该让整趟车读不出来,按没有路线信息回退
}
return new ArrayList<>();
}
private static List<String> stringList(Object value) {
List<String> result = new ArrayList<>();
if (value instanceof List<?> list) {
for (Object item : list) {
String text = stringValue(item);
if (Func.isNotEmpty(text)) {
result.add(text);
}
}
return result;
}
String text = stringValue(value);
if (Func.isEmpty(text)) {
return result;
}
try {
Object parsed = JsonUtil.parse(text, List.class);
if (parsed instanceof List<?> list) {
for (Object item : list) {
String item2 = stringValue(item);
if (Func.isNotEmpty(item2)) {
result.add(item2);
}
}
return result;
}
} catch (Exception ignored) {
// 不是 JSON 数组就当逗号分隔处理
}
return Func.toStrList(",", text);
}
private static String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
}