小程序对接

This commit is contained in:
2026-09-11 14:39:10 +08:00
parent 93c7e6f8f5
commit aa8b7ade99
40 changed files with 3850 additions and 39 deletions
@@ -0,0 +1,69 @@
/**
* 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.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
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.vo.DriverVehicleCardVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverAppService;
import org.springframework.web.bind.annotation.GetMapping;
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/driver/**}
* 同时兼容未去前缀直连 {@code /blade-transport/driver/**}。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/driver", "/blade-transport/driver"})
@Tag(name = "司机端档案", description = "小程序司机个人档案")
public class DriverAppController extends BladeController {
private final IDriverAppService driverAppService;
@GetMapping("/mine")
@ApiOperationSupport(order = 1)
@Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析")
public R<DriverVO> mine(@RequestParam(required = false) String mobile) {
return R.data(driverAppService.currentByPhone(mobile));
}
@GetMapping("/vehicles")
@ApiOperationSupport(order = 2)
@Operation(summary = "当前司机车辆列表", description = "按司机 driving_vehicle 车牌匹配 blade_transport_vehicle")
public R<List<DriverVehicleCardVO>> vehicles() {
return R.data(driverAppService.myVehicles());
}
}
@@ -0,0 +1,156 @@
/**
* 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.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
import org.springblade.transport.service.IDriverWaybillService;
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;
/**
* 司机端运单接口(小程序)
* <p>
* 对外完整路径:{@code /api/blade-transport/waybill/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /waybill/**})。
* 同时兼容未去前缀直连({@code /blade-transport/waybill/**}),避免 404。
* 不挂管理端菜单鉴权,仅需登录态(Blade Secure)。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/waybill", "/blade-transport/waybill"})
@Tag(name = "司机端运单", description = "小程序司机端运单")
public class DriverWaybillController extends BladeController {
private final IDriverWaybillService driverWaybillService;
@GetMapping("/current-task")
@ApiOperationSupport(order = 1)
@Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单")
public R<DriverWaybillCardVO> currentTask() {
return R.data(driverWaybillService.currentTask());
}
@GetMapping("/pending-preview")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数")
public R<DriverWaybillPreviewVO> pendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.pendingPreview(size));
}
@GetMapping("/counts")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成")
public R<DriverWaybillTabCountsVO> counts() {
return R.data(driverWaybillService.tabCounts());
}
@GetMapping("/page")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成")
public R<IPage<DriverWaybillCardVO>> page(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "关键字:运单号/起终点") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.page(current, size, statusCode, keyword));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 5)
@Operation(summary = "司机运单详情", description = "返回 requireAccept、在途打卡可见性(transitCheckinVisible / requireTransitCheckinToday)等字段")
public R<DriverWaybillCardVO> detail(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(driverWaybillService.detail(id));
}
@PostMapping("/accept")
@ApiOperationSupport(order = 6)
@Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中")
public R accept(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.status(driverWaybillService.accept(id));
}
@PostMapping("/reject")
@ApiOperationSupport(order = 7)
@Operation(summary = "司机拒绝接单", description = "过程配置接单为「是」时,司机可拒绝接单,运单保持待执行并记录拒单")
public R reject(
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
return R.status(driverWaybillService.reject(id, reason));
}
@PostMapping("/enroute/submit")
@ApiOperationSupport(order = 8)
@Operation(summary = "提交在途打卡", description = "过程配置在途节点 punch=是,且满足频次/时段时允许提交")
public R<DriverEnrouteRecordVO> submitEnroute(@RequestBody EnrouteSubmitDTO dto) {
return R.data(driverWaybillService.submitEnroute(dto));
}
@PostMapping("/node/submit")
@ApiOperationSupport(order = 9)
@Operation(summary = "提交过程节点打卡", description = "到场/装货/发货/到货/卸货/签收等 punch=是;在途请走 /enroute/submit")
public R<DriverNodePunchVO> submitNode(@RequestBody NodeSubmitDTO dto) {
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)) {
return null;
}
try {
return Integer.valueOf(status.trim());
} catch (NumberFormatException ex) {
return null;
}
}
}
@@ -33,6 +33,7 @@ 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;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springframework.web.bind.annotation.GetMapping;
@@ -44,13 +45,14 @@ import org.springframework.web.bind.annotation.RestController;
/**
* 异常处置控制器
*
* @author Chill
* <p>
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
* 司机上报(submit)/ 列表 / 详情仅需登录态;跟进与完成保留菜单鉴权。
*/
@RestController
@AllArgsConstructor
@PreAuth(menu = "exception_disposal")
@RequestMapping("/exception-disposal")
@RequestMapping({"/exception-disposal", "/blade-transport/exception-disposal"})
@Tag(name = "异常处置", description = "异常处置")
public class ExceptionDisposalController extends BladeController {
@@ -70,8 +72,16 @@ public class ExceptionDisposalController extends BladeController {
return R.data(exceptionDisposalService.detail(id));
}
@PostMapping("/follow")
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "异常上报", description = "司机端上报异常;上报人取登录态,运单信息按 waybillId/waybillNo 回填")
public R<ExceptionDisposalVO> submit(@RequestBody ExceptionDisposal request) {
return R.data(exceptionDisposalService.submitReport(request));
}
@PostMapping("/follow")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 4)
@Operation(summary = "异常跟进")
public R follow(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.follow(request);
@@ -79,7 +89,8 @@ public class ExceptionDisposalController extends BladeController {
}
@PostMapping("/complete")
@ApiOperationSupport(order = 4)
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 5)
@Operation(summary = "完成异常")
public R complete(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.complete(request.getId());
@@ -87,7 +98,8 @@ public class ExceptionDisposalController extends BladeController {
}
@PostMapping("/batch-complete")
@ApiOperationSupport(order = 5)
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 6)
@Operation(summary = "批量完成异常")
public R batchComplete(@RequestParam String ids) {
exceptionDisposalService.batchComplete(ids);
@@ -53,6 +53,7 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
import org.springblade.transport.service.IContractManageService;
@@ -100,6 +101,14 @@ public class WaybillController extends BladeController {
return R.data(waybillService.detail(id));
}
@GetMapping("/punch-records")
@ApiOperationSupport(order = 1)
@Operation(summary = "打卡记录与司机上传", description = "返回节点/在途打卡流水,以及司机上传凭证图(label=节点-凭证类型)")
public R<WaybillPunchRecordsVO> punchRecords(
@Parameter(description = "运单ID", required = true) @RequestParam Long waybillId) {
return R.data(waybillService.listPunchRecords(waybillId));
}
@GetMapping("/list")
@ApiOperationSupport(order = 2)
@Operation(summary = "分页", description = "传入waybill")
@@ -273,9 +282,9 @@ public class WaybillController extends BladeController {
@PostMapping("/reassign")
@ApiOperationSupport(order = 20)
@Operation(summary = "重新派单", description = "传入id")
public R reassign(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.status(waybillService.reassign(id));
@Operation(summary = "重新派单", description = "传入运单ID及新的司机、手机号、车牌")
public R reassign(@RequestBody Waybill waybill) {
return R.status(waybillService.reassign(waybill));
}
@PostMapping("/complete")
@@ -0,0 +1,16 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
/**
* 运单在途打卡 Mapper
*/
@Mapper
public interface WaybillEnroutePunchMapper extends BaseMapper<WaybillEnroutePunch> {
}
@@ -0,0 +1,16 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
/**
* 运单过程节点打卡 Mapper
*/
@Mapper
public interface WaybillNodePunchMapper extends BaseMapper<WaybillNodePunch> {
}
@@ -0,0 +1,47 @@
/**
* 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 org.springblade.transport.pojo.vo.DriverVehicleCardVO;
import org.springblade.transport.pojo.vo.DriverVO;
import java.util.List;
/**
* 司机端档案服务(小程序)
*/
public interface IDriverAppService {
/**
* 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile
*
* @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析
*/
DriverVO currentByPhone(String mobile);
/**
* 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no
*/
List<DriverVehicleCardVO> myVehicles();
}
@@ -0,0 +1,96 @@
/**
* 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.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
/**
* 司机端运单服务(小程序首页 / 列表)
*/
public interface IDriverWaybillService {
/**
* 当前登录司机的运输中任务(最多一条)
*/
DriverWaybillCardVO currentTask();
/**
* 当前登录司机的待接运单预览
*
* @param size 预览条数,默认 2
*/
DriverWaybillPreviewVO pendingPreview(Integer size);
/**
* 列表 Tab 统计:全部 / 待接单 / 进行中 / 已完成
*/
DriverWaybillTabCountsVO tabCounts();
/**
* 司机运单分页
*
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
* @param keyword 关键字(运单号 / 起终点,可选)
*/
IPage<DriverWaybillCardVO> page(Integer current, Integer size, Integer status, String keyword);
/**
* 司机运单详情(含是否需要确认接单 requireAccept、在途打卡可见性等)
*/
DriverWaybillCardVO detail(Long id);
/**
* 司机确认接单:过程配置要求接单且尚未接单时,写入接单记录并将运单改为进行中。
*/
boolean accept(Long id);
/**
* 司机拒绝接单:过程配置要求接单且尚未接单时,写入拒单记录,运单保持待执行。
*/
boolean reject(Long id, String reason);
/**
* 提交在途打卡(过程配置在途节点 punch=是,且满足频次/时段)。
*/
DriverEnrouteRecordVO submitEnroute(EnrouteSubmitDTO dto);
/**
* 提交过程节点打卡(到场/装货/卸货/签收等 punch=是;在途请走 submitEnroute)。
*/
DriverNodePunchVO submitNode(NodeSubmitDTO dto);
/**
* 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。
*/
boolean complete(Long id);
}
@@ -39,6 +39,11 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
ExceptionDisposalVO detail(Long id);
/**
* 司机端异常上报:写入 pending 记录,返回新建详情(含 id)
*/
ExceptionDisposalVO submitReport(ExceptionDisposal request);
void follow(ExceptionDisposalFollowRequest request);
void complete(Long id);
@@ -28,6 +28,7 @@ import org.springblade.transport.excel.WaybillExcel;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
@@ -42,6 +43,13 @@ public interface IWaybillService extends BaseService<Waybill> {
IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill);
WaybillVO detail(Long id);
/**
* 管理端:运单打卡记录 + 司机上传凭证图(label=节点-凭证类型)
*/
WaybillPunchRecordsVO listPunchRecords(Long waybillId);
Waybill syncDriverAcceptState(Waybill waybill);
boolean submit(Waybill waybill);
boolean saveDraft(Waybill waybill);
BusinessRemoveResultVO removeWaybill(String ids);
@@ -51,8 +59,15 @@ public interface IWaybillService extends BaseService<Waybill> {
boolean changeRoute(Waybill waybill);
boolean maintainMileage(WaybillMileageRequest request);
boolean cancel(Long id);
boolean reassign(Long id);
boolean reassign(Waybill waybill);
boolean complete(Long id);
/**
* 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
* (改状态 + 生成应收应付明细 + 尝试完成配载单)。
*/
boolean completeWithoutDeptCheck(Long id);
BusinessRemoveResultVO batchComplete(String ids);
LoadingManageVO roadLoading(String ids);
@@ -0,0 +1,210 @@
/**
* 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.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.feign.IUserClient;
import org.springblade.system.pojo.entity.User;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverAppService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.wrapper.DriverWrapper;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 司机端档案服务实现
*/
@Service
@RequiredArgsConstructor
public class DriverAppServiceImpl implements IDriverAppService {
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final IUserClient userClient;
@Override
public DriverVO currentByPhone(String mobile) {
Driver driver = currentDriver(mobile);
return driver == null ? null : DriverWrapper.build().entityVO(driver);
}
@Override
public List<DriverVehicleCardVO> myVehicles() {
Driver driver = currentDriver(null);
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
return List.of();
}
List<String> plates = splitPlates(driver.getDrivingVehicle());
if (plates.isEmpty()) {
return List.of();
}
// 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌)
List<TransportVehicle> vehicles = transportVehicleService.list(Wrappers.<TransportVehicle>lambdaQuery()
.and(w -> {
w.in(TransportVehicle::getPlateNo, plates);
for (String plate : plates) {
w.or().apply(
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'',''),'-',''),' ','') = {0}",
plate
);
}
})
.orderByDesc(TransportVehicle::getUpdateTime));
// 去重(精确与规范化可能命中同一条)
Map<Long, TransportVehicle> uniq = new LinkedHashMap<>();
for (TransportVehicle vehicle : vehicles) {
if (vehicle.getId() != null) {
uniq.putIfAbsent(vehicle.getId(), vehicle);
}
}
return uniq.values().stream().map(this::toCard).collect(Collectors.toList());
}
private Driver currentDriver(String mobileHint) {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
throw new ServiceException("未登录");
}
String phone = resolvePhone(userId, mobileHint);
Driver driver = null;
if (Func.isNotEmpty(phone)) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getMobile, phone)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 1"));
}
if (driver == null) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
return driver;
}
private List<String> splitPlates(String drivingVehicle) {
String normalized = drivingVehicle.replace("", ",").replace("", ",").replace(";", ",")
.replace("", ",").replace("/", ",").replace("|", ",");
Set<String> plates = new LinkedHashSet<>();
for (String part : Func.toStrList(",", normalized)) {
if (Func.isEmpty(part)) {
continue;
}
String plate = normalizePlate(part);
if (Func.isNotEmpty(plate)) {
plates.add(plate);
}
}
return new ArrayList<>(plates);
}
/** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */
private String normalizePlate(String plateNo) {
if (Func.isEmpty(plateNo)) {
return "";
}
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
}
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
DriverVehicleCardVO card = new DriverVehicleCardVO();
card.setId(vehicle.getId());
card.setPlateNo(vehicle.getPlateNo());
card.setVehicleType(vehicle.getVehicleType());
card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack()));
card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
card.setVin("");
card.setEngineNo("");
if (vehicle.getDrivingLicenseEndDate() != null) {
card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString());
} else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) {
card.setLicenseValidEnd("长期");
} else {
card.setLicenseValidEnd("");
}
card.setCertificationStatus(vehicle.getCertificationStatus());
return card;
}
private String firstNotEmpty(String first, String second) {
if (Func.isNotEmpty(first)) {
return first;
}
return Func.toStr(second, "");
}
/**
* 解析用于匹配司机档案的手机号。
* 优先级:前端传入且与本人一致的 mobile → JWT account(司机账号多为手机号)→ 用户中心 phone/account
*/
private String resolvePhone(Long userId, String mobileHint) {
String selfPhone = resolveSelfPhone(userId);
String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim();
if (Func.isNotEmpty(hint)) {
if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) {
throw new ServiceException("只能查询本人司机档案");
}
return hint;
}
return selfPhone;
}
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) {
return account.trim();
}
R<User> result = userClient.userInfoById(userId);
if (result == null || !R.isSuccess(result) || result.getData() == null) {
return Func.isEmpty(account) ? null : account.trim();
}
User user = result.getData();
if (Func.isNotEmpty(user.getPhone())) {
return user.getPhone().trim();
}
if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
return user.getAccount().trim();
}
return Func.isEmpty(account) ? null : account.trim();
}
}
@@ -37,15 +37,22 @@ import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 异常处置服务实现类
@@ -62,9 +69,12 @@ public class ExceptionDisposalServiceImpl
private static final String STATUS_COMPLETED = "completed";
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
private final IWaybillService waybillService;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper) {
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
}
@Override
@@ -82,6 +92,79 @@ public class ExceptionDisposalServiceImpl
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public ExceptionDisposalVO submitReport(ExceptionDisposal request) {
if (request == null) {
throw new ServiceException("请填写异常信息");
}
if (Func.isBlank(request.getExceptionType())) {
throw new ServiceException("请选择异常类型");
}
if (Func.isBlank(request.getReportDescription())) {
throw new ServiceException("请填写上报说明");
}
if (request.getReportDescription().length() > 500) {
throw new ServiceException("上报说明不能超过500字");
}
ExceptionDisposal disposal = new ExceptionDisposal();
disposal.setExceptionType(request.getExceptionType().trim());
disposal.setExceptionReason(Func.isBlank(request.getExceptionReason())
? null
: request.getExceptionReason().trim());
disposal.setReportDescription(request.getReportDescription().trim());
disposal.setScenePhotos(Func.isBlank(request.getScenePhotos())
? null
: request.getScenePhotos().trim());
disposal.setDisposalStatus(STATUS_PENDING);
disposal.setReportTime(LocalDateTime.now());
disposal.setReporterId(AuthUtil.getUserId());
disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId()));
fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo());
if (disposal.getWaybillId() == null) {
throw new ServiceException("请关联运单后再上报");
}
if (!save(disposal)) {
throw new ServiceException("异常上报失败");
}
return toVO(disposal);
}
/** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */
private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) {
Waybill waybill = null;
if (waybillId != null) {
waybill = waybillService.getById(waybillId);
}
if (waybill == null && Func.isNotBlank(waybillNo)) {
waybill = waybillService.getOne(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getWaybillNo, waybillNo)
.eq(Waybill::getIsDeleted, 0)
.last("LIMIT 1"));
}
if (waybill == null) {
if (waybillId != null || Func.isNotBlank(waybillNo)) {
throw new ServiceException("关联运单不存在");
}
return;
}
disposal.setWaybillId(waybill.getId());
disposal.setWaybillNo(waybill.getWaybillNo());
disposal.setVehicleNo(waybill.getVehicleNo());
disposal.setProjectId(waybill.getProjectId());
disposal.setProjectName(waybill.getProjectName());
disposal.setCarrierId(waybill.getCarrierId());
disposal.setCarrierName(waybill.getCarrierName());
String loadingOrMaster = Func.isNotBlank(waybill.getLoadingNo())
? waybill.getLoadingNo()
: waybill.getMasterNo();
disposal.setLoadingOrMasterNo(loadingOrMaster);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void follow(ExceptionDisposalFollowRequest request) {
@@ -179,9 +262,49 @@ public class ExceptionDisposalServiceImpl
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
vo.setUpdateUserName(UserCache.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) {
if (vo == null || waybillId == null) {
return;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
return;
}
Map<String, String> route = new HashMap<>(2);
route.put("start", Func.toStr(waybill.getDepartureName(), ""));
route.put("end", Func.toStr(waybill.getArrivalName(), ""));
vo.setRoute(route);
Map<String, String> cargo = new HashMap<>(2);
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
vo.setCargo(cargo);
}
private String formatWeight(BigDecimal quantity, String unit) {
if (quantity == null) {
return "";
}
String qty = quantity.stripTrailingZeros().toPlainString();
return Func.isBlank(unit) ? qty : qty + unit;
}
private List<String> splitPhotos(String scenePhotos) {
if (Func.isBlank(scenePhotos)) {
return List.of();
}
return Arrays.stream(scenePhotos.split(","))
.map(String::trim)
.filter(s -> Func.isNotBlank(s))
.collect(Collectors.toList());
}
private List<ExceptionDisposalFollowRecordVO> followRecords(Long id) {
List<ExceptionDisposalFollowRecord> records = followRecordMapper.selectList(Wrappers.<ExceptionDisposalFollowRecord>lambdaQuery()
.eq(ExceptionDisposalFollowRecord::getDisposalId, id)
@@ -33,7 +33,9 @@ import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.transport.excel.ProcessConfigExportExcel;
import org.springblade.transport.mapper.ProcessConfigMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.ProcessConfigVO;
import org.springblade.transport.service.IProcessConfigService;
@@ -43,8 +45,11 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 过程配置 服务实现类
@@ -54,15 +59,25 @@ import java.util.Objects;
@Service
public class ProcessConfigServiceImpl extends BaseServiceImpl<ProcessConfigMapper, ProcessConfig> implements IProcessConfigService {
private final WaybillMapper waybillMapper;
public ProcessConfigServiceImpl(WaybillMapper waybillMapper) {
this.waybillMapper = waybillMapper;
}
@Override
public IPage<ProcessConfigVO> selectProcessConfigPage(IPage<ProcessConfig> page, ProcessConfigVO processConfig) {
IPage<ProcessConfig> entityPage = page(page, buildQuery(processConfig));
return ProcessConfigWrapper.build().pageVO(entityPage);
IPage<ProcessConfigVO> voPage = ProcessConfigWrapper.build().pageVO(entityPage);
fillHasRelatedWaybill(voPage.getRecords());
return voPage;
}
@Override
public ProcessConfigVO detail(Long id) {
return ProcessConfigWrapper.build().entityVO(loadEditable(id, false));
ProcessConfigVO detail = ProcessConfigWrapper.build().entityVO(loadEditable(id, false));
fillHasRelatedWaybill(List.of(detail));
return detail;
}
@Override
@@ -71,6 +86,7 @@ public class ProcessConfigServiceImpl extends BaseServiceImpl<ProcessConfigMappe
boolean created = Func.isEmpty(processConfig.getId());
if (!created) {
ProcessConfig oldRecord = loadEditable(processConfig.getId(), true);
assertNoRelatedWaybill(oldRecord);
processConfig.setConfigCode(oldRecord.getConfigCode());
processConfig.setDeptId(oldRecord.getDeptId());
processConfig.setDeptName(oldRecord.getDeptName());
@@ -176,6 +192,65 @@ public class ProcessConfigServiceImpl extends BaseServiceImpl<ProcessConfigMappe
}
}
private void assertNoRelatedWaybill(ProcessConfig processConfig) {
if (hasRelatedWaybill(processConfig.getProjectIds())) {
throw new ServiceException("该项目已有运单,过程配置不可修改");
}
}
private void fillHasRelatedWaybill(List<ProcessConfigVO> records) {
if (Func.isEmpty(records)) {
return;
}
Set<Long> allProjectIds = new HashSet<>();
for (ProcessConfigVO record : records) {
allProjectIds.addAll(parseProjectIds(record.getProjectIds()));
}
Set<Long> projectIdsWithWaybill = findProjectIdsWithWaybill(allProjectIds);
for (ProcessConfigVO record : records) {
List<Long> projectIds = parseProjectIds(record.getProjectIds());
record.setHasRelatedWaybill(projectIds.stream().anyMatch(projectIdsWithWaybill::contains));
}
}
private boolean hasRelatedWaybill(String projectIds) {
return !findProjectIdsWithWaybill(new HashSet<>(parseProjectIds(projectIds))).isEmpty();
}
private Set<Long> findProjectIdsWithWaybill(Set<Long> projectIds) {
if (Func.isEmpty(projectIds)) {
return Set.of();
}
Set<Long> result = new HashSet<>();
for (Long projectId : projectIds) {
if (waybillMapper.selectCount(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getProjectId, projectId)
.eq(Waybill::getIsDeleted, 0)) > 0) {
result.add(projectId);
}
}
return result;
}
private List<Long> parseProjectIds(String projectIds) {
if (Func.isEmpty(projectIds)) {
return List.of();
}
return Arrays.stream(projectIds.split(","))
.map(String::trim)
.filter(Func::isNotEmpty)
.map(item -> {
try {
return Long.valueOf(item);
} catch (NumberFormatException ex) {
return null;
}
})
.filter(Objects::nonNull)
.distinct()
.toList();
}
private LambdaQueryWrapper<ProcessConfig> buildQuery(ProcessConfigVO processConfig) {
TransportBusinessSupport.validateAllDept(processConfig.getAllDept(), "过程配置");
LambdaQueryWrapper<ProcessConfig> queryWrapper = Wrappers.<ProcessConfig>lambdaQuery().eq(ProcessConfig::getIsDeleted, 0);
@@ -36,15 +36,22 @@ import org.springblade.transport.excel.WaybillExcel;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillPunchPhotoVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
@@ -53,6 +60,7 @@ import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.WaybillWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -60,8 +68,11 @@ import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -94,9 +105,16 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@org.springframework.context.annotation.Lazy
private IReceivablePayableDetailService receivablePayableDetailService;
@jakarta.annotation.Resource
private WaybillNodePunchMapper waybillNodePunchMapper;
@jakarta.annotation.Resource
private WaybillEnroutePunchMapper waybillEnroutePunchMapper;
@Override
public IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill) {
IPage<Waybill> entityPage = page(page, buildQuery(waybill));
entityPage.getRecords().forEach(this::syncDriverAcceptState);
IPage<WaybillVO> result = WaybillWrapper.build().pageVO(entityPage);
fillMileageMaintainable(result.getRecords());
return result;
@@ -104,12 +122,327 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
public WaybillVO detail(Long id) {
WaybillVO result = WaybillWrapper.build().entityVO(loadEditable(id, false));
WaybillVO result = WaybillWrapper.build().entityVO(syncDriverAcceptState(loadEditable(id, false)));
fillCustomerNameFromContract(result);
fillMileageMaintainable(List.of(result));
return result;
}
@Override
public WaybillPunchRecordsVO listPunchRecords(Long waybillId) {
WaybillPunchRecordsVO vo = new WaybillPunchRecordsVO();
if (waybillId == null) {
return vo;
}
Waybill waybill = getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在");
}
String processJson = resolveLiveProcessJson(waybill);
Map<String, List<String>> voucherTypesByNode = buildVoucherTypesIndex(processJson);
List<WaybillNodePunch> nodePunches = waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybillId)
.orderByAsc(WaybillNodePunch::getPunchTime)
.orderByAsc(WaybillNodePunch::getId));
Map<String, WaybillNodePunch> latestNodePunch = new LinkedHashMap<>();
for (WaybillNodePunch punch : nodePunches) {
String code = Func.toStr(punch.getNodeCode(), "").trim();
String name = Func.toStr(punch.getNodeName(), "").trim();
if (Func.isNotEmpty(code)) {
latestNodePunch.put(code.toLowerCase(Locale.ROOT), punch);
}
if (Func.isNotEmpty(name)) {
latestNodePunch.put(name.toLowerCase(Locale.ROOT), punch);
}
}
List<WaybillEnroutePunch> enroutePunches = waybillEnroutePunchMapper.selectList(Wrappers.<WaybillEnroutePunch>lambdaQuery()
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
.orderByAsc(WaybillEnroutePunch::getPunchTime)
.orderByAsc(WaybillEnroutePunch::getId));
WaybillEnroutePunch latestEnroute = enroutePunches.isEmpty() ? null : enroutePunches.get(enroutePunches.size() - 1);
List<String> transitTypes = voucherTypesByNode.getOrDefault("transit", List.of("货物照片"));
List<WaybillPunchRecordItemVO> records = new ArrayList<>();
List<WaybillPunchPhotoVO> uploads = new ArrayList<>();
List<Map<String, Object>> processNodes = WaybillProcessSupport.listEnabledProcessNodes(processJson);
if (processNodes.isEmpty()) {
processNodes = WaybillProcessSupport.listDriverPunchNodes(processJson);
}
for (Map<String, Object> node : processNodes) {
String nodeCode = WaybillProcessSupport.nodeKey(node);
String nodeName = WaybillProcessSupport.nodeName(node);
boolean isTransit = WaybillProcessSupport.isTransitNodePublic(node);
WaybillPunchRecordItemVO item = new WaybillPunchRecordItemVO();
item.setNodeCode(nodeCode);
item.setNodeName(nodeName);
item.setType(isTransit ? "enroute" : "node");
item.setExceptionFlag(false);
item.setPhotos(new ArrayList<>());
if (isTransit) {
if (latestEnroute != null) {
item.setId(latestEnroute.getId());
item.setPunched(true);
item.setStatusName("已打卡");
item.setPunchTime(formatPunchTime(latestEnroute.getPunchTime()));
item.setAddress(Func.toStr(latestEnroute.getAddress(), ""));
item.setLongitude(decimalText(latestEnroute.getLongitude()));
item.setLatitude(decimalText(latestEnroute.getLatitude()));
if (Func.isNotEmpty(latestEnroute.getPhoto())) {
String voucherType = transitTypes.isEmpty() ? "货物照片" : transitTypes.get(0);
WaybillPunchPhotoVO photo = buildPhoto(
nodeName, voucherType, latestEnroute.getPhoto().trim(), item.getPunchTime());
item.getPhotos().add(photo);
}
// 司机上传:展示全部在途照片(不仅最新一条)
for (WaybillEnroutePunch punch : enroutePunches) {
if (Func.isEmpty(punch.getPhoto())) {
continue;
}
String voucherType = transitTypes.isEmpty() ? "货物照片" : transitTypes.get(0);
uploads.add(buildPhoto(nodeName, voucherType, punch.getPhoto().trim(), formatPunchTime(punch.getPunchTime())));
}
} else {
item.setPunched(false);
item.setStatusName("未打卡");
item.setPunchTime("");
}
records.add(item);
continue;
}
WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName);
if (punched != null) {
List<String> types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName);
item.setId(punched.getId());
item.setPunched(true);
item.setStatusName("已打卡");
item.setPunchTime(formatPunchTime(punched.getPunchTime()));
item.setAddress(Func.toStr(punched.getAddress(), ""));
item.setLongitude(decimalText(punched.getLongitude()));
item.setLatitude(decimalText(punched.getLatitude()));
item.setWeight(punched.getWeight());
item.setVolume(punched.getVolume());
item.setQuantity(punched.getQuantity());
item.setRemark(punched.getRemark());
item.setExceptionFlag(Objects.equals(punched.getExceptionFlag(), 1));
List<WaybillPunchPhotoVO> photos = decodePunchPhotos(punched.getPhotos(), nodeName, types, item.getPunchTime());
item.setPhotos(photos);
uploads.addAll(photos);
} else {
item.setPunched(false);
item.setStatusName("未打卡");
item.setPunchTime("");
}
records.add(item);
}
vo.setRecords(records);
vo.setDriverUploads(uploads);
return vo;
}
private WaybillNodePunch findLatestNodePunch(Map<String, WaybillNodePunch> index, String nodeCode, String nodeName) {
if (index == null || index.isEmpty()) {
return null;
}
if (Func.isNotEmpty(nodeCode)) {
WaybillNodePunch hit = index.get(nodeCode.toLowerCase(Locale.ROOT));
if (hit != null) {
return hit;
}
}
if (Func.isNotEmpty(nodeName)) {
return index.get(nodeName.toLowerCase(Locale.ROOT));
}
return null;
}
/** 动态过程配置优先,回退运单快照 */
private String resolveLiveProcessJson(Waybill waybill) {
if (waybill.getProjectId() != null) {
String projectId = String.valueOf(waybill.getProjectId());
String live = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectId)
.orderByDesc(ProcessConfig::getUpdateTime)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.orElse(null);
if (Func.isNotEmpty(live)) {
return live;
}
}
return waybill.getProcessJson();
}
private Map<String, List<String>> buildVoucherTypesIndex(String processJson) {
Map<String, List<String>> map = new LinkedHashMap<>();
for (Map<String, Object> node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
String key = WaybillProcessSupport.nodeKey(node);
String name = WaybillProcessSupport.nodeName(node);
List<String> types = WaybillProcessSupport.nodeStringList(node, "voucherTypes");
if (Func.isNotEmpty(key)) {
map.put(key.toLowerCase(Locale.ROOT), types);
}
if (Func.isNotEmpty(name)) {
map.put(name.toLowerCase(Locale.ROOT), types);
}
}
return map;
}
private List<String> resolveNodeVoucherTypes(Map<String, List<String>> index, String nodeCode, String nodeName) {
if (index == null || index.isEmpty()) {
return List.of();
}
if (Func.isNotEmpty(nodeCode)) {
List<String> hit = index.get(nodeCode.toLowerCase(Locale.ROOT));
if (hit != null) {
return hit;
}
}
if (Func.isNotEmpty(nodeName)) {
List<String> hit = index.get(nodeName.toLowerCase(Locale.ROOT));
if (hit != null) {
return hit;
}
}
return List.of();
}
/**
* 解析打卡 photos
* 1) JSON 数组 [{"type":"委托单","url":"..."}]
* 2) 逗号分隔 URL,按 voucherTypes 下标回推类型
*/
@SuppressWarnings("unchecked")
private List<WaybillPunchPhotoVO> decodePunchPhotos(
String raw,
String nodeName,
List<String> voucherTypes,
String punchTime
) {
List<WaybillPunchPhotoVO> out = new ArrayList<>();
if (Func.isEmpty(raw)) {
return out;
}
String text = raw.trim();
if (text.startsWith("[")) {
try {
List<?> list = JsonUtil.parse(text, List.class);
if (list != null) {
int i = 0;
for (Object item : list) {
if (item instanceof Map<?, ?> map) {
String url = Func.toStr(map.get("url"), "").trim();
if (Func.isEmpty(url)) {
continue;
}
String type = Func.toStr(map.get("type"), "").trim();
if (Func.isEmpty(type) && voucherTypes != null && i < voucherTypes.size()) {
type = voucherTypes.get(i);
}
if (Func.isEmpty(type)) {
type = "凭证" + (i + 1);
}
out.add(buildPhoto(nodeName, type, url, punchTime));
i++;
} else if (item != null) {
String url = String.valueOf(item).trim();
if (Func.isEmpty(url)) {
continue;
}
String type = (voucherTypes != null && i < voucherTypes.size())
? voucherTypes.get(i)
: ("凭证" + (i + 1));
out.add(buildPhoto(nodeName, type, url, punchTime));
i++;
}
}
return out;
}
} catch (Exception ignored) {
// fall through to comma split
}
}
String[] urls = text.split(",");
for (int i = 0; i < urls.length; i++) {
String url = urls[i].trim();
if (Func.isEmpty(url)) {
continue;
}
String type = (voucherTypes != null && i < voucherTypes.size())
? voucherTypes.get(i)
: ("凭证" + (i + 1));
out.add(buildPhoto(nodeName, type, url, punchTime));
}
return out;
}
private WaybillPunchPhotoVO buildPhoto(String nodeName, String voucherType, String url, String punchTime) {
WaybillPunchPhotoVO photo = new WaybillPunchPhotoVO();
photo.setNodeName(nodeName);
photo.setVoucherType(voucherType);
photo.setUrl(url);
photo.setPunchTime(punchTime);
photo.setLabel(nodeName + "-" + voucherType);
return photo;
}
private String formatPunchTime(Date time) {
if (time == null) {
return "";
}
return org.springblade.core.tool.utils.DateUtil.format(time, org.springblade.core.tool.utils.DateUtil.PATTERN_DATETIME);
}
private String decimalText(BigDecimal value) {
return value == null ? "" : value.stripTrailingZeros().toPlainString();
}
@Override
public Waybill syncDriverAcceptState(Waybill waybill) {
if (waybill == null || waybill.getId() == null) {
return waybill;
}
if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) {
return waybill;
}
// 与司机端一致:优先项目最新过程配置,再回退运单快照
String processJson = resolveLiveProcessJson(waybill);
if (Func.isNotEmpty(processJson)) {
waybill.setProcessJson(processJson);
}
boolean requireAccept = WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson);
String acceptStatus = waybill.getDriverAcceptStatus();
if (requireAccept && Func.isEmpty(acceptStatus)) {
acceptStatus = WaybillProcessSupport.ACCEPT_PENDING;
}
String nextStatus = WaybillProcessSupport.normalizeBusinessStatus(
waybill.getBusinessStatus(), processJson, acceptStatus);
boolean acceptChanged = !Objects.equals(acceptStatus, waybill.getDriverAcceptStatus());
boolean statusChanged = !Objects.equals(nextStatus, waybill.getBusinessStatus());
if (!acceptChanged && !statusChanged) {
return waybill;
}
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setBusinessStatus(nextStatus);
update(Wrappers.<Waybill>lambdaUpdate()
.set(Waybill::getBusinessStatus, nextStatus)
.set(Waybill::getDriverAcceptStatus, acceptStatus)
.eq(Waybill::getId, waybill.getId()));
return waybill;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(Waybill waybill) {
@@ -128,20 +461,31 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
private void prepareForSave(Waybill waybill) {
boolean created = Func.isEmpty(waybill.getId());
Waybill oldRecord = null;
if (!created) {
Waybill oldRecord = loadEditable(waybill.getId(), true);
oldRecord = loadEditable(waybill.getId(), true);
assertNotLoaded(oldRecord);
waybill.setWaybillNo(oldRecord.getWaybillNo());
waybill.setLoadingNo(oldRecord.getLoadingNo());
waybill.setDeptId(oldRecord.getDeptId());
waybill.setDeptName(oldRecord.getDeptName());
waybill.setMileageRemark(oldRecord.getMileageRemark());
if (Func.isEmpty(waybill.getProcessJson())) {
waybill.setProcessJson(oldRecord.getProcessJson());
}
} else {
waybill.setLoadingNo(null);
waybill.setMileageRemark(null);
fillProjectProcessConfig(waybill);
}
// 无过程快照时按项目回填;再按接单设置决定 pending / running
fillProjectProcessConfig(waybill);
prepare(waybill);
if (oldRecord != null) {
preserveOrResetDriverAccept(waybill, oldRecord);
} else {
clearDriverAcceptRecord(waybill);
}
applyDriverAcceptBusinessStatus(waybill);
fillCustomerName(waybill);
if (created && Func.isEmpty(waybill.getWaybillNo())) {
waybill.setWaybillNo(nextCode());
@@ -180,21 +524,60 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
private void fillProjectProcessConfig(Waybill waybill) {
if (Func.isNotEmpty(waybill.getProcessJson()) || Func.isEmpty(waybill.getProjectId())) {
String live = resolveLiveProcessJson(waybill);
if (Func.isNotEmpty(live)) {
waybill.setProcessJson(live);
}
}
/**
* 无过程配置,或接单设置为「无需确认接单」时,运单直接进入进行中(running);
* 需要司机确认接单且尚未接单时保持待执行(pending)。
*/
private void applyDriverAcceptBusinessStatus(Waybill waybill) {
if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) {
return;
}
String projectId = String.valueOf(waybill.getProjectId());
processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
.eq(ProcessConfig::getStatus, 1)
.eq(ProcessConfig::getIsDeleted, 0)
.like(ProcessConfig::getProjectIds, projectId)
.orderByDesc(ProcessConfig::getCreateTime))
.stream()
.filter(processConfig -> containsProjectId(processConfig.getProjectIds(), projectId))
.map(ProcessConfig::getNodeConfigJson)
.filter(Func::isNotEmpty)
.findFirst()
.ifPresent(waybill::setProcessJson);
String processJson = resolveLiveProcessJson(waybill);
if (Func.isNotEmpty(processJson)) {
waybill.setProcessJson(processJson);
}
boolean requireAccept = WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson);
if (requireAccept && Func.isEmpty(waybill.getDriverAcceptStatus())) {
waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_PENDING);
}
waybill.setBusinessStatus(WaybillProcessSupport.normalizeBusinessStatus(
waybill.getBusinessStatus(), processJson, waybill.getDriverAcceptStatus()));
}
private void preserveOrResetDriverAccept(Waybill waybill, Waybill oldRecord) {
if (driverAssignmentChanged(waybill, oldRecord)) {
clearDriverAcceptRecord(waybill);
return;
}
waybill.setDriverAcceptStatus(oldRecord.getDriverAcceptStatus());
waybill.setDriverAcceptTime(oldRecord.getDriverAcceptTime());
waybill.setDriverAcceptDriverId(oldRecord.getDriverAcceptDriverId());
waybill.setDriverRejectTime(oldRecord.getDriverRejectTime());
waybill.setDriverRejectReason(oldRecord.getDriverRejectReason());
}
private boolean driverAssignmentChanged(Waybill waybill, Waybill oldRecord) {
return !Objects.equals(waybill.getDriverId(), oldRecord.getDriverId())
|| !Objects.equals(
TransportBusinessSupport.trimToNull(waybill.getDriverPhone()),
TransportBusinessSupport.trimToNull(oldRecord.getDriverPhone()))
|| !Objects.equals(
TransportBusinessSupport.trimToNull(waybill.getVehicleNo()),
TransportBusinessSupport.trimToNull(oldRecord.getVehicleNo()));
}
private void clearDriverAcceptRecord(Waybill waybill) {
waybill.setDriverAcceptStatus(null);
waybill.setDriverAcceptTime(null);
waybill.setDriverAcceptDriverId(null);
waybill.setDriverRejectTime(null);
waybill.setDriverRejectReason(null);
}
private boolean containsProjectId(String projectIds, String projectId) {
@@ -254,7 +637,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
excel.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()));
excel.setCreateTime(record.getCreateTime());
excel.setUpdateTime(record.getUpdateTime());
excel.setBusinessStatus(WaybillWrapper.businessStatusName(record.getBusinessStatus()));
String processJson = resolveLiveProcessJson(record);
excel.setBusinessStatus(WaybillWrapper.businessStatusName(
WaybillProcessSupport.normalizeBusinessStatus(
record.getBusinessStatus(), processJson, record.getDriverAcceptStatus())));
return excel;
}).toList();
}
@@ -352,8 +738,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
target.setRemark(source.getRemark());
target.setBusinessStatus("pending");
target.setWaybillNo(nextCode());
clearDriverAcceptRecord(target);
fillProjectProcessConfig(target);
prepare(target);
applyDriverAcceptBusinessStatus(target);
validate(target);
save(target);
return detail(target.getId());
@@ -418,20 +806,61 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reassign(Long id) {
Waybill waybill = loadEditable(id, true);
assertNotLoaded(waybill);
if (!"pending".equals(waybill.getBusinessStatus())) {
throw new ServiceException("仅待执行运单允许重新派单");
public boolean reassign(Waybill request) {
if (request == null || Func.isEmpty(request.getId())) {
throw new ServiceException("运单ID不能为空");
}
waybill.setBusinessStatus("pending");
Waybill waybill = loadEditable(request.getId(), true);
assertNotLoaded(waybill);
if (!"pending".equals(waybill.getBusinessStatus()) && !"running".equals(waybill.getBusinessStatus())) {
throw new ServiceException("仅待执行/进行中运单允许重新派单");
}
String driverName = TransportBusinessSupport.trimToNull(request.getDriverName());
String driverPhone = TransportBusinessSupport.trimToNull(request.getDriverPhone());
String vehicleNo = TransportBusinessSupport.trimToNull(request.getVehicleNo());
TransportBusinessSupport.validateRequired(driverName, "司机不能为空");
TransportBusinessSupport.validateRequired(driverPhone, "手机号不能为空");
TransportBusinessSupport.validateRequired(vehicleNo, "车牌号不能为空");
waybill.setDriverId(request.getDriverId());
waybill.setDriverName(driverName);
waybill.setDriverPhone(driverPhone);
waybill.setVehicleNo(vehicleNo);
// 同步承运/任务 JSON,避免列表与表单读到旧司机
waybill.setCarrierJson(buildImportCarrierJson(waybill));
waybill.setTaskInfoJson(buildTaskInfoJson(waybill));
fillProjectProcessConfig(waybill);
clearDriverAcceptRecord(waybill);
// 清空后重新进入待接单
waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_PENDING);
applyDriverAcceptBusinessStatus(waybill);
return updateById(waybill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean complete(Long id) {
Waybill waybill = loadEditable(id, true);
return doComplete(loadEditable(id, true));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean completeWithoutDeptCheck(Long id) {
if (Func.isEmpty(id)) {
throw new ServiceException("运单ID不能为空");
}
Waybill waybill = getById(id);
if (Func.isEmpty(waybill) || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在");
}
return doComplete(waybill);
}
/**
* 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。
*/
private boolean doComplete(Waybill waybill) {
if ("completed".equals(waybill.getBusinessStatus()) || "cancelled".equals(waybill.getBusinessStatus())) {
throw new ServiceException("当前状态不允许完成");
}
@@ -682,6 +1111,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setCarrierJson(TransportBusinessSupport.trimToNull(waybill.getCarrierJson()));
waybill.setTaskInfoJson(TransportBusinessSupport.trimToNull(waybill.getTaskInfoJson()));
waybill.setProcessJson(TransportBusinessSupport.trimToNull(waybill.getProcessJson()));
waybill.setDriverAcceptStatus(TransportBusinessSupport.trimToNull(waybill.getDriverAcceptStatus()));
waybill.setDriverRejectReason(TransportBusinessSupport.trimToNull(waybill.getDriverRejectReason()));
waybill.setRouteJson(TransportBusinessSupport.trimToNull(waybill.getRouteJson()));
waybill.setFreightJson(TransportBusinessSupport.trimToNull(waybill.getFreightJson()));
waybill.setAttachmentsJson(TransportBusinessSupport.trimToNull(waybill.getAttachmentsJson()));
@@ -694,6 +1125,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setDeptName(dept.getDeptName());
}
if (waybill.getStatus() == null) { waybill.setStatus(1); }
// 默认 pending;最终 pending/running 由 applyDriverAcceptBusinessStatus 按过程配置校正
if (Func.isEmpty(waybill.getBusinessStatus())) { waybill.setBusinessStatus("pending"); }
}
@@ -0,0 +1,472 @@
/**
* 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.support;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 运单过程配置解析(对齐 web 端 waybill-manage / process-config
*/
public final class WaybillProcessSupport {
public static final String STATUS_PENDING = "pending";
public static final String STATUS_RUNNING = "running";
public static final String ACCEPT_PENDING = "pending";
public static final String ACCEPT_ACCEPTED = "accepted";
public static final String ACCEPT_REJECTED = "rejected";
private static final String CONFIRM_YES = "yes";
private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept";
private static final String NODE_TRANSIT = "transit";
private static final String NODE_TRANSIT_NAME = "在途";
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
private WaybillProcessSupport() {
}
/**
* 在途打卡判定结果(供司机端「今日在途打卡」面板使用)。
*/
public record TransitCheckinDecision(
boolean punchEnabled,
boolean visible,
boolean dueToday,
boolean doneToday,
int frequencyDays,
String timeStart,
String timeEnd
) {
public static TransitCheckinDecision hidden() {
return new TransitCheckinDecision(false, false, false, false, 1, "00:00", "23:59");
}
}
/**
* 是否需要接单确认。
* <p>
* 存在启用的接单节点,且 confirmMode=yes(是否确认=是)即为需要接单;
* 不依赖 confirmDriver(是否勾选司机)——只要尚未接单或已拒绝,业务状态均为待执行。
* <p>
* 无过程配置 / 接单节点为「无需确认接单」→ false。
*/
public static boolean requiresDriverAcceptConfirmation(String processJson) {
List<Map<String, Object>> nodes = parseProcessNodes(processJson);
if (nodes.isEmpty()) {
return false;
}
for (Map<String, Object> node : nodes) {
if (!isAcceptNode(node) || !isEnabled(node)) {
continue;
}
String confirmMode = stringVal(node.get("confirmMode"));
if (CONFIRM_NO_ACCEPT.equals(confirmMode)) {
return false;
}
if (CONFIRM_YES.equals(confirmMode) || Func.isEmpty(confirmMode)) {
return true;
}
}
return false;
}
/**
* 根据过程配置决定司机侧初始业务状态:
* 需要确认接单 → pending(待执行/待接单);否则 → running(进行中)。
*/
public static String resolveDriverFacingStatus(String processJson) {
return requiresDriverAcceptConfirmation(processJson) ? STATUS_PENDING : STATUS_RUNNING;
}
public static boolean isAccepted(String driverAcceptStatus) {
return ACCEPT_ACCEPTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
}
public static boolean isRejected(String driverAcceptStatus) {
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
}
public static boolean isTerminalBusinessStatus(String businessStatus) {
return "draft".equals(businessStatus)
|| "completed".equals(businessStatus)
|| "cancelled".equals(businessStatus)
|| "waiting_dispatch".equals(businessStatus)
|| "dispatching".equals(businessStatus);
}
/**
* 校正业务状态。
* <p>
* 需要接单(接单节点 confirmMode=yes)且尚未接单(未响应 / 已拒绝)→ pending(待执行);
* 已接单 → running(进行中);不需要接单 → running(仅当当前为空或 pending 时提升)。
* draft / completed / cancelled 等终态或调度中间态不改动。
*/
public static String normalizeBusinessStatus(String businessStatus, String processJson) {
return normalizeBusinessStatus(businessStatus, processJson, null);
}
public static String normalizeBusinessStatus(String businessStatus, String processJson, String driverAcceptStatus) {
if (isTerminalBusinessStatus(businessStatus)) {
return businessStatus;
}
if (requiresDriverAcceptConfirmation(processJson)) {
return isAccepted(driverAcceptStatus) ? STATUS_RUNNING : STATUS_PENDING;
}
if (Func.isEmpty(businessStatus) || STATUS_PENDING.equals(businessStatus)) {
return STATUS_RUNNING;
}
return businessStatus;
}
/**
* 过程配置是否启用在途打卡:在途节点 enabled 且 punch=是。
*/
public static boolean isTransitPunchEnabled(String processJson) {
Map<String, Object> transit = findTransitNode(processJson);
return transit != null && isEnabled(transit) && isTruthy(transit.get("punch"));
}
/**
* 计算「今日在途打卡」是否展示 / 是否到期。
* <p>
* 规则:
* <ul>
* <li>在途节点未启用或 punch≠是 → 不展示</li>
* <li>运单非进行中(running)→ 不展示</li>
* <li>频次:每 N 天打卡 1 次;无历史 → 到期;上次打卡日 + N ≤ 今日 → 到期</li>
* <li>时段:到期时须落在 timeStart~timeEnd(支持跨午夜);今日已打则仍展示(已打卡态)</li>
* </ul>
*/
public static TransitCheckinDecision evaluateTransitCheckin(
String processJson,
String businessStatus,
Date lastPunchAt,
LocalDateTime now
) {
Map<String, Object> transit = findTransitNode(processJson);
if (transit == null || !isEnabled(transit) || !isTruthy(transit.get("punch"))) {
return TransitCheckinDecision.hidden();
}
int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1);
String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00");
String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59");
if (!STATUS_RUNNING.equals(businessStatus)) {
return new TransitCheckinDecision(true, false, false, false, frequencyDays, timeStart, timeEnd);
}
LocalDateTime current = now == null ? LocalDateTime.now() : now;
LocalDate today = current.toLocalDate();
LocalDate lastDate = toLocalDate(lastPunchAt);
boolean doneToday = lastDate != null && lastDate.equals(today);
boolean dueToday;
if (lastDate == null) {
dueToday = true;
} else {
LocalDate nextDue = lastDate.plusDays(frequencyDays);
dueToday = !today.isBefore(nextDue);
}
boolean inWindow = isWithinTimeWindow(current.toLocalTime(), timeStart, timeEnd);
boolean visible = doneToday || (dueToday && inWindow);
return new TransitCheckinDecision(true, visible, dueToday && inWindow && !doneToday, doneToday, frequencyDays, timeStart, timeEnd);
}
public static Map<String, Object> findTransitNode(String processJson) {
List<Map<String, Object>> nodes = parseProcessNodes(processJson);
for (Map<String, Object> node : nodes) {
if (isTransitNode(node)) {
return node;
}
}
return null;
}
/**
* 司机端应展示的打卡节点:enabled 且 punch=是,排除接单/回单。
*/
public static List<Map<String, Object>> listDriverPunchNodes(String processJson) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> node : parseProcessNodes(processJson)) {
if (!isEnabled(node) || !isTruthy(node.get("punch"))) {
continue;
}
if (isAcceptNode(node) || isReturnNode(node)) {
continue;
}
result.add(node);
}
return result;
}
/**
* 启用中的过程节点(按配置顺序,含接单/回单)。
*/
public static List<Map<String, Object>> listEnabledProcessNodes(String processJson) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> node : parseProcessNodes(processJson)) {
if (isEnabled(node)) {
result.add(node);
}
}
return result;
}
/**
* 当前过程节点在启用节点列表中的下标;找不到返回 0(视为从首个开始)。
*/
public static int indexOfCurrentNode(List<Map<String, Object>> enabledNodes, String currentProcessNode) {
if (enabledNodes == null || enabledNodes.isEmpty()) {
return 0;
}
String current = stringVal(currentProcessNode);
if (Func.isEmpty(current)) {
// 无当前节点:定位到第一个非接单节点
for (int i = 0; i < enabledNodes.size(); i++) {
if (!isAcceptNode(enabledNodes.get(i))) {
return i;
}
}
return 0;
}
for (int i = 0; i < enabledNodes.size(); i++) {
Map<String, Object> node = enabledNodes.get(i);
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));
if (current.equalsIgnoreCase(key) || current.equals(name) || name.contains(current) || current.contains(name)) {
return i;
}
}
return 0;
}
public static boolean isTransitNodePublic(Map<String, Object> node) {
return isTransitNode(node);
}
public static boolean nodeNeedLocation(Map<String, Object> node) {
return isTruthy(node.get("location"));
}
public static boolean nodeNeedCargo(Map<String, Object> node) {
return isTruthy(node.get("uploadCargo"));
}
public static boolean nodeNeedVoucher(Map<String, Object> node) {
return isTruthy(node.get("uploadVoucher"));
}
@SuppressWarnings("unchecked")
public static List<String> nodeStringList(Map<String, Object> node, String field) {
Object raw = node.get(field);
if (raw instanceof List<?> list) {
List<String> out = new ArrayList<>();
for (Object item : list) {
if (item != null && Func.isNotEmpty(String.valueOf(item).trim())) {
out.add(String.valueOf(item).trim());
}
}
return out;
}
if (raw instanceof String str && Func.isNotEmpty(str)) {
String[] parts = str.split("[,]");
List<String> out = new ArrayList<>();
for (String part : parts) {
if (Func.isNotEmpty(part.trim())) {
out.add(part.trim());
}
}
return out;
}
return Collections.emptyList();
}
public static String nodeKey(Map<String, Object> node) {
return stringVal(node.get("key"));
}
public static String nodeName(Map<String, Object> node) {
String name = stringVal(node.get("name"));
return Func.isEmpty(name) ? nodeKey(node) : name;
}
@SuppressWarnings("unchecked")
public static List<Map<String, Object>> parseProcessNodes(String processJson) {
if (Func.isEmpty(processJson)) {
return Collections.emptyList();
}
try {
Object parsed = JsonUtil.parse(processJson, Object.class);
if (parsed instanceof List<?> list) {
return castNodeList(list);
}
if (parsed instanceof Map<?, ?> map) {
Object nodes = map.get("nodes");
if (nodes instanceof List<?> list) {
return castNodeList(list);
}
Object nodeConfigJson = map.get("nodeConfigJson");
if (nodeConfigJson instanceof String str && Func.isNotEmpty(str)) {
return parseProcessNodes(str);
}
if (nodeConfigJson instanceof List<?> list) {
return castNodeList(list);
}
}
} catch (Exception ignored) {
return Collections.emptyList();
}
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> castNodeList(List<?> list) {
return list.stream()
.filter(Map.class::isInstance)
.map(item -> (Map<String, Object>) item)
.toList();
}
private static boolean isReturnNode(Map<String, Object> node) {
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));
return "return".equals(key) || "回单".equals(name);
}
private static boolean isAcceptNode(Map<String, Object> node) {
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));
return "accept".equals(key) || "接单".equals(name);
}
private static boolean isTransitNode(Map<String, Object> node) {
String key = stringVal(node.get("key"));
String name = stringVal(node.get("name"));
String type = stringVal(node.get("type"));
return NODE_TRANSIT.equals(key) || NODE_TRANSIT_NAME.equals(name) || NODE_TRANSIT.equals(type);
}
private static boolean isEnabled(Map<String, Object> node) {
Object enabled = node.get("enabled");
if (enabled == null) {
return true;
}
if (enabled instanceof Boolean bool) {
return bool;
}
String text = String.valueOf(enabled).trim();
return !("false".equalsIgnoreCase(text) || "0".equals(text));
}
private static boolean isTruthy(Object value) {
if (value == null) {
return false;
}
if (value instanceof Boolean bool) {
return bool;
}
String text = String.valueOf(value).trim();
return "true".equalsIgnoreCase(text) || "1".equals(text) || "yes".equalsIgnoreCase(text);
}
private static String stringVal(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private static int parsePositiveInt(Object value, int defaultVal) {
if (value == null) {
return defaultVal;
}
try {
int n = Integer.parseInt(String.valueOf(value).trim());
return n < 1 ? defaultVal : n;
} catch (NumberFormatException ex) {
return defaultVal;
}
}
private static String normalizeHm(String value, String fallback) {
LocalTime t = parseHm(value);
if (t == null) {
return fallback;
}
return t.format(HM_PADDED);
}
private static LocalTime parseHm(String value) {
if (Func.isEmpty(value)) {
return null;
}
String text = value.trim();
try {
return LocalTime.parse(text, HM_PADDED);
} catch (DateTimeParseException ignored) {
// fallthrough
}
try {
return LocalTime.parse(text, HM);
} catch (DateTimeParseException ignored) {
return null;
}
}
/**
* 是否在打卡时段内(按 HH:mm 分钟含端点);timeStart &gt; timeEnd 视为跨午夜。
*/
public static boolean isWithinTimeWindow(LocalTime now, String timeStart, String timeEnd) {
LocalTime start = parseHm(timeStart);
LocalTime end = parseHm(timeEnd);
if (start == null || end == null || now == null) {
return true;
}
int nowM = now.getHour() * 60 + now.getMinute();
int startM = start.getHour() * 60 + start.getMinute();
int endM = end.getHour() * 60 + end.getMinute();
if (startM == endM) {
return true;
}
if (startM < endM) {
return nowM >= startM && nowM <= endM;
}
// 跨午夜:如 22:00-06:00
return nowM >= startM || nowM <= endM;
}
private static LocalDate toLocalDate(Date date) {
if (date == null) {
return null;
}
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
}
@@ -30,6 +30,7 @@ import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import java.util.Objects;
@@ -52,7 +53,11 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
waybillVO.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource()));
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
waybillVO.setReadonly(currentDeptId != null && !Objects.equals(waybill.getDeptId(), currentDeptId));
waybillVO.setBusinessStatusName(businessStatusName(waybill.getBusinessStatus()));
String displayStatus = WaybillProcessSupport.normalizeBusinessStatus(
waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus());
waybillVO.setBusinessStatus(displayStatus);
waybillVO.setBusinessStatusName(businessStatusName(displayStatus));
waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
return waybillVO;
}