🔀 合并 websoft/dev,解决待回单状态与汇票可选冲突

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-24 14:50:05 +08:00
77 changed files with 6109 additions and 293 deletions
@@ -92,7 +92,7 @@ public class DriverWaybillController extends BladeController {
@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) {
@Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.page(current, size, statusCode, keyword));
}
@@ -129,12 +129,12 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/task/page")
@ApiOperationSupport(order = 24)
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
@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) {
@Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
}
@@ -201,13 +201,6 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.submitNode(dto));
}
@PostMapping("/complete")
@ApiOperationSupport(order = 10)
@Operation(summary = "司机完成运单", description = "校验司机归属后改状态为已完成,并检查生成应收应付明细(与管理端一致)")
public R complete(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.status(driverWaybillService.complete(id));
}
/** 前端可能传空字符串表示「全部」 */
private Integer parseStatus(String status) {
if (Func.isEmpty(status)) {
@@ -33,6 +33,8 @@ 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.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -50,7 +52,7 @@ import org.springframework.web.bind.annotation.RestController;
* <p>
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
* 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。
* 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。
*/
@RestController
@AllArgsConstructor
@@ -114,4 +116,28 @@ public class ExceptionDisposalController extends BladeController {
return R.success("编辑成功");
}
@PostMapping("/reject")
@ApiOperationSupport(order = 8)
@Operation(summary = "拒绝异常", description = "调度端拒绝上报:先写一条拒绝原因的跟进记录,再了结;仅需登录态")
public R reject(@RequestBody ExceptionDisposalRejectRequest request) {
exceptionDisposalService.reject(request);
return R.success("已拒绝");
}
@PostMapping("/revoke")
@ApiOperationSupport(order = 9)
@Operation(summary = "撤销异常上报", description = "上报人撤销自己未了结的上报,不写跟进记录;仅需登录态")
public R revoke(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.revoke(request.getId());
return R.success("已撤销");
}
@PostMapping("/route-adjust")
@ApiOperationSupport(order = 10)
@Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态")
public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) {
exceptionDisposalService.adjustRoute(request);
return R.success("已调整路线");
}
}
@@ -88,7 +88,7 @@ public class ManageWaybillController extends BladeController {
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 = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @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,
@@ -112,7 +112,7 @@ public class ManageWaybillController extends BladeController {
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) String keyword,
@Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) {
return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign));
}
@@ -145,7 +145,7 @@ public final class SettlementImportTemplateExporter {
List<String> lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择。");
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
@@ -0,0 +1,61 @@
/**
* 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>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.feign;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.AllArgsConstructor;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.service.IDriverService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 司机档案 Feign 实现类
*
* @author Chill
*/
@NonDS
@Hidden
@RestController
@AllArgsConstructor
public class DriverClient implements IDriverClient {
private final IDriverService driverService;
@Override
@PostMapping(ENSURE_PROFILE)
public R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param) {
if (param == null) {
return R.fail("司机档案参数不能为空");
}
return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone()));
}
}
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
@@ -59,6 +60,16 @@ public interface IDriverService extends BaseService<Driver> {
*/
boolean submit(Driver driver);
/**
* 查找或创建司机档案并绑定小程序账号(登录路径专用)
*
* @param tenantId 租户ID,必须非空
* @param mpUserId 小程序用户ID
* @param phone 手机号
* @return 档案ID与本次处置标志;冲突时 message 携带可读原因
*/
DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
/**
* 修改状态
*
@@ -157,11 +157,6 @@ public interface IDriverWaybillService {
*/
DriverNodePunchVO submitNode(NodeSubmitDTO dto);
/**
* 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。
*/
boolean complete(Long id);
/**
* 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。
* <p>
@@ -25,6 +25,8 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -56,4 +58,21 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
void batchComplete(String ids);
/**
* 拒绝异常:先写一条「拒绝:原因」的跟进记录,再了结。
* 拒绝了结复用已完成终态,不新增状态值。
*/
void reject(ExceptionDisposalRejectRequest request);
/**
* 撤销异常上报:仅限上报人本人、仅限未了结,置为已撤销,不留跟进记录
*/
void revoke(Long id);
/**
* 同意并调整路线:先写一条调整说明的跟进记录,再改载体路线,最后了结。
* 有配载单时改配载单的站点行路线,没有配载单时只按路线段改运单的地址。
*/
void adjustRoute(ExceptionDisposalRouteAdjustRequest request);
}
@@ -108,7 +108,7 @@ public interface IWaybillService extends BaseService<Waybill> {
boolean complete(Long id);
/**
* 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
* 末位打卡自动完成:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
* (改状态 + 生成应收应付明细 + 尝试完成配载单)。
*/
boolean completeWithoutDeptCheck(Long id);
@@ -349,8 +349,8 @@ public class CertAuditServiceImpl implements ICertAuditService {
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
}
// 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
// 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
// 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
// 建档查重与按证件号识别同一人都会失效
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
driver.setDriverName(snapshot.getRealName());
driver.setIdCardNo(snapshot.getIdCardNo());
@@ -382,10 +382,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
/**
* 身份证号不得落在另一名司机名下
* <p>
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
* 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
* 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
* 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
* {@code blade_transport_driver.id_card_no} 上有唯一索引
* {@code uk_transport_driver_id_card};证件号已属于另一条档案时,
* 此处阻断审核并整体回滚,交由管理端先处理档案归属。
* <p>
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
@@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.mapper.DriverMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
@@ -53,6 +55,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@@ -61,6 +64,7 @@ import java.util.Objects;
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> implements IDriverService {
@@ -74,6 +78,21 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
private static final int REMARK_MAX_LENGTH = 200;
private static final int DEFAULT_ENABLED_STATUS = 1;
private static final int DEFAULT_FALSE = 0;
/**
* 档案来源:登录时自动新建
*/
private static final String PROFILE_SOURCE_LOGIN_AUTO = "login_auto";
/**
* 档案来源:绑定到已有档案
*/
private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound";
/**
* 自动建档的默认岗位:从小程序司机端登录即视为司机。
* 岗位为空会让档案被派单候选筛掉。
*/
private static final String PROFILE_DEFAULT_POST = "司机";
private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员";
private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对";
private final IMpUserClient mpUserClient;
private final ISysClient sysClient;
@@ -101,6 +120,137 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone) {
String resolvedTenantId = trimToNull(tenantId);
if (resolvedTenantId == null) {
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
}
// 租户是档案归属与手机号查重的范围,tenant_id 为空的档案不能被后续同号查询命中,
// 故此处拒绝写库(登录层只记 WARN)
if (resolvedTenantId == null) {
throw new ServiceException("租户信息为空,无法创建司机档案");
}
if (Func.isEmpty(mpUserId)) {
throw new ServiceException("小程序用户ID不能为空");
}
String mobile = trimToNull(phone);
if (mobile == null) {
throw new ServiceException("手机号不能为空");
}
// 脱敏手机号(如 155****2748)不是可用的业务数据:写进档案后按手机号查不到司机
if (mobile.contains("*")) {
throw new ServiceException("手机号无效,不能写入司机档案");
}
// 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识,
// phone 会被运营商二次放号
Driver bound = findDriverByUserId(mpUserId);
if (bound != null) {
return ensureResult(bound.getId(), false, false, null);
}
List<Driver> sameMobile = list(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getTenantId, resolvedTenantId)
.eq(Driver::getMobile, mobile));
if (sameMobile.isEmpty()) {
return createPlaceholderProfile(resolvedTenantId, mpUserId, mobile);
}
if (sameMobile.size() > 1) {
return ensureResult(null, false, false, CONFLICT_MOBILE_MULTIPLE);
}
Driver existing = sameMobile.get(0);
if (Func.isNotEmpty(existing.getUserId())) {
if (Objects.equals(existing.getUserId(), mpUserId)) {
return ensureResult(existing.getId(), false, false, null);
}
return ensureResult(null, false, false, CONFLICT_MOBILE_OCCUPIED);
}
return bindExistingProfile(existing.getId(), mpUserId);
}
/**
* 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件
*/
private Driver findDriverByUserId(Long mpUserId) {
return getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, mpUserId)
.last("LIMIT 1"));
}
/**
* 新建占位档案
* <p>
* 不走 {@link #submit}:那条路径要求 15 项必填。这里只满足表约束——
* id_card_no 留 NULL(唯一索引对 NULL 不去重,NULL 同时是「未采集」的判据),
* 其余 NOT NULL 列写空串或可读占位。
*/
private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) {
Driver driver = new Driver();
driver.setTenantId(tenantId);
driver.setUserId(mpUserId);
driver.setDriverName("司机" + mobileTail(mobile));
driver.setMobile(mobile);
driver.setDrivingType("");
driver.setDriverType("");
driver.setPosts(PROFILE_DEFAULT_POST);
driver.setOrganizationName("");
driver.setEmergencyContactName("");
driver.setEmergencyContactMobile("");
driver.setStatus(DEFAULT_ENABLED_STATUS);
driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO);
driver.setAccountBoundTime(LocalDateTime.now());
try {
save(driver);
} catch (Exception exception) {
// 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案
Driver concurrent = findDriverByUserId(mpUserId);
if (concurrent != null) {
return ensureResult(concurrent.getId(), false, false, null);
}
log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception);
throw new ServiceException("创建司机档案失败");
}
return ensureResult(driver.getId(), true, false, null);
}
/**
* 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容
*/
private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) {
Driver patch = new Driver();
patch.setId(driverId);
patch.setUserId(mpUserId);
patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND);
patch.setAccountBoundTime(LocalDateTime.now());
// 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle
carryDrivingVehicle(driverId, patch);
try {
updateById(patch);
} catch (Exception exception) {
// 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上
Driver concurrent = findDriverByUserId(mpUserId);
if (concurrent != null) {
return ensureResult(concurrent.getId(), false, false, null);
}
log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception);
throw new ServiceException("绑定司机档案失败");
}
return ensureResult(driverId, false, true, null);
}
private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) {
DriverEnsureProfileVO result = new DriverEnsureProfileVO();
result.setDriverId(driverId);
result.setCreated(created);
result.setBoundExisting(boundExisting);
result.setMessage(message);
return result;
}
private String mobileTail(String mobile) {
return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {
@@ -107,7 +107,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private static final String STATUS_WAITING_DISPATCH = "waiting_dispatch";
private static final String STATUS_DISPATCHING = "dispatching";
private static final String STATUS_RUNNING = "running";
private static final String STATUS_PENDING_RETURN = WaybillProcessSupport.STATUS_PENDING_RETURN;
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_CANCELLED = "cancelled";
@@ -119,14 +118,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private static final List<String> PENDING_STATUSES = Arrays.asList(
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
);
/** Tab「全部」统计口径:待接 + 运输中 + 待确认回单 + 已完成(不含已取消) */
/** Tab「全部」统计口径:待接 + 运输中 + 待回单确认 + 已完成(不含已取消) */
private static final List<String> TAB_ALL_STATUSES = Arrays.asList(
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING,
STATUS_PENDING_RETURN, STATUS_COMPLETED
WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, STATUS_COMPLETED
);
/** 允许完成运单的业务状态 */
private static final List<String> COMPLETABLE_STATUSES = Arrays.asList(
STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
/**
* 进行中口径:司机手上还没走完的单,含待回单确认。
* 待回单确认指司机已打完末位卡、等业务员确认回单,此时业务状态还不是 completed。
*/
private static final List<String> ACTIVE_STATUSES = Arrays.asList(
STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING,
WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
);
private static final int DEFAULT_PREVIEW_SIZE = 2;
@@ -522,35 +525,35 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
nodePunchMapper.insert(punch);
advanceCurrentProcessNode(waybill, nodeCfg);
// 末位打卡节点是到货/卸货/签收时:
// - 回单无需确认 → 直接完成;
// - 回单需确认 → 进入「待确认回单」,等运管员批量/单条完成后才变已完成。
// isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
// 末位打卡节点为到货/卸货/签收、且运单允许完成时:回单无需确认则本次打卡一并完成运单,
// 回单需业务员确认则停在待回单确认。
// isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
String nodeName = WaybillProcessSupport.nodeName(nodeCfg);
String waybillBusinessStatus = waybill.getBusinessStatus();
boolean completed = false;
if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey)
&& WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName)
&& canAutoCompleteWaybill(waybill)) {
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
completed = waybillService.completeWithoutDeptCheck(waybill.getId());
} else {
markPendingReturn(waybill);
if (completed) {
waybillBusinessStatus = STATUS_COMPLETED;
}
} else if (WaybillProcessSupport.STATUS_RUNNING.equalsIgnoreCase(waybill.getBusinessStatus())) {
// 当前业务状态为 running 才置为待回单确认;canAutoCompleteWaybill 的状态口径还含待执行/待调度。
// 待回单确认在 isPunchEnded 里与已完成、已取消同属结束态。
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
waybillService.updateById(waybill);
waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
}
}
DriverNodePunchVO vo = toNodePunchVO(punch);
vo.setWaybillCompleted(completed);
vo.setWaybillBusinessStatus(waybillBusinessStatus);
return vo;
}
/**
* 司机末位打卡完成且回单需内部确认时,将运单置为待确认回单。
*/
private void markPendingReturn(Waybill waybill) {
waybill.setBusinessStatus(STATUS_PENDING_RETURN);
waybillService.updateById(waybill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean accept(Long id) {
@@ -626,24 +629,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return waybillService.updateById(waybill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean complete(Long id) {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
requireCurrentDriver();
Waybill waybill = loadDriverWaybill(id);
waybill = normalizeAcceptStatus(waybill);
if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) {
throw new ServiceException("当前运单状态不允许完成");
}
// 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁
assertNoUnresolvedException(waybill.getId());
// 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单
return waybillService.completeWithoutDeptCheck(waybill.getId());
}
private void assertAcceptable(Waybill waybill) {
if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) {
throw new ServiceException("当前运单状态不允许接单");
@@ -858,8 +843,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
switch (status) {
case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES);
case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES);
case 2 -> wrapper.in(Waybill::getBusinessStatus, STATUS_COMPLETED, STATUS_PENDING_RETURN);
case 1 -> wrapper.in(Waybill::getBusinessStatus, ACTIVE_STATUSES);
case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED);
case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED);
default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES);
}
@@ -1026,6 +1011,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
card.setBusinessStatus(waybill.getBusinessStatus());
String createTime = formatDateTime(waybill.getCreateTime());
card.setCreateTime(createTime);
card.setPublishTime(createTime);
@@ -1127,6 +1113,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
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());
// 原始业务状态只在整趟车子单状态唯一时下发:配载车混状态时一趟车没有唯一原始状态,给 null 由前端按数字态展示
List<String> childBusinessStatuses = children.stream()
.map(TripVO.TripWaybillVO::getBusinessStatus)
.filter(Func::isNotBlank)
.distinct()
.collect(Collectors.toList());
card.setBusinessStatus(childBusinessStatuses.size() == 1 ? childBusinessStatuses.get(0) : null);
card.setRequireAccept(trip.getRequireAccept());
card.setAcceptStatus(trip.getAcceptStatus());
card.setTransportOrgType(trip.getTransportOrgType());
@@ -1534,27 +1527,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。
* 自动完成的前置:业务状态在进行中口径内,且没有未了结异常。
* <p>
* 不满足返回 false,由调用方静默跳过,打卡不受影响。
*/
private boolean canAutoCompleteWaybill(Waybill waybill) {
return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())
return ACTIVE_STATUSES.contains(waybill.getBusinessStatus())
&& unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty();
}
/**
* 断言该运单没有未了结的异常。
* <p>
* 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
* 「完成运单」绕过打卡门禁。
*/
private void assertNoUnresolvedException(Long waybillId) {
if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
}
}
/**
* 组装过程配置 punch=是 的打卡节点列表。
* <p>
@@ -2018,9 +1999,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
return switch (businessStatus) {
case STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING -> 0;
case STATUS_RUNNING -> 1;
// 待确认回单:司机侧节点已打完,对司机按已完成展示
case STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
@@ -29,15 +29,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.LoadingManageMapper;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -45,6 +51,7 @@ import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -54,10 +61,13 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -73,23 +83,53 @@ public class ExceptionDisposalServiceImpl
private static final String STATUS_PENDING = "pending";
private static final String STATUS_PROCESSING = "processing";
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_REVOKED = "revoked";
private static final String STATUS_CANCELLED = "cancelled";
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
/** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
private static final int REJECT_REASON_MAX_LENGTH = 200;
/** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
private static final int ROUTE_JSON_MAX_LENGTH = 8000;
private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
private static final int ADDRESS_MAX_LENGTH = 255;
private static final String NODE_KIND_START = "start";
private static final String NODE_KIND_TRANSIT = "transit";
private static final String NODE_KIND_END = "end";
/** 途经站地址在配载单上的连接符,与 PC 同步地址列时一致 */
private static final String TRANSIT_ADDRESS_JOINER = ",";
/** 跟进记录与变更记录共用的同一句结论 */
private static final String ROUTE_CHANGE_UNCHANGED = "同意调整路线,未修改路线";
private static final String ROUTE_CHANGE_PREFIX = "同意调整路线:";
/** 结论里站点之间的连接符,如「A → B → C」 */
private static final String ROUTE_STATION_SEPARATOR = " → ";
/** task_info_json 里存路线变更记录数组的键,与 PC 同一份结构 */
private static final String TASK_INFO_ROUTE_CHANGE_RECORDS = "routeChangeRecords";
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
private final IWaybillService waybillService;
private final IDriverWaybillService driverWaybillService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
private final LoadingManageMapper loadingManageMapper;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService,
IDriverWaybillService driverWaybillService,
MpUserDataScopeSupport mpUserDataScopeSupport) {
MpUserDataScopeSupport mpUserDataScopeSupport,
LoadingManageMapper loadingManageMapper) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
this.driverWaybillService = driverWaybillService;
this.mpUserDataScopeSupport = mpUserDataScopeSupport;
this.loadingManageMapper = loadingManageMapper;
}
@Override
@@ -102,7 +142,9 @@ public class ExceptionDisposalServiceImpl
@Override
public ExceptionDisposalVO detail(Long id) {
ExceptionDisposalVO vo = toVO(getExisting(id));
ExceptionDisposal entity = getExisting(id);
ExceptionDisposalVO vo = convertVO(entity);
fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
vo.setFollowRecords(followRecords(id));
return vo;
}
@@ -342,7 +384,7 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("跟进说明不能超过500字");
}
ExceptionDisposal disposal = getExisting(request.getId());
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("已完成的异常不允许继续跟进");
}
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
@@ -368,7 +410,7 @@ public class ExceptionDisposalServiceImpl
@Transactional(rollbackFor = Exception.class)
public void complete(Long id) {
ExceptionDisposal disposal = getExisting(id);
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("异常已完成,请勿重复操作");
}
disposal.setDisposalStatus(STATUS_COMPLETED);
@@ -390,7 +432,7 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("存在无效的异常记录");
}
for (ExceptionDisposal disposal : disposals) {
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("已完成的异常不允许重复完成");
}
disposal.setDisposalStatus(STATUS_COMPLETED);
@@ -398,6 +440,484 @@ public class ExceptionDisposalServiceImpl
updateBatchById(disposals);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reject(ExceptionDisposalRejectRequest request) {
if (request == null || request.getId() == null) {
throw new ServiceException("请选择需要拒绝的异常");
}
ExceptionDisposal disposal = getExisting(request.getId());
assertNotSettled(disposal);
String rejectReason = Func.toStr(request.getRejectReason(), "").trim();
if (Func.isBlank(rejectReason)) {
throw new ServiceException("请填写拒绝原因");
}
if (rejectReason.length() > REJECT_REASON_MAX_LENGTH) {
throw new ServiceException("拒绝原因不能超过200字");
}
String rejectContent = "拒绝:" + rejectReason;
// 拒绝弹窗只有原因、没有附件,attachments_json 不写
insertFollowRecord(disposal.getId(), rejectContent);
// 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的
disposal.setDisposalStatus(STATUS_COMPLETED);
disposal.setLatestFollowContent(rejectContent);
updateById(disposal);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void revoke(Long id) {
ExceptionDisposal disposal = getExisting(id);
Long currentUserId = AuthUtil.getUserId();
// 撤销是上报人收回自己的上报,不是一次处置
if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) {
throw new ServiceException("只能撤销本人上报的异常");
}
assertNotSettled(disposal);
// 不写跟进记录,也不动 latest_follow_content
disposal.setDisposalStatus(STATUS_REVOKED);
updateById(disposal);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void adjustRoute(ExceptionDisposalRouteAdjustRequest request) {
if (request == null || request.getId() == null) {
throw new ServiceException("请选择需要调整路线的异常");
}
ExceptionDisposal disposal = getExisting(request.getId());
assertNotSettled(disposal);
Waybill waybill = disposal.getWaybillId() == null ? null : waybillService.getById(disposal.getWaybillId());
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("关联运单不存在");
}
// 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体
LoadingManage loading = findLoading(waybill.getLoadingNo());
if (loading == null && Func.isNotBlank(waybill.getLoadingNo())) {
throw new ServiceException("运单已关联配载单,请在配载单中修改");
}
// 校验与拼装全部在写入之前完成
List<Station> stations = validateRouteNodes(request.getRouteNodes(), loading);
List<String> sourceAddresses = loading != null
? loadingStationAddresses(loading)
: waybillStationAddresses(waybill);
List<String> targetAddresses = stations.stream().map(Station::address).toList();
// 首站地址不可修改;原数据首站地址为空时跳过比对
if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0))
&& !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) {
throw new ServiceException("起始地址不允许修改");
}
String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus();
if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) {
throw new ServiceException("当前状态不允许变更运输路线");
}
RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses);
// 事务内顺序固定:跟进记录 → 载体路线 → 异常状态
insertFollowRecord(disposal.getId(), plan.followContent());
if (loading != null) {
applyLoadingRoute(loading, plan);
} else {
applyWaybillRoute(waybill, plan);
}
disposal.setDisposalStatus(STATUS_COMPLETED);
disposal.setLatestFollowContent(plan.followContent());
updateById(disposal);
}
/** 已了结(已完成 / 已撤销)的异常不允许再次处置 */
private void assertNotSettled(ExceptionDisposal disposal) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("该异常已处理");
}
}
/** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */
private void insertFollowRecord(Long disposalId, String content) {
ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
record.setDisposalId(disposalId);
record.setFollowContent(content);
record.setFollowUser(AuthUtil.getUserId());
record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
record.setFollowTime(LocalDateTime.now());
followRecordMapper.insert(record);
}
/** 配载单按配载单号取,逻辑删除的视为没有 */
private LoadingManage findLoading(String loadingNo) {
if (Func.isBlank(loadingNo)) {
return null;
}
return loadingManageMapper.selectOne(Wrappers.<LoadingManage>lambdaQuery()
.eq(LoadingManage::getLoadingNo, loadingNo.trim())
.eq(LoadingManage::getIsDeleted, 0)
.last("LIMIT 1"));
}
/**
* 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。
* <p>
* kind 由位置决定,必须与站序一致。
*/
private List<Station> validateRouteNodes(List<ExceptionDisposalRouteAdjustRequest.RouteNode> routeNodes,
LoadingManage loading) {
if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) {
throw new ServiceException("路线节点至少需要两个");
}
List<Station> stations = new ArrayList<>(routeNodes.size());
for (int index = 0; index < routeNodes.size(); index++) {
ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index);
if (node == null) {
throw new ServiceException("请完善路线节点");
}
String address = Func.toStr(node.getAddress(), "").trim();
if (Func.isBlank(address)) {
throw new ServiceException("站点地址不能为空");
}
String expectedKind = index == 0
? NODE_KIND_START
: (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT);
if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) {
throw new ServiceException("路线节点类型不正确");
}
stations.add(new Station(address, normalizedTags(node.getTags())));
}
assertTagsMatchChildWaybills(stations, loading);
return stations;
}
private List<String> normalizedTags(List<String> tags) {
List<String> normalized = new ArrayList<>();
if (tags == null) {
return normalized;
}
for (String tag : tags) {
String text = Func.toStr(tag, "").trim();
if (Func.isNotBlank(text)) {
normalized.add(text);
}
}
return normalized;
}
/**
* 站点标记里的数字是配载单运单集合里的位次(1 起),越界时拒绝。
* <p>
* 没有配载单时没有子运单序号,站点标记不参与落库。
*/
private void assertTagsMatchChildWaybills(List<Station> stations, LoadingManage loading) {
if (loading == null) {
return;
}
int childCount = childWaybillCount(loading.getWaybillIdsJson());
for (Station station : stations) {
for (String tag : station.tags()) {
int order = tagOrder(tag);
if (order < 1 || order > childCount) {
throw new ServiceException("路线节点未关联子运单");
}
}
}
}
/** 配载单运单集合的元素个数,即站点标记可用的位次上限 */
private int childWaybillCount(String waybillIdsJson) {
if (Func.isBlank(waybillIdsJson)) {
return 0;
}
try {
List<?> waybillIds = JsonUtil.parse(waybillIdsJson, List.class);
return waybillIds == null ? 0 : waybillIds.size();
} catch (Exception ignored) {
// 运单集合不是 JSON 数组时按没有子运单处理
return 0;
}
}
/** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */
private List<String> loadingStationAddresses(LoadingManage loading) {
List<String> addresses = new ArrayList<>();
for (Map<String, Object> row : loadingStationRows(loading)) {
addAddress(addresses, text(row.get("address")));
}
if (!addresses.isEmpty()) {
return addresses;
}
return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress());
}
/**
* 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。
* 段里缺地址时用站名替代。
*/
private List<String> waybillStationAddresses(Waybill waybill) {
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
if (segments.isEmpty()) {
return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress());
}
List<String> addresses = new ArrayList<>(segments.size() + 1);
Map<String, Object> firstSegment = segments.get(0);
addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")),
text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), "")));
for (Map<String, Object> segment : segments) {
addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), ""));
}
return addresses;
}
/** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */
private List<String> addressColumns(String departureAddress, String transitAddress, String arrivalAddress) {
List<String> addresses = new ArrayList<>(3);
addAddress(addresses, departureAddress);
if (Func.isNotBlank(transitAddress)) {
for (String transit : transitAddress.split("[,,]")) {
addAddress(addresses, transit.trim());
}
}
addAddress(addresses, arrivalAddress);
return addresses;
}
private void addAddress(List<String> addresses, String address) {
if (Func.isNotBlank(address)) {
addresses.add(address.trim());
}
}
/**
* 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。
* 长度口径与 PC 改路线一致。
*/
private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List<Station> stations,
List<String> sourceAddresses, List<String> targetAddresses) {
String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses));
String departureAddress = targetAddresses.get(0);
String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1);
String routeJson;
String transitAddress = null;
String sourceTaskInfoJson;
if (loading != null) {
routeJson = stationRowsJson(stations);
transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream()
.collect(Collectors.joining(TRANSIT_ADDRESS_JOINER));
sourceTaskInfoJson = loading.getTaskInfoJson();
} else {
// 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,不整份换成站点行
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
boolean addressChanged = !sourceAddresses.equals(targetAddresses);
// 段形状可识别时站数必须等于段数 + 1;认不出段形状时不改 route_json
if (addressChanged && !segments.isEmpty() && segments.size() + 1 != targetAddresses.size()) {
throw new ServiceException("无配载单的运单不支持增减途经站");
}
routeJson = addressChanged ? rewriteWaybillRouteSegments(segments, targetAddresses) : null;
sourceTaskInfoJson = waybill.getTaskInfoJson();
}
String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent);
TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字");
TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字");
TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字");
TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字");
return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress,
arrivalAddress, taskInfoJson);
}
/**
* 同一份结论既进跟进记录,也进 task_info_json 的变更记录:
* 写调整后的站点顺序(地址 → 地址 → …),不写「哪一站从什么改成什么」;
* 一个地址都没改时给固定的一句。
*/
private String routeChangeContent(List<String> sourceAddresses, List<String> targetAddresses) {
if (sourceAddresses.equals(targetAddresses)) {
return ROUTE_CHANGE_UNCHANGED;
}
List<String> parts = new ArrayList<>(targetAddresses.size());
for (String address : targetAddresses) {
parts.add(displayAddress(address));
}
return ROUTE_CHANGE_PREFIX + String.join(ROUTE_STATION_SEPARATOR, parts);
}
private String displayAddress(String address) {
return Func.isBlank(address) ? "-" : address;
}
/** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */
private String stationRowsJson(List<Station> stations) {
List<Map<String, Object>> rows = new ArrayList<>(stations.size());
for (Station station : stations) {
Map<String, Object> row = new LinkedHashMap<>(4);
row.put("address", station.address());
// 站点行没有独立的站名,展示地址与地址取同一份文本
row.put("displayAddress", station.address());
row.put("tags", station.tags());
// locked 只影响 PC 编辑弹窗能否拖动,司机端不读
row.put("locked", false);
rows.add(row);
}
return JsonUtil.toJson(rows);
}
/**
* 按路线段改写运单路线:每段的出发地对应第 i 站、到达地对应第 i+1 站,其它键(segmentNo、
* transportType、未知键)原样保留。
* <p>
* 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写;
* 站数与段数不匹配由调用方拒绝。
*/
private String rewriteWaybillRouteSegments(List<Map<String, Object>> segments, List<String> targetAddresses) {
if (segments.isEmpty()) {
return null;
}
List<Map<String, Object>> rewritten = new ArrayList<>(segments.size());
for (int index = 0; index < segments.size(); index++) {
Map<String, Object> segment = new LinkedHashMap<>(segments.get(index));
rewriteSegmentPoint(segment, "departure", targetAddresses.get(index));
rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
rewritten.add(segment);
}
return JsonUtil.toJson(rewritten);
}
/** 站点地址与站名写同一份文本 */
private void rewriteSegmentPoint(Map<String, Object> segment, String pointKey, String targetAddress) {
String currentAddress = firstNonBlank(text(segment.get(pointKey + "Address")),
text(segment.get(pointKey + "Name")), "");
if (Objects.equals(currentAddress, targetAddress)) {
return;
}
segment.put(pointKey + "Address", targetAddress);
segment.put(pointKey + "Name", targetAddress);
}
/**
* 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。
* 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。
*/
private List<Map<String, Object>> routeSegments(String routeJson) {
List<Map<String, Object>> elements = jsonObjectList(routeJson);
for (Map<String, Object> element : elements) {
if (element.containsKey("address") || element.containsKey("tags")) {
return new ArrayList<>();
}
if (!element.containsKey("departureAddress") && !element.containsKey("departureName")
&& !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) {
return new ArrayList<>();
}
}
return elements;
}
/**
* 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。
* 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建。
*/
private String appendRouteChangeRecord(String taskInfoJson, String content) {
Map<String, Object> taskInfo = parseTaskInfo(taskInfoJson);
List<Object> records = new ArrayList<>();
Map<String, Object> record = new LinkedHashMap<>(4);
record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now()));
record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
record.put("content", content);
record.put("remark", "");
records.add(record);
Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS);
if (existing instanceof List<?> existingRecords) {
records.addAll(existingRecords);
}
taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records);
return JsonUtil.toJson(taskInfo);
}
/** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建 */
private Map<String, Object> parseTaskInfo(String taskInfoJson) {
Map<String, Object> taskInfo = new LinkedHashMap<>();
if (Func.isBlank(taskInfoJson)) {
return taskInfo;
}
try {
Object parsed = JsonUtil.parse(taskInfoJson, Object.class);
if (parsed instanceof Map<?, ?> map) {
map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value));
}
} catch (Exception ignored) {
taskInfo.clear();
}
return taskInfo;
}
private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) {
loading.setRouteJson(plan.routeJson());
loading.setDepartureAddress(plan.departureAddress());
loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress()));
loading.setArrivalAddress(plan.arrivalAddress());
loading.setTaskInfoJson(plan.taskInfoJson());
if (loadingManageMapper.updateById(loading) <= 0) {
throw new ServiceException("变更运输路线失败");
}
}
private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) {
if (Func.isNotBlank(plan.routeJson())) {
waybill.setRouteJson(plan.routeJson());
}
waybill.setDepartureAddress(plan.departureAddress());
waybill.setArrivalAddress(plan.arrivalAddress());
waybill.setTaskInfoJson(plan.taskInfoJson());
if (!waybillService.updateById(waybill)) {
throw new ServiceException("变更运输路线失败");
}
}
/** 解析成对象数组;坏 JSON 与空值都按空数组处理 */
private List<Map<String, Object>> jsonObjectList(String json) {
List<Map<String, Object>> result = new ArrayList<>();
if (Func.isBlank(json)) {
return result;
}
try {
Object parsed = JsonUtil.parse(json, List.class);
if (parsed instanceof List<?> list) {
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);
}
}
}
} catch (Exception ignored) {
return new ArrayList<>();
}
return result;
}
private String text(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private String firstNonBlank(String addressValue, String nameValue, String fallbackValue) {
if (Func.isNotBlank(addressValue)) {
return addressValue;
}
return Func.isNotBlank(nameValue) ? nameValue : fallbackValue;
}
/** 规范化后的一个站点:地址 + 该站的装卸标记 */
private record Station(String address, List<String> tags) {
}
/** 一次路线调整要落库的全部内容 */
private record RouteChangePlan(String followContent,
String routeJson,
String departureAddress,
String transitAddress,
String arrivalAddress,
String taskInfoJson) {
}
private LambdaQueryWrapper<ExceptionDisposal> buildQuery(ExceptionDisposalVO query) {
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getIsDeleted, 0)
@@ -429,7 +949,14 @@ public class ExceptionDisposalServiceImpl
return disposal;
}
/** 列表与上报结果共用的转换:不含详情页的配载单号、子运单号与可调整站点 */
private ExceptionDisposalVO toVO(ExceptionDisposal entity) {
ExceptionDisposalVO vo = convertVO(entity);
fillRouteAndCargo(vo, entity.getWaybillId());
return vo;
}
private ExceptionDisposalVO convertVO(ExceptionDisposal entity) {
ExceptionDisposalVO vo = BeanUtil.copyProperties(entity, ExceptionDisposalVO.class);
if (vo == null) {
return null;
@@ -438,18 +965,17 @@ public class ExceptionDisposalServiceImpl
vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser()));
vo.setDisposalStatusName(statusName(entity.getDisposalStatus()));
vo.setScenePhotoList(splitPhotos(entity.getScenePhotos()));
fillRouteAndCargo(vo, entity.getWaybillId());
return vo;
}
/** 按关联运单补齐详情页路线 / 货物展示字段 */
private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
/** 按关联运单补齐路线与货物展示字段,返回本次取到的运单;运单不存在时返回 null */
private Waybill fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
if (vo == null || waybillId == null) {
return;
return null;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
return;
return null;
}
Map<String, String> route = new HashMap<>(2);
route.put("start", Func.toStr(waybill.getDepartureName(), ""));
@@ -460,6 +986,213 @@ public class ExceptionDisposalServiceImpl
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
vo.setCargo(cargo);
return waybill;
}
/**
* 详情页的可编辑站点与配载单信息。
* <p>
* 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。
* 运单不存在时三项各给空值。
*/
private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) {
if (vo == null) {
return;
}
if (waybill == null) {
vo.setChildWaybillNos(List.of());
vo.setRouteNodes(List.of());
return;
}
LoadingManage loading = findLoading(waybill.getLoadingNo());
if (loading == null) {
vo.setChildWaybillNos(new ArrayList<>());
vo.setRouteNodes(waybillRouteNodes(waybill));
return;
}
Map<Integer, Waybill> childWaybills = childWaybillsByOrder(loading);
vo.setLoadingNo(loading.getLoadingNo());
vo.setChildWaybillNos(childWaybillNos(childWaybills));
List<Map<String, Object>> rows = loadingStationRows(loading);
vo.setRouteNodes(loadingRouteNodes(rows, childWaybills));
vo.setRoute(loadingRoute(rows, loading, waybill));
}
/** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */
private List<Map<String, Object>> loadingStationRows(LoadingManage loading) {
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> row : jsonObjectList(loading.getRouteJson())) {
if (Func.isNotBlank(text(row.get("address")))) {
rows.add(row);
}
}
return rows;
}
private List<ExceptionDisposalVO.RouteNode> loadingRouteNodes(List<Map<String, Object>> rows,
Map<Integer, Waybill> childWaybills) {
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
List<String> tags = stringList(row.get("tags"));
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
node.setAddress(text(row.get("address")));
node.setTags(tags);
node.setProjects(stationProjects(tags, childWaybills));
nodes.add(node);
}
return stationKinds(nodes);
}
/**
* 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。
* 地址口径与改路线时的比对一致(地址优先、站名兜底)。
*/
private List<ExceptionDisposalVO.RouteNode> waybillRouteNodes(Waybill waybill) {
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>();
for (String address : waybillStationAddresses(waybill)) {
if (Func.isBlank(address)) {
continue;
}
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
node.setAddress(address);
node.setTags(new ArrayList<>());
node.setProjects(new ArrayList<>());
nodes.add(node);
}
return stationKinds(nodes);
}
/** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */
private List<ExceptionDisposalVO.RouteNode> stationKinds(List<ExceptionDisposalVO.RouteNode> nodes) {
if (nodes.size() < 2) {
return new ArrayList<>();
}
for (int index = 0; index < nodes.size(); index++) {
nodes.get(index).setKind(index == 0 ? NODE_KIND_START
: (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT));
}
return nodes;
}
/**
* 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。
* 标记对不上子运单(越界、子运单已不存在)时跳过。
*/
private List<String> stationProjects(List<String> tags, Map<Integer, Waybill> childWaybills) {
Set<String> projects = new LinkedHashSet<>();
for (String tag : tags) {
Waybill child = childWaybills.get(tagOrder(tag) - 1);
if (child == null) {
continue;
}
String projectName = Func.toStr(child.getProjectName(), "").trim();
if (Func.isNotBlank(projectName)) {
projects.add(projectName);
}
}
return new ArrayList<>(projects);
}
/**
* 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。
* 位次不因缺子运单而前移。
*/
private Map<Integer, Waybill> childWaybillsByOrder(LoadingManage loading) {
Map<Integer, Waybill> childWaybills = new LinkedHashMap<>();
List<Long> waybillIds = waybillIds(loading.getWaybillIdsJson());
List<Long> queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList();
if (queryIds.isEmpty()) {
return childWaybills;
}
Map<Long, Waybill> waybillsById = new HashMap<>();
List<Waybill> found = waybillService.listByIds(queryIds);
if (found != null) {
for (Waybill child : found) {
if (child != null && !Objects.equals(child.getIsDeleted(), 1)) {
waybillsById.put(child.getId(), child);
}
}
}
for (int order = 0; order < waybillIds.size(); order++) {
Waybill child = waybillsById.get(waybillIds.get(order));
if (child != null) {
childWaybills.put(order, child);
}
}
return childWaybills;
}
/** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */
private List<Long> waybillIds(String waybillIdsJson) {
List<Long> waybillIds = new ArrayList<>();
if (Func.isBlank(waybillIdsJson)) {
return waybillIds;
}
try {
Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
if (parsed instanceof List<?> list) {
for (Object item : list) {
long waybillId = Func.toLong(text(item), 0L);
waybillIds.add(waybillId > 0 ? waybillId : null);
}
}
} catch (Exception ignored) {
// 运单集合不是 JSON 数组时按没有子运单处理
return new ArrayList<>();
}
return waybillIds;
}
/** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */
private List<String> childWaybillNos(Map<Integer, Waybill> childWaybills) {
List<String> childWaybillNos = new ArrayList<>(childWaybills.size());
for (Waybill child : childWaybills.values()) {
String waybillNo = Func.toStr(child.getWaybillNo(), "").trim();
if (Func.isNotBlank(waybillNo)) {
childWaybillNos.add(waybillNo);
}
}
return childWaybillNos;
}
/** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */
private Map<String, String> loadingRoute(List<Map<String, Object>> rows, LoadingManage loading, Waybill waybill) {
String start;
String end;
if (rows.isEmpty()) {
start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), "");
end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), "");
} else {
start = stationDisplayAddress(rows.get(0));
end = stationDisplayAddress(rows.get(rows.size() - 1));
}
Map<String, String> route = new HashMap<>(2);
route.put("start", start);
route.put("end", end);
return route;
}
private String stationDisplayAddress(Map<String, Object> row) {
return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), "");
}
/** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */
private int tagOrder(String tag) {
String digits = Func.toStr(tag, "").replaceAll("\\D+", "");
return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0);
}
private List<String> stringList(Object value) {
List<String> texts = new ArrayList<>();
if (value instanceof List<?> list) {
for (Object item : list) {
String text = text(item);
if (Func.isNotBlank(text)) {
texts.add(text);
}
}
}
return texts;
}
private String formatWeight(BigDecimal quantity, String unit) {
@@ -494,6 +1227,7 @@ public class ExceptionDisposalServiceImpl
case STATUS_PENDING -> "待处理";
case STATUS_PROCESSING -> "处理中";
case STATUS_COMPLETED -> "已完成";
case STATUS_REVOKED -> "已撤销";
default -> "";
};
}
@@ -114,6 +114,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String TODO_REASSIGN = "reassign";
/** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */
private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0";
/** 异常条目跳转页码:参数 id 是异常处置记录主键,该页直接按 id 拉详情 */
private static final String EXCEPTION_TARGET_URL = "/subpackages/admin/disposal-detail?id=%s";
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
@@ -726,13 +728,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return loading == null || loading.getId() == null ? fallback : loading.getId();
}
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
/**
* 分组查询的关键字:逐行模糊匹配运单号 / 司机 / 车牌 / 项目。
* <p>
* 这里是逐行 LIKE,不是运次级匹配:命中哪一行只决定这趟车要不要出现,不缩小它的子单集合。
*/
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key)
.or().like("vehicle_no", key).or().like("project_name", key));
}
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
@@ -744,6 +751,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setId(waybill.getId());
detail.setWaybillNo(waybill.getWaybillNo());
detail.setStatus(toAppStatus(waybill.getBusinessStatus()));
detail.setBusinessStatus(waybill.getBusinessStatus());
String mode = Func.toStr(waybill.getTransportType(), "");
detail.setTransportMode(mode);
@@ -927,7 +935,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
/**
* 系统用户可见组织:普通账号只出所属组织,超管不过滤;组织为空不拦截。
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
* <p>
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
* 详情与重新派单本身不走这里。
*/
private List<Long> manageableDeptIds() {
if (TransportBusinessSupport.isAdministrator()) {
@@ -1011,6 +1022,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime()));
card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
card.setBusinessStatus(waybill.getBusinessStatus());
card.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
card.setDriverName(Func.toStr(waybill.getDriverName(), ""));
card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
@@ -1068,8 +1080,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
return switch (businessStatus) {
case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0;
case STATUS_RUNNING -> 1;
case WaybillProcessSupport.STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
// 数字枚举与司机端 toAppStatus 同源:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消;
// 待回单确认属进行中,落 1。给 null 时前端显示「未知」。
case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
@@ -1220,8 +1234,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
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);
// 待处理事项的「立即处置」直接落到该条异常的处置详情页,不再先到异常列表
item.setTargetUrl(String.format(EXCEPTION_TARGET_URL, disposal.getId()));
return item;
}
@@ -362,7 +362,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
Map<String, BigDecimal> dispatchedGoods = dispatchedGoods(routeWaybills, routePlans);
route.put("dispatchedQuantity", dispatchedGoods.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
route.put("dispatchedGoods", dispatchedGoods);
route.put("arrivedQuantity", routeWaybills.stream().filter(item -> "completed".equals(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
route.put("arrivedQuantity", routeWaybills.stream().filter(item -> arrived(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
route.put("waybills", routeWaybills);
route.put("transportPlans", routePlans);
}
@@ -541,12 +541,22 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
}
private BigDecimal completedQuantity(String masterNo, Map<String, Object> route) {
return waybillsByMasterNo(masterNo).stream()
.filter(item -> "completed".equals(item.getBusinessStatus()))
.filter(item -> arrived(item.getBusinessStatus()))
.filter(item -> belongsToRoute(item, route))
.flatMap(item -> waybillGoods(item).stream())
.map(item -> decimal(item, "quantity"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* 已到达口径:待回单确认也算到达——货物确实到了,回单只是单据。
* 不计入会按「调度总量不能大于上一段已到达数量」把多联总单的下一段挡在门外。
*/
static boolean arrived(String businessStatus) {
return "completed".equals(businessStatus)
|| WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
}
private Map<String, BigDecimal> availableGoods(MasterOrder masterOrder) {
Map<String, BigDecimal> available = new LinkedHashMap<>();
for (Map<String, Object> goods : parseArray(masterOrder.getGoodsJson())) available.merge(goodsKey(goods), decimal(goods, "quantity"), BigDecimal::add);
@@ -61,6 +61,7 @@ import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -3295,9 +3296,9 @@ public class ReceivablePayableDetailServiceImpl
private String waybillStatusName(String status) {
return switch (status == null ? "" : status) {
case "completed" -> "已完成";
case "pending_return" -> "待确认回单";
case "processing", "running", "in_progress", "inProgress" -> "进行中";
case "pending", "created" -> "待执行";
case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认";
case "cancelled", "canceled" -> "已取消";
default -> status;
};
@@ -45,6 +45,7 @@ final class TripAggregation {
static final String RUNNING = "running";
static final String COMPLETED = "completed";
static final String CANCELLED = "cancelled";
static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
@@ -53,9 +54,10 @@ final class TripAggregation {
/**
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
* 否则「子单全已取消」会被算成运输中而不是已取消。
* 待回单确认要进:子单漏在运次外,剩下那几张会被判成整趟车已走完。
*/
static final List<String> CHILD_STATUSES = List.of(
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, PENDING_RETURN_CONFIRM, COMPLETED, CANCELLED
);
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
@@ -90,10 +90,14 @@ public class TripQueryServiceImpl implements ITripQueryService {
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
/** 关键字在子单上匹配的字段 */
/**
* 关键字在子单上匹配的字段。
* <p>
* project_name 是运单创建时写入的快照列:项目改名后旧运单仍是旧名,按新名搜不到这些单。
*/
private static final String[] KEYWORD_COLUMNS = {
"waybill_no", "loading_no", "departure_name", "arrival_name",
"departure_address", "arrival_address", "vehicle_no"
"departure_address", "arrival_address", "vehicle_no", "driver_name", "project_name"
};
private final IWaybillService waybillService;
@@ -531,10 +531,33 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
// 只有显式传 false 才是取消打卡,未传按已打卡处理
boolean punched = !Boolean.FALSE.equals(request.getPunched());
if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
return saveEnroutePunchRecord(waybill, request, punched);
// 待回单确认的运单司机已无待打卡节点,再补一条打卡流水与状态含义矛盾;
// 漏打的出路是先取消该节点的打卡退回进行中(punched=false),再重新补录。
if (punched && WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
.equalsIgnoreCase(waybill.getBusinessStatus())) {
throw new ServiceException("运单待业务员确认回单,不能补录打卡");
}
return saveNodePunchRecord(waybill, request, punched);
if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
return revertIfReturnConfirmPreconditionLost(waybill, punched,
saveEnroutePunchRecord(waybill, request, punched));
}
return revertIfReturnConfirmPreconditionLost(waybill, punched,
saveNodePunchRecord(waybill, request, punched));
}
/**
* 取消打卡撤掉了「司机已无待打卡节点」这个前提,停在待回单确认的运单退回进行中。
* <p>
* 只认取消打卡后的待回单确认,其它状态一律不动。
*/
private boolean revertIfReturnConfirmPreconditionLost(Waybill waybill, boolean punched, boolean saved) {
if (!saved || punched
|| !WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
.equalsIgnoreCase(waybill.getBusinessStatus())) {
return saved;
}
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING);
return updateById(waybill);
}
/**
@@ -962,14 +985,16 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
/**
* 运单是否已越过接单阶段。
* <p>
* 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、
* 正向列举进行中/已完成/已取消/待回单确认,不能用「不是终态」取反:终态里还包含草稿、待调度、
* 调度中这些并未越过接单的阶段,取反会把它们判成已接单。
* <p>
* 待回单确认下司机已打完末位卡,无需接单配置时接单节点不该再显示未打卡。
*/
private static boolean passedAcceptStage(String businessStatus) {
return "running".equals(businessStatus)
|| WaybillProcessSupport.STATUS_PENDING_RETURN.equals(businessStatus)
|| "completed".equals(businessStatus)
|| "cancelled".equals(businessStatus);
|| "cancelled".equals(businessStatus)
|| WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
}
private String formatPunchTime(Date time) {
@@ -1064,10 +1089,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setLoadingNo(null);
waybill.setMileageRemark(null);
}
// 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致
// 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致;
// 编辑保存只改资料,business_status 以库里的值为准,请求体里的值不回写
String preservedBusinessStatus = created
? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus())
: null;
: editPreservedBusinessStatus(oldRecord);
if (isCompletedBusinessStatus(preservedBusinessStatus)) {
preservedBusinessStatus = "completed";
}
@@ -1089,6 +1115,17 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
}
/**
* 编辑保存要回写的业务状态:取库里的值。
* 草稿返回 null —— submit 即正式提交,草稿须由过程校正提升为待执行 / 进行中。
*/
private String editPreservedBusinessStatus(Waybill oldRecord) {
String currentStatus = oldRecord == null
? null
: TransportBusinessSupport.trimToNull(oldRecord.getBusinessStatus());
return STATUS_DRAFT.equals(currentStatus) ? null : currentStatus;
}
private void fillCustomerName(Waybill waybill) {
if (Func.isNotEmpty(waybill.getContractId())) {
ContractManage contract = contractManageService.getById(waybill.getContractId());
@@ -1509,21 +1546,14 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Transactional(rollbackFor = Exception.class)
public boolean confirmReturnReceipt(Long id) {
Waybill waybill = loadEditable(id, true);
String status = waybill.getBusinessStatus();
// pending_return 为正规待确认态;running/processing 兼容历史「打完卡仍停在进行中」的数据
if (!WaybillProcessSupport.STATUS_PENDING_RETURN.equals(status)
&& !"running".equals(status)
&& !"processing".equals(status)) {
// 判据只看运单当前状态,不看回单配置当前值:配置改回「无需确认」后,
// 已停在待回单确认的运单仍要能确认回单
if (!WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(waybill.getBusinessStatus())) {
throw new ServiceException("当前状态不允许确认回单");
}
String processJson = LiveProcessConfigSupport.resolve(
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
waybill.getProcessJson());
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
// 否则用户会以为是确认没生效
throw new ServiceException("该运单的回单不需要业务员确认");
}
assertPunchNodesFinished(waybill.getId(), processJson);
assertNoUnresolvedException(waybill.getId());
return doComplete(waybill);
@@ -1594,8 +1624,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
BusinessRemoveResultVO result = new BusinessRemoveResultVO();
for (Waybill waybill : listByIds(idList)) {
try {
// 待确认回单走确认回单校验(打卡节点 + 未了结异常);其它状态仍走普通完成
if (WaybillProcessSupport.STATUS_PENDING_RETURN.equals(waybill.getBusinessStatus())) {
// 待回单确认走确认回单校验(打卡节点 + 未了结异常);其它状态仍走普通完成
if (WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(waybill.getBusinessStatus())) {
confirmReturnReceipt(waybill.getId());
} else {
complete(waybill.getId());
@@ -39,12 +39,12 @@ import org.springframework.stereotype.Component;
* 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存
* 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。
* <p>
* 两个开关默认开启,可关掉以回退到不校验认证的行为:
* 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截:
* <pre>
* transport:
* certification:
* vehicle-gate-enabled: false
* driver-gate-enabled: false
* vehicle-gate-enabled: true
* driver-gate-enabled: true
* </pre>
*
* @author Chill
@@ -57,14 +57,14 @@ public class CertificationGate {
/**
* 车辆认证门槛开关
*/
@Value("${transport.certification.vehicle-gate-enabled:true}")
private boolean vehicleGateEnabled = true;
@Value("${transport.certification.vehicle-gate-enabled:false}")
private boolean vehicleGateEnabled = false;
/**
* 司机认证门槛开关
*/
@Value("${transport.certification.driver-gate-enabled:true}")
private boolean driverGateEnabled = true;
@Value("${transport.certification.driver-gate-enabled:false}")
private boolean driverGateEnabled = false;
/**
* 判定车辆是否可用
@@ -49,8 +49,8 @@ public final class WaybillProcessSupport {
public static final String STATUS_PENDING = "pending";
public static final String STATUS_RUNNING = "running";
/** 司机打卡已完成、待运管员确认回单 */
public static final String STATUS_PENDING_RETURN = "pending_return";
/** 司机已打完末位卡、回单需业务员确认,尚未生成应收应付 */
public static final String STATUS_PENDING_RETURN_CONFIRM = "pending_return_confirm";
public static final String ACCEPT_PENDING = "pending";
public static final String ACCEPT_ACCEPTED = "accepted";
public static final String ACCEPT_REJECTED = "rejected";
@@ -136,17 +136,16 @@ public final class WaybillProcessSupport {
}
/**
* 运单已结束:已完成、已取消,或待确认回单。此后不再接受司机打卡。
* 运单已结束:已完成、已取消,或已打完末位卡停在待回单确认。此后不再接受司机打卡。
* <p>
* 待确认回单表示司机侧节点已打完,等待运管确认,不能再打卡。
* 草稿、待执行、待调度、调度中的运单司机仍要能打卡,
* 只认这三个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
*/
public static boolean isPunchEnded(String businessStatus) {
String status = stringVal(businessStatus);
return "completed".equalsIgnoreCase(status)
|| "cancelled".equalsIgnoreCase(status)
|| STATUS_PENDING_RETURN.equalsIgnoreCase(status);
|| STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status);
}
/**
@@ -160,8 +159,8 @@ public final class WaybillProcessSupport {
if ("cancelled".equalsIgnoreCase(status)) {
return "运单已取消";
}
if (STATUS_PENDING_RETURN.equalsIgnoreCase(status)) {
return "运单待确认回单";
if (STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status)) {
return "运单待业务员确认回单";
}
return null;
}
@@ -203,7 +202,7 @@ public final class WaybillProcessSupport {
* 上报异常前的运单状态门禁原因;可上报时返回 null。
* <p>
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
* 结束优先于未开始:已结束的运单先说结束,不再提「等你上路」。
*/
public static String reportBlockedReason(String businessStatus) {
String endedReason = punchEndedReason(businessStatus);
@@ -222,7 +221,7 @@ public final class WaybillProcessSupport {
|| "cancelled".equals(businessStatus)
|| "waiting_dispatch".equals(businessStatus)
|| "dispatching".equals(businessStatus)
|| STATUS_PENDING_RETURN.equals(businessStatus);
|| STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
}
/**
@@ -230,7 +229,7 @@ public final class WaybillProcessSupport {
* <p>
* 需要接单(接单节点 confirmMode=yes)且尚未接单(未响应 / 已拒绝)→ pending(待执行);
* 已接单 → running(进行中);不需要接单 → running(仅当当前为空或 pending 时提升)。
* draft / completed / cancelled / pending_return 等终态或调度中间态不改动。
* draft / completed / cancelled / pending_return_confirm 等终态或调度中间态不改动。
*/
public static String normalizeBusinessStatus(String businessStatus, String processJson) {
return normalizeBusinessStatus(businessStatus, processJson, null);
@@ -122,7 +122,7 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
case "waiting_dispatch" -> "待调度";
case "dispatching" -> "调度中";
case "running", "processing", "in_progress", "inProgress" -> "进行中";
case "pending_return" -> "待确认回单";
case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认";
case "completed" -> "已完成";
case "cancelled" -> "已取消";
case "closed" -> "已关闭";