1、新增小程序相关接口

2、调整OA
This commit is contained in:
2026-09-18 16:31:37 +08:00
parent 0fa0eae43c
commit 01993779a7
71 changed files with 6463 additions and 213 deletions
@@ -30,7 +30,6 @@ import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
@@ -48,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
* <p>
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
* 司机上报(submit)/ 列表 / 详情仅需登录态;跟进与完成保留菜单鉴权
* 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)
*/
@RestController
@AllArgsConstructor
@@ -80,27 +79,24 @@ public class ExceptionDisposalController extends BladeController {
}
@PostMapping("/follow")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 4)
@Operation(summary = "异常跟进")
@Operation(summary = "异常跟进", description = "调度端跟进;仅需登录态")
public R follow(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.follow(request);
return R.success("跟进成功");
}
@PostMapping("/complete")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 5)
@Operation(summary = "完成异常")
@Operation(summary = "完成异常", description = "调度端结案;仅需登录态")
public R complete(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.complete(request.getId());
return R.success("完成成功");
}
@PostMapping("/batch-complete")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 6)
@Operation(summary = "批量完成异常")
@Operation(summary = "批量完成异常", description = "调度端批量结案;仅需登录态")
public R batchComplete(@RequestParam String ids) {
exceptionDisposalService.batchComplete(ids);
return R.success("批量完成成功");
@@ -0,0 +1,143 @@
/**
* 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.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
import org.springblade.transport.pojo.vo.AdminHomeVO;
import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.service.IManageWaybillService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 调度端运单(小程序管理端)
* <p>
* 对外完整路径:{@code /api/blade-transport/waybill/manage/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /waybill/manage/**})。
* 同时兼容未去前缀直连({@code /blade-transport/waybill/manage/**})。
* 仅需登录态,不挂管理端菜单鉴权。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/waybill/manage", "/blade-transport/waybill/manage"})
@Tag(name = "调度端运单", description = "小程序调度端首页统计与运单列表")
public class ManageWaybillController extends BladeController {
private final IManageWaybillService manageWaybillService;
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
public R<AdminHomeStatsVO> stats() {
return R.data(manageWaybillService.stats());
}
@GetMapping("/home")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名")
public R<AdminHomeVO> home() {
return R.data(manageWaybillService.home());
}
@GetMapping("/list")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单分页列表", description = "当前组织运单;status:0待接单/1运输中/2已完成;exceptionexception/normaltransportTypecommon/load")
public R<IPage<AdminWaybillCardVO>> list(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception,
@Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType,
@Parameter(description = "创建日起 YYYY-MM-DD") @RequestParam(required = false) String startDate,
@Parameter(description = "创建日止 YYYY-MM-DD") @RequestParam(required = false) String endDate) {
return R.data(manageWaybillService.pageList(
current, size, keyword, status, exception, transportType, startDate, endDate));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
public R<AdminWaybillDetailVO> detail(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(manageWaybillService.detail(id));
}
@GetMapping("/pending")
@ApiOperationSupport(order = 5)
@Operation(summary = "待处理运单", description = "待接单/运输中;needReassign=true 仅司机已拒单")
public R<IPage<AdminWaybillCardVO>> pending(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
@Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) {
return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign));
}
@PostMapping("/reassign")
@ApiOperationSupport(order = 6)
@Operation(summary = "重新派单", description = "小程序调度端:跳过组织校验,仅需登录态;传入运单ID及新司机、手机号、车牌")
public R reassign(@RequestBody Waybill waybill) {
return R.status(manageWaybillService.reassign(
waybill.getId(),
waybill.getDriverId(),
waybill.getDriverName(),
waybill.getDriverPhone(),
waybill.getVehicleNo()));
}
@GetMapping("/driver-search")
@ApiOperationSupport(order = 7)
@Operation(summary = "搜索司机", description = "按姓名/手机号模糊搜索,供重新派单选用")
public R<List<AdminDriverOptionVO>> driverSearch(
@Parameter(description = "关键字") @RequestParam(required = false) String keyword) {
return R.data(manageWaybillService.searchDrivers(keyword));
}
@GetMapping("/vehicle-search")
@ApiOperationSupport(order = 8)
@Operation(summary = "搜索车牌", description = "按车牌模糊搜索(来自司机绑定车牌)")
public R<List<AdminVehicleOptionVO>> vehicleSearch(
@Parameter(description = "关键字") @RequestParam(required = false) String keyword) {
return R.data(manageWaybillService.searchVehicles(keyword));
}
}
@@ -68,6 +68,12 @@ public interface IDriverWaybillService {
*/
DriverWaybillCardVO detail(Long id);
/**
* 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。
* 供调度端 manage/detail 复用。
*/
DriverWaybillCardVO detailPunchSnapshot(Long id);
/**
* 司机确认接单:过程配置要求接单且尚未接单时,写入接单记录并将运单改为进行中。
*/
@@ -0,0 +1,90 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
import org.springblade.transport.pojo.vo.AdminHomeVO;
import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import java.util.List;
/**
* 调度端(小程序管理端)运单首页服务
*/
public interface IManageWaybillService {
/**
* 运单状态统计:运输中 / 待接单 / 在途异常 / 已完成
*/
AdminHomeStatsVO stats();
/**
* 首页聚合:统计 + 角标 + 待处理事项(异常处置≠已完成)+ 用户名
*/
AdminHomeVO home();
/**
* 调度端运单分页列表
*
* @param current 页码
* @param size 每页条数
* @param keyword 运单号/司机/车牌
* @param status 0待接单/1运输中/2已完成,空=全部
* @param exception exception有异常 / normal无异常 / 空=全部
* @param transportType common普通 / load配载 / 空=全部
* @param startDate 创建日起 YYYY-MM-DD
* @param endDate 创建日止 YYYY-MM-DD
*/
IPage<AdminWaybillCardVO> pageList(Integer current, Integer size, String keyword, String status,
String exception, String transportType, String startDate, String endDate);
/**
* 调度端运单详情(不校验司机归属)
*/
AdminWaybillDetailVO detail(Long id);
/**
* 待处理运单(待接单 / 运输中;可筛需重新派单)
*/
IPage<AdminWaybillCardVO> pendingList(Integer current, Integer size, String keyword, Boolean needReassign);
/**
* 重新派单:跳过管理端部门校验,仅需登录态(司机、手机号、车牌)
*/
boolean reassign(Long id, Long driverId, String driverName, String driverPhone, String vehicleNo);
/**
* 搜索司机(姓名/手机号)
*/
List<AdminDriverOptionVO> searchDrivers(String keyword);
/**
* 搜索车牌(来自司机绑定车牌)
*/
List<AdminVehicleOptionVO> searchVehicles(String keyword);
}
@@ -60,6 +60,12 @@ public interface IWaybillService extends BaseService<Waybill> {
boolean maintainMileage(WaybillMileageRequest request);
boolean cancel(Long id);
boolean reassign(Waybill waybill);
/**
* 小程序调度端重新派单:跳过管理端部门校验,其余逻辑与 {@link #reassign(Waybill)} 一致。
*/
boolean reassignWithoutDeptCheck(Waybill waybill);
boolean complete(Long id);
/**
@@ -225,6 +225,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO detailPunchSnapshot(Long id) {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在");
}
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverEnrouteRecordVO submitEnroute(EnrouteSubmitDTO dto) {
@@ -610,6 +622,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setAcceptStatus(waybill.getDriverAcceptStatus());
card.setRejectReason(waybill.getDriverRejectReason());
// 详情页字段(列表也可带上,体积很小)
String cargoName = Func.toStr(waybill.getCargoName(), "");
String weightText = card.getWeight();
card.setCargoName(cargoName);
card.setPickupAddress(card.getFromAddress());
card.setUnloadAddress(card.getToAddress());
card.setCargoQuantity(weightText);
card.setTotalWeight(weightText);
card.setTransportType(toTransportTypeLabel(waybill.getTransportType()));
card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime()));
card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime()));
card.setRemark(Func.toStr(waybill.getRemark(), ""));
if (withEnrouteRecords) {
Date lastPunchAt = findLastPunchTime(waybill.getId());
WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin(
@@ -623,6 +648,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setTransitTimeEnd(transit.timeEnd());
card.setEnrouteRecords(listEnrouteRecords(waybill.getId()));
card.setPunchNodes(buildPunchNodes(waybill, transit, processJson));
card.setRoutePoints(buildSimpleRoutePoints(waybill));
card.setProcessJson(processJson);
} else {
// 列表/首页:只解析过程配置是否启用在途打卡,不做频次/时段与落库查询
boolean punchEnabled = WaybillProcessSupport.isTransitPunchEnabled(processJson);
@@ -634,18 +661,61 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return card;
}
private List<DriverWaybillCardVO.DriverRoutePointVO> buildSimpleRoutePoints(Waybill waybill) {
DriverWaybillCardVO.DriverRoutePointVO load = new DriverWaybillCardVO.DriverRoutePointVO();
load.setName(Func.toStr(waybill.getDepartureName(), "装货点"));
load.setAddress(Func.toStr(waybill.getDepartureAddress(), load.getName()));
load.setStatus("pending");
DriverWaybillCardVO.DriverRoutePointVO unload = new DriverWaybillCardVO.DriverRoutePointVO();
unload.setName(Func.toStr(waybill.getArrivalName(), "卸货点"));
unload.setAddress(Func.toStr(waybill.getArrivalAddress(), unload.getName()));
unload.setStatus("pending");
return List.of(load, unload);
}
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
}
String t = transportType.trim().toLowerCase();
return switch (t) {
case "road", "gl" -> "公路运输";
case "railway", "rail" -> "铁路运输";
case "river", "water", "waterway" -> "水路运输";
case "air", "aviation" -> "航空运输";
default -> transportType;
};
}
private String formatLocalDateYmd(LocalDate date) {
if (date == null) {
return "";
}
return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
/**
* 动态获取项目启用中的过程配置节点 JSON;无则回退运单快照 processJson
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson
* 避免项目配置改坏后司机端打卡页空白。
*/
private String resolveProcessJson(Waybill waybill) {
if (waybill == null) {
return null;
}
String snapshot = waybill.getProcessJson();
String live = loadLiveProcessConfigJson(waybill.getProjectId());
if (Func.isNotEmpty(live)) {
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
return live;
}
// 动态配置存在但无可打卡节点:仍回退快照
if (Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return live;
}
return waybill.getProcessJson();
return snapshot;
}
private String loadLiveProcessConfigJson(Long projectId) {
@@ -0,0 +1,642 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeBadgesVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
import org.springblade.transport.pojo.vo.AdminHomeVO;
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
* <p>
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
*/
@Service
@RequiredArgsConstructor
public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String STATUS_PENDING = "pending";
private static final String STATUS_RUNNING = "running";
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_CANCELLED = "cancelled";
private static final String ACCEPT_REJECTED = "rejected";
private static final String DISPOSAL_PENDING = "pending";
private static final String DISPOSAL_PROCESSING = "processing";
private static final String RISK_PENDING = "pending";
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
private static final String TRANSPORT_COMMON = "common";
private static final String TRANSPORT_LOAD = "load";
private static final int FEED_LIMIT = 20;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IExceptionDisposalService exceptionDisposalService;
private final IRiskDisposalService riskDisposalService;
private final IDriverWaybillService driverWaybillService;
@Override
public AdminHomeStatsVO stats() {
AdminHomeStatsVO vo = new AdminHomeStatsVO();
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
vo.setException(countIncompleteExceptions());
return vo;
}
@Override
public AdminHomeVO home() {
AdminHomeVO home = new AdminHomeVO();
home.setUserName(resolveUserName());
home.setStats(stats());
AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
badges.setException(home.getStats().getException());
badges.setRisk(countPendingRisks());
home.setBadges(badges);
home.setFeed(buildExceptionFeed());
return home;
}
@Override
public IPage<AdminWaybillCardVO> pageList(Integer current, Integer size, String keyword, String status,
String exception, String transportType, String startDate, String endDate) {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
emptyVo.setRecords(List.of());
return emptyVo;
}
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
applyStatusFilter(wrapper, status);
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
applyTransportTypeFilter(wrapper, transportType);
applyKeywordFilter(wrapper, keyword);
applyCreateTimeFilter(wrapper, startDate, endDate);
wrapper.orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
}
@Override
public AdminWaybillDetailVO detail(Long id) {
if (id == null) {
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
}
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
}
boolean hasException = false;
Long exceptionId = null;
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, id)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
if (latest != null) {
hasException = true;
exceptionId = latest.getId();
}
return toDetail(waybill, hasException, exceptionId);
}
@Override
public IPage<AdminWaybillCardVO> pendingList(Integer current, Integer size, String keyword, Boolean needReassign) {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
if (Boolean.TRUE.equals(needReassign)) {
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
} else if (Boolean.FALSE.equals(needReassign)) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
}
applyKeywordFilter(wrapper, keyword);
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
}
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(waybill.getId());
detail.setWaybillNo(waybill.getWaybillNo());
detail.setStatus(toAppStatus(waybill.getBusinessStatus()));
String mode = Func.toStr(waybill.getTransportType(), "");
detail.setTransportMode(mode);
detail.setTransportType(toTransportTypeLabel(mode));
detail.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
detail.setFromName(formatPlaceName(waybill.getDepartureName(), mode));
detail.setToName(formatPlaceName(waybill.getArrivalName(), mode));
String fromAddr = Func.toStr(waybill.getDepartureAddress(), Func.toStr(waybill.getDepartureName(), ""));
String toAddr = Func.toStr(waybill.getArrivalAddress(), Func.toStr(waybill.getArrivalName(), ""));
detail.setFromAddress(fromAddr);
detail.setToAddress(toAddr);
detail.setPickupAddress(fromAddr);
detail.setUnloadAddress(toAddr);
String cargo = Func.toStr(waybill.getCargoName(), "");
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
detail.setCargoName(cargo);
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
detail.setPlanShipTime(formatLocalDate(waybill.getEstimatedStartTime()));
detail.setPlanFinishTime(formatLocalDate(waybill.getEstimatedEndTime()));
detail.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
detail.setDriverName(Func.toStr(waybill.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
detail.setRemark(Func.toStr(waybill.getRemark(), ""));
detail.setHasException(hasException);
detail.setExceptionId(exceptionId);
detail.setNeedReassign(ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus()));
detail.setAcceptStatus(Func.toStr(waybill.getDriverAcceptStatus(), ""));
detail.setRejectReason(Func.toStr(waybill.getDriverRejectReason(), ""));
detail.setDriverId(waybill.getDriverId());
AdminWaybillDetailVO.AdminRoutePointVO load = new AdminWaybillDetailVO.AdminRoutePointVO();
load.setName(Func.toStr(waybill.getDepartureName(), "装货点"));
load.setAddress(fromAddr);
load.setStatus("pending");
AdminWaybillDetailVO.AdminRoutePointVO unload = new AdminWaybillDetailVO.AdminRoutePointVO();
unload.setName(Func.toStr(waybill.getArrivalName(), "卸货点"));
unload.setAddress(toAddr);
unload.setStatus("pending");
detail.setRoutePoints(List.of(load, unload));
// 复用司机端打卡组装:过程节点 + 途打卡记录(调度端只读展示)
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybill.getId());
if (punch != null) {
detail.setPunchNodes(punch.getPunchNodes());
detail.setEnrouteRecords(punch.getEnrouteRecords());
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
rp.setName(p.getName());
rp.setAddress(p.getAddress());
rp.setStatus(p.getStatus());
return rp;
}).toList());
}
}
return detail;
}
@Override
public boolean reassign(Long id, Long driverId, String driverName, String driverPhone, String vehicleNo) {
Waybill request = new Waybill();
request.setId(id);
request.setDriverId(driverId);
request.setDriverName(driverName);
request.setDriverPhone(driverPhone);
request.setVehicleNo(vehicleNo);
return waybillService.reassignWithoutDeptCheck(request);
}
@Override
public List<AdminDriverOptionVO> searchDrivers(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 20");
if (Func.isNotBlank(key)) {
wrapper.and(w -> w.like(Driver::getDriverName, key).or().like(Driver::getMobile, key));
}
return driverService.list(wrapper).stream().map(d -> {
AdminDriverOptionVO vo = new AdminDriverOptionVO();
vo.setId(d.getId());
vo.setName(Func.toStr(d.getDriverName(), ""));
vo.setPhone(Func.toStr(d.getMobile(), ""));
vo.setVehicleNo(Func.toStr(d.getDrivingVehicle(), ""));
return vo;
}).toList();
}
@Override
public List<AdminVehicleOptionVO> searchVehicles(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.isNotNull(Driver::getDrivingVehicle)
.ne(Driver::getDrivingVehicle, "")
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 30");
if (Func.isNotBlank(key)) {
wrapper.like(Driver::getDrivingVehicle, key);
}
java.util.LinkedHashMap<String, AdminVehicleOptionVO> map = new java.util.LinkedHashMap<>();
for (Driver d : driverService.list(wrapper)) {
String plate = Func.toStr(d.getDrivingVehicle(), "").trim();
if (Func.isBlank(plate) || map.containsKey(plate)) {
continue;
}
AdminVehicleOptionVO vo = new AdminVehicleOptionVO();
vo.setVehicleNo(plate);
vo.setDriverName(Func.toStr(d.getDriverName(), ""));
map.put(plate, vo);
}
return new java.util.ArrayList<>(map.values());
}
/** 运输方式字典值 → 展示文案 */
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
}
String t = transportType.trim().toLowerCase();
return switch (t) {
case "road", "gl" -> "公路运输";
case "railway", "rail" -> "铁路运输";
case "river", "water", "waterway" -> "水路运输";
case "air", "aviation" -> "航空运输";
default -> transportType;
};
}
private long countWaybillByStatus(String status) {
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getBusinessStatus, status));
}
/**
* 小程序调度端不做组织过滤。
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
*/
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
return Wrappers.<Waybill>lambdaQuery();
}
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
String businessStatus = toBusinessStatus(status);
if (Func.isNotBlank(businessStatus)) {
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
}
}
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
if (EXCEPTION_YES.equals(exception)) {
wrapper.in(Waybill::getId, exceptionWaybillIds);
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
}
}
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
if (TRANSPORT_LOAD.equals(transportType)) {
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
} else if (TRANSPORT_COMMON.equals(transportType)) {
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
}
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
.or().like(Waybill::getDriverName, key)
.or().like(Waybill::getVehicleNo, key));
}
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
if (Func.isNotBlank(startDate)) {
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
if (start != null) {
wrapper.ge(Waybill::getCreateTime, start);
}
}
if (Func.isNotBlank(endDate)) {
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
if (end != null) {
wrapper.le(Waybill::getCreateTime, end);
}
}
}
/** 小程序 status → 后端 businessStatus */
private String toBusinessStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> STATUS_PENDING;
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
default -> null;
};
}
private Set<Long> loadIncompleteExceptionWaybillIds() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.select(ExceptionDisposal::getWaybillId)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.isNotNull(ExceptionDisposal::getWaybillId));
Set<Long> ids = new HashSet<>();
for (ExceptionDisposal item : list) {
if (item.getWaybillId() != null) {
ids.add(item.getWaybillId());
}
}
return ids;
}
private AdminWaybillCardVO toCard(Waybill waybill, boolean hasException) {
AdminWaybillCardVO card = new AdminWaybillCardVO();
card.setId(waybill.getId());
card.setWaybillNo(waybill.getWaybillNo());
String mode = Func.toStr(waybill.getTransportType(), "");
card.setTransportMode(mode);
card.setFromName(formatPlaceName(waybill.getDepartureName(), mode));
card.setToName(formatPlaceName(waybill.getArrivalName(), mode));
card.setCargo(Func.toStr(waybill.getCargoName(), ""));
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime()));
card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
card.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
card.setDriverName(Func.toStr(waybill.getDriverName(), ""));
card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
card.setHasException(hasException);
card.setTransportType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
card.setCreateTime(formatDateTime(waybill.getCreateTime()));
card.setNeedReassign(ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus()));
card.setBuyerPaid(false);
return card;
}
/**
* 公路运输:起/终仅展示市县(去掉省/自治区);其它运输方式原样返回。
*/
private String formatPlaceName(String name, String transportType) {
String raw = Func.toStr(name, "").trim();
if (Func.isBlank(raw) || !isRoadTransport(transportType)) {
return raw;
}
return toCityCounty(raw);
}
private boolean isRoadTransport(String transportType) {
if (Func.isBlank(transportType)) {
return false;
}
String t = transportType.trim().toLowerCase();
return t.contains("road") || transportType.contains("公路") || transportType.contains("道路") || "gl".equals(t);
}
/** 去掉省级前缀,保留「市 + 区/县/旗」 */
private String toCityCounty(String name) {
String s = name.replaceFirst("^.+?(省|自治区|特别行政区)", "");
if (Func.isBlank(s)) {
s = name;
}
java.util.regex.Matcher city = java.util.regex.Pattern
.compile("^(.+?市)(.+?(?:区|县|旗|市))?")
.matcher(s);
if (city.find()) {
return Func.toStr(city.group(1), "") + Func.toStr(city.group(2), "");
}
java.util.regex.Matcher prefecture = java.util.regex.Pattern
.compile("^(.+?(?:州|盟|地区))(.+?(?:区|县|旗|市))?")
.matcher(s);
if (prefecture.find()) {
return Func.toStr(prefecture.group(1), "") + Func.toStr(prefecture.group(2), "");
}
return s;
}
private Integer toAppStatus(String businessStatus) {
if (Func.isBlank(businessStatus)) {
return null;
}
return switch (businessStatus) {
case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0;
case STATUS_RUNNING -> 1;
case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
}
private String formatWeight(BigDecimal quantity, String unit) {
if (quantity == null) {
return "";
}
String qty = quantity.stripTrailingZeros().toPlainString();
return Func.isBlank(unit) ? qty : qty + unit;
}
private String formatLocalDate(LocalDate date) {
if (date == null) {
return "";
}
return date.format(DATE_YMD);
}
private String formatDateTime(Date date) {
if (date == null) {
return "";
}
return DateUtil.format(date, DateUtil.PATTERN_DATETIME);
}
private long countIncompleteExceptions() {
return exceptionDisposalService.count(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING));
}
private long countPendingRisks() {
return riskDisposalService.count(Wrappers.<RiskDisposal>lambdaQuery()
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
}
private List<AdminTodoItemVO> buildExceptionFeed() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.last("LIMIT " + FEED_LIMIT));
return list.stream().map(this::toTodoItem).collect(Collectors.toList());
}
private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) {
AdminTodoItemVO item = new AdminTodoItemVO();
item.setId(disposal.getId());
item.setType("exception");
item.setTitle("异常待处置");
item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null
? disposal.getReportTime()
: toLocalDateTime(disposal.getCreateTime())));
item.setDesc(buildExceptionDesc(disposal));
item.setWaybillNo(Func.toStr(disposal.getWaybillNo(), ""));
item.setActionLabel("立即处置");
String status = Func.toStr(disposal.getDisposalStatus(), DISPOSAL_PENDING);
item.setTargetUrl("/subpackages/admin/exception?status=" + status);
return item;
}
private String buildExceptionDesc(ExceptionDisposal disposal) {
String reporter = Func.toStr(disposal.getReporterName(), "司机");
String type = Func.toStr(disposal.getExceptionType(), "异常");
String reason = Func.isNotBlank(disposal.getExceptionReason())
? disposal.getExceptionReason()
: Func.toStr(disposal.getReportDescription(), "");
if (Func.isBlank(reason)) {
return reporter + "上报" + type;
}
String text = reporter + "上报" + type + "" + reason.trim();
return text.length() > 80 ? text.substring(0, 80) + "" : text;
}
private String resolveUserName() {
String realName = UserCache.getUserRealName(AuthUtil.getUserId());
if (Func.isNotBlank(realName)) {
return realName;
}
return Func.toStr(AuthUtil.getUserName(), "");
}
private String formatTimeAgo(LocalDateTime time) {
if (time == null) {
return "";
}
Duration duration = Duration.between(time, LocalDateTime.now());
if (duration.isNegative()) {
duration = Duration.ZERO;
}
long minutes = duration.toMinutes();
if (minutes < 1) {
return "刚刚";
}
if (minutes < 60) {
return minutes + "分钟";
}
long hours = duration.toHours();
if (hours < 24) {
return hours + "小时";
}
long days = duration.toDays();
if (days < 30) {
return days + "";
}
return time.format(DATE_MD);
}
private LocalDateTime toLocalDateTime(Date date) {
if (date == null) {
return null;
}
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
}
}
@@ -819,10 +819,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reassign(Waybill request) {
return doReassign(request, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reassignWithoutDeptCheck(Waybill request) {
return doReassign(request, false);
}
private boolean doReassign(Waybill request, boolean checkDept) {
if (request == null || Func.isEmpty(request.getId())) {
throw new ServiceException("运单ID不能为空");
}
Waybill waybill = loadEditable(request.getId(), true);
Waybill waybill = loadEditable(request.getId(), checkDept);
assertNotLoaded(waybill);
if (!"pending".equals(waybill.getBusinessStatus()) && !"running".equals(waybill.getBusinessStatus())) {
throw new ServiceException("仅待执行/进行中运单允许重新派单");