Merge remote-tracking branch 'websoft/dev'

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java
This commit is contained in:
2026-10-29 14:07:28 +08:00
93 changed files with 4723 additions and 542 deletions
@@ -0,0 +1,62 @@
/**
* 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.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 管理端认证审核请求
* <p>
* 同一字段 body 与 query 都可传,两处都给时以 body 为准。
*
* @author Chill
*/
@Data
@Schema(description = "管理端认证审核请求")
public class CertAuditRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "认证记录主键")
private Long id;
@Schema(description = "true 通过,false 驳回")
private Boolean pass;
@Schema(description = "驳回原因;驳回时必填,≤200 字")
private String rejectReason;
/**
* 审核员修订后的全量认证内容(JSON);空或空白表示不修订
* <p>
* 通过时才生效:它既是写回档案的依据,也会原样留痕到认证记录上。
*/
@Schema(description = "审核员修订后的全量认证内容JSON;空表示不修订")
private String auditedSnapshotJson;
}
@@ -0,0 +1,90 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 异常处置 → 录入维修记录请求
* <p>
* 车牌号与车船类型不接收前端传值:车牌取自异常处置,车船类型固定「车辆」,
* 避免维修记录挂到与异常无关的车上。
*
* @author Chill
*/
@Data
@Schema(description = "异常处置录入维修记录请求")
public class ExceptionMaintenanceRecordRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "异常处置ID")
private Long id;
@Schema(description = "维修时间")
private LocalDateTime maintenanceTime;
@Schema(description = "维修位置")
private String location;
@Schema(description = "维修人")
private String maintainer;
@Schema(description = "维修单位")
private String company;
@Schema(description = "联系方式")
private String contact;
@Schema(description = "更换零件")
private String replacedPart;
@Schema(description = "费用")
private BigDecimal cost;
@Schema(description = "里程数(公里)")
private BigDecimal mileage;
@Schema(description = "出厂时间")
private LocalDateTime factoryTime;
@Schema(description = "详细地址")
private String address;
@Schema(description = "备注说明")
private String remark;
@Schema(description = "附件JSON,[{\"name\":\"...\",\"url\":\"...\"}]")
private String attachments;
}
@@ -68,6 +68,9 @@ public class LoadingManage extends TenantEntity {
@Schema(description = "联系电话")
private String driverPhone;
@Schema(description = "司机ID")
private Long driverId;
@Schema(description = "押运人")
private String escortName;
@@ -61,6 +61,12 @@ public class MaintenanceRecord extends TenantEntity {
*/
@Schema(description = "车牌号/船号")
private String vehicleNo;
/**
* 关联异常处置ID(小程序录入时写入)
*/
@Schema(description = "关联异常处置ID(小程序录入时写入)")
@TableField(updateStrategy = FieldStrategy.NEVER)
private Long exceptionId;
/**
* 维修人
*/
@@ -28,6 +28,7 @@ package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -125,6 +126,19 @@ public class TransportCertRecord extends TenantEntity {
@TableField(updateStrategy = FieldStrategy.NEVER)
@Schema(description = "该次提交的全量认证内容JSON")
private String snapshotJson;
/**
* 审核通过时确定的修订版内容(JSON)
* <p>
* 审核员可以在通过时直接改内容:司机提交的是 {@code snapshotJson}(永不可改),
* 最终写回档案、司机端读到的「当前生效内容」以本字段优先,为 null 才回退到
* {@code snapshotJson}。为 null 即「审核员没有修订」。
* <p>
* {@code @JsonIgnore}:司机端的 {@code TransportCertRecordVO} 继承本实体,
* 修订版是审核过程的留痕,不该下发给司机。
*/
@JsonIgnore
@Schema(description = "审核通过时确定的修订版内容JSON;null 表示未修订")
private String auditedSnapshotJson;
/**
* 驳回原因,仅当审核状态为已驳回时有值
*/
@@ -38,7 +38,7 @@ public class AdminHomeBadgesVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "异常处置待办数(disposalStatus≠completed)")
@Schema(description = "异常处置待办数(disposalStatus=pending)")
private long exception;
@Schema(description = "风险记录待办数(disposalStatus=pending)")
@@ -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.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 调度端档案统计(司机 + 车辆证件有效期)
*/
@Data
@Schema(description = "调度端档案统计")
public class ArchiveManageStatsVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "司机证件有效期统计")
private DriverExpiryStatVO driver = new DriverExpiryStatVO();
@Schema(description = "车辆证件有效期统计")
private TransportVehicleExpiryStatVO vehicle = new TransportVehicleExpiryStatVO();
}
@@ -81,6 +81,13 @@ public class CertAuditDetailVO implements Serializable {
@Schema(description = "待审快照:本次提交的全量内容")
private String pendingSnapshotJson;
/**
* 审核员在本次审核中填写的修订版内容;未修订时为 null
* <p>
* 只在「通过」时才会落库,驳回不产生生效内容。审核页据此回显审核员改了什么。
*/
@Schema(description = "本次审核提交的修订版内容;未修订时为 null")
private String auditedSnapshotJson;
@Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null")
private String effectiveSnapshotJson;
@Schema(description = "生效快照记录ID;null 表示该对象从未认证通过")
@@ -48,6 +48,12 @@ public class DriverVO extends Driver {
@Serial
private static final long serialVersionUID = 1L;
/**
* 搜索关键字:同时模糊匹配姓名与手机号(小程序列表用,管理端不传)
*/
@TableField(exist = false)
@Schema(description = "搜索关键字(姓名 / 手机号)")
private String keyword;
/**
* 证件有效期状态:within30-30天内到期,expired-已到期
*/
@@ -106,6 +106,10 @@ public class ExceptionDisposalVO extends ExceptionDisposal {
@Schema(description = "可调整的路线站点,少于两个站时为空")
private List<RouteNode> routeNodes;
@TableField(exist = false)
@Schema(description = "关联的维修记录(车辆故障维修异常录入,未录入时为空对象,判定须看 id)")
private MaintenanceRecordVO maintenanceRecord;
/**
* 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名
*/
@@ -76,4 +76,25 @@ public class MaintenanceRecordVO extends MaintenanceRecord {
@Schema(description = "更新人姓名")
private String updateUserName;
/**
* 关联运单号(由 exception_id 关联异常处置带出)
*/
@TableField(exist = false)
@Schema(description = "关联运单号")
private String waybillNo;
/**
* 关联异常类型(一级)
*/
@TableField(exist = false)
@Schema(description = "关联异常类型")
private String exceptionType;
/**
* 关联异常原因(二级)
*/
@TableField(exist = false)
@Schema(description = "关联异常原因")
private String exceptionReason;
}
@@ -96,5 +96,13 @@ public class TransportCertRecordVO extends TransportCertRecord {
@TableField(exist = false)
@Schema(description = "司机手机号")
private String driverMobile;
/**
* 该条记录是否经过审核员修订
* <p>
* 由服务层按 {@code auditedSnapshotJson} 是否有内容算出,前端据此打「已修订」标记。
*/
@TableField(exist = false)
@Schema(description = "是否经过审核员修订")
private Boolean revised;
}
@@ -48,6 +48,12 @@ public class TransportVehicleVO extends TransportVehicle {
@Serial
private static final long serialVersionUID = 1L;
/**
* 搜索关键字:同时模糊匹配车牌、VIN 与所有人(小程序列表用,管理端不传)
*/
@TableField(exist = false)
@Schema(description = "搜索关键字(车牌 / VIN / 所有人)")
private String keyword;
/**
* 证件有效期状态:within30-30天内到期,expired-已到期
*/
@@ -34,8 +34,10 @@ import java.util.Set;
/**
* 运次查询条件
* <p>
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
* 司机端用 {@link #forDriver(Long)} 按司机档案 ID 收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集。
* <p>
* 归属判据只有司机档案 ID;driver_phone 是派单时的号码快照,不参与判定。
*/
@Data
@Schema(description = "运次查询条件")
@@ -47,8 +49,8 @@ public class TripQueryVO implements Serializable {
@Schema(description = "运次主键(详情按它取单个运次)")
private String tripId;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "司机档案 ID(司机端收口判据,空或非正数一律不放行)")
private Long driverId;
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@@ -92,9 +94,9 @@ public class TripQueryVO implements Serializable {
@Schema(description = "每页条数(按运次,不是运单)")
private Integer size;
public static TripQueryVO forDriver(String driverPhone) {
public static TripQueryVO forDriver(Long driverId) {
TripQueryVO query = new TripQueryVO();
query.setDriverPhone(driverPhone);
query.setDriverId(driverId);
return query;
}
@@ -36,6 +36,7 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.CertAuditRequest;
import org.springblade.transport.pojo.vo.CertAuditCapabilityVO;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
@@ -43,10 +44,13 @@ import org.springblade.transport.service.ICertAuditService;
import org.springblade.transport.support.CertAuditPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.function.Function;
/**
* 认证审核(管理端)
* <p>
@@ -92,13 +96,26 @@ public class CertificationAuditController extends BladeController {
@PostMapping("/audit")
@ApiOperationSupport(order = 3)
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变。通过时可携带修订后的全量认证内容,它将成为新的生效内容并原样留痕")
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
public R<Boolean> audit(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
@Parameter(description = "审核请求体(推荐):id、pass、rejectReason、auditedSnapshotJson")
@RequestBody(required = false) CertAuditRequest body,
@Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id,
@Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass,
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
// 同一字段 body 与 query 都可传,两处都给时以 body 为准。
Long recordId = preferBody(body, CertAuditRequest::getId, id);
Boolean approved = preferBody(body, CertAuditRequest::getPass, pass);
String reason = preferBody(body, CertAuditRequest::getRejectReason, rejectReason);
String revision = preferBody(body, CertAuditRequest::getAuditedSnapshotJson, null);
if (recordId == null) {
return R.fail("缺少认证记录ID");
}
if (approved == null) {
return R.fail("缺少审核结果");
}
return R.status(certAuditService.audit(recordId, Boolean.TRUE.equals(approved), reason, revision));
}
// 本接口不挂 @PreAuth;无权限时返回 false。
@@ -109,4 +126,21 @@ public class CertificationAuditController extends BladeController {
return R.data(certAuditService.capability());
}
/**
* 取一个审核参数:body 里给了就用 body 的,否则用 query 的
*
* @param body 审核请求体,可为 null
* @param getter 从请求体取该字段
* @param queryValue 该字段的 query 参数值
* @param <T> 字段类型
* @return body 字段值;body 为 null 或该字段为 null 时返回 query 参数值
*/
private static <T> T preferBody(CertAuditRequest body, Function<CertAuditRequest, T> getter, T queryValue) {
if (body == null) {
return queryValue;
}
T bodyValue = getter.apply(body);
return bodyValue == null ? queryValue : bodyValue;
}
}
@@ -54,9 +54,10 @@ public class DriverAppController extends BladeController {
@GetMapping("/mine")
@ApiOperationSupport(order = 1)
@Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析")
@Operation(summary = "当前登录司机档案", description = "按登录用户的 user_id 匹配 blade_transport_driver.user_id")
// mobile:旧版小程序仍会带这个参数,已不参与解析,保留只为不让老客户端报错
public R<DriverVO> mine(@RequestParam(required = false) String mobile) {
return R.data(driverAppService.currentByPhone(mobile));
return R.data(driverAppService.current());
}
@GetMapping("/vehicles")
@@ -65,14 +65,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/current-task")
@ApiOperationSupport(order = 1)
@Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单")
@Operation(summary = "首页:当前运输中任务", description = "当前司机档案(按登录用户解析)名下、businessStatus=running 的最新一条运单;归属判据是运单 driver_id")
public R<DriverWaybillCardVO> currentTask() {
return R.data(driverWaybillService.currentTask());
}
@GetMapping("/pending-preview")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数")
@Operation(summary = "首页:待接运单预览", description = "当前司机档案名下、businessStatus=pending 的预览列表与总数;归属判据是运单 driver_id")
public R<DriverWaybillPreviewVO> pendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.pendingPreview(size));
@@ -80,14 +80,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/counts")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成")
@Operation(summary = "运单 Tab 统计", description = "仅统计当前司机档案名下(运单 driver_id)的运单:全部 / 待接单 / 进行中 / 已完成")
public R<DriverWaybillTabCountsVO> counts() {
return R.data(driverWaybillService.tabCounts());
}
@GetMapping("/page")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成")
@Operation(summary = "运单分页列表", description = "仅返回运单 driver_id 等于当前司机档案的数据;status:空=全部,0待接单,1运输中,2已完成")
public R<IPage<DriverWaybillCardVO>> page(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@@ -107,14 +107,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条")
@Operation(summary = "首页:当前运输中运次", description = "当前司机档案可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条;配载段按配载单 driver_id,未配载段按运单 driver_id")
public R<DriverWaybillCardVO> tripCurrentTask() {
return R.data(driverWaybillService.tripCurrentTask());
}
@GetMapping("/task/pending-preview")
@ApiOperationSupport(order = 22)
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
@Operation(summary = "首页:待接运次预览", description = "当前司机档案可见的待接运次预览与总数(按运次计)")
public R<DriverWaybillPreviewVO> tripPendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.tripPendingPreview(size));
@@ -166,7 +166,7 @@ public class DriverWaybillController extends BladeController {
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
@Operation(summary = "司机端行驶轨迹", description = "按当前司机档案校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
public R<WaybillTrackVO> track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(driverWaybillService.track(id));
}
@@ -36,6 +36,7 @@ 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.dto.ExceptionMaintenanceRecordRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IExceptionDisposalService;
@@ -140,4 +141,12 @@ public class ExceptionDisposalController extends BladeController {
return R.success("已调整路线");
}
@PostMapping("/maintenance-record")
@ApiOperationSupport(order = 11)
@Operation(summary = "录入维修记录", description = "车辆故障维修类异常的子流程:写一条关联维修记录并追加跟进记录,不结案;一条异常至多一条;仅需登录态")
public R submitMaintenanceRecord(@RequestBody ExceptionMaintenanceRecordRequest request) {
exceptionDisposalService.submitMaintenanceRecord(request);
return R.success("已保存维修记录");
}
}
@@ -0,0 +1,94 @@
/**
* 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.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import org.springblade.transport.service.IManageArchiveService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 调度端档案(小程序管理端)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/**})。
* 同时兼容未去前缀直连({@code /blade-transport/archive/manage/**})。
* 仅需登录态,不挂管理端菜单鉴权。
* <p>
* 为什么单独一个接口:管理端 {@code /driver/expiry-stat}、{@code /transport-vehicle/expiry-stat}
* 挂在 {@code @PreAuth(menu = "driver")} / {@code @PreAuth(menu = "transport_vehicle")} 下,
* 小程序登录的令牌 roleId 是占位值 "0"、查不到 blade_role_menu,必然被拒(401 请求未授权)。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage", "/blade-transport/archive/manage"})
@Tag(name = "调度端档案", description = "小程序调度端资料页档案统计")
public class ManageArchiveController extends BladeController {
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可选择组织";
private final IManageArchiveService manageArchiveService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "档案统计", description = "司机档案与车辆档案的总数、30 天内到期数、已到期数;不按组织收口")
public R<ArchiveManageStatsVO> stats() {
return R.data(manageArchiveService.stats());
}
// 本接口不挂 @PreAuth;无权限返回 false,不抛 403,供小程序决定空态。
@GetMapping("/capability")
@ApiOperationSupport(order = 2)
@Operation(summary = "档案管理能力判定", description = "当前登录是否为调度端管理员;false 时小程序显示无权限空态,不踢登录")
public R<Boolean> capability() {
return R.data(mpUserDataScopeSupport.isMpDispatcher());
}
@GetMapping("/dept/tree")
@ApiOperationSupport(order = 3)
@Operation(summary = "组织树", description = "小程序组织选择器用:启用中的部门树,已剔除「外部组织」整支;非管理员回业务 403")
public R<List<DeptTreeNodeVO>> deptTree() {
if (!mpUserDataScopeSupport.isMpDispatcher()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.data(manageArchiveService.deptTree());
}
}
@@ -0,0 +1,180 @@
/**
* 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.baomidou.mybatisplus.core.toolkit.Wrappers;
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 jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.wrapper.DriverWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/**
* 调度端司机档案管理(小程序)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/driver/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/driver/**}),
* 同时兼容未去前缀直连。
* <p>
* 为什么不复用管理端的 {@code /driver/**}:那些接口挂在 {@code @PreAuth(menu = "driver")} 下,
* 小程序令牌的 roleId 是占位值 "0"、查不到 blade_role_menu,对任何小程序账号都必然被拒。
* 这里不挂 {@code @PreAuth},改成方法内显式判定管理员身份,且**权限不足必须回业务 403**:
* 小程序请求层把 401 一律当会话失效(清令牌 + 回登录页),回 401 会把越权访问变成被踢下线。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage/driver", "/blade-transport/archive/manage/driver"})
@Tag(name = "调度端司机档案", description = "小程序调度端司机管理")
public class ManageArchiveDriverController extends BladeController {
private static final int DEFAULT_CURRENT = 1;
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 100;
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可管理司机档案";
private final IDriverService driverService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
/**
* 分页
*/
@GetMapping("/page")
@ApiOperationSupport(order = 1)
@Operation(summary = "司机档案分页", description = "keyword 同时模糊匹配姓名与手机号;status 不传为启用停用全量;expireStatus 取 within30 / expired")
public R<IPage<DriverVO>> page(DriverVO driver, Query query) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
fillExpiryDate(driver);
return R.data(driverService.selectDriverPage(Condition.getPage(normalizeQuery(query)), driver));
}
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "司机档案详情")
public R<DriverVO> detail(@Parameter(description = "司机主键", required = true) @RequestParam Long id) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
Driver query = new Driver();
query.setId(id);
return R.data(DriverWrapper.build().entityVO(driverService.getOne(Condition.getQueryWrapper(query))));
}
/**
* 新增或修改
*/
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改司机档案", description = "直接写档案、立即生效,不生成待审快照")
public R submit(@Valid @RequestBody Driver driver) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
// 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入
driver.setEffectiveCertRecordId(null);
boolean saved = driverService.submit(driver);
// submit 内部把空串 trimToNull,update-strategy=not_null 会跳过该字段,导致删不掉大头照;这里显式清空
if (saved && driver.getId() != null && Func.isBlank(driver.getHeadPhoto())) {
driverService.update(Wrappers.<Driver>lambdaUpdate()
.eq(Driver::getId, driver.getId())
.set(Driver::getHeadPhoto, null));
}
return R.status(saved);
}
/**
* 启停用
*/
@PostMapping("/status")
@ApiOperationSupport(order = 4)
@Operation(summary = "启用 / 停用司机", description = "status:1 启用,2 停用。停用司机不进调度检索,但仍可登录")
public R status(@Parameter(description = "主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.status(driverService.changeStatus(id, status));
}
private boolean canManage() {
return mpUserDataScopeSupport.isMpDispatcher();
}
private Query normalizeQuery(Query query) {
if (query == null) {
query = new Query();
}
if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) {
query.setCurrent(DEFAULT_CURRENT);
}
if (query.getSize() == null || query.getSize() <= 0) {
query.setSize(DEFAULT_SIZE);
}
if (query.getSize() > MAX_SIZE) {
query.setSize(MAX_SIZE);
}
return query;
}
private void fillExpiryDate(DriverVO driver) {
if (driver.getToday() == null) {
driver.setToday(LocalDate.now());
}
if (driver.getWarningDate() == null) {
driver.setWarningDate(driver.getToday().plusDays(30));
}
}
}
@@ -0,0 +1,169 @@
/**
* 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 jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import org.springblade.transport.wrapper.TransportVehicleWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/**
* 调度端车辆档案管理(小程序)
* <p>
* 对外完整路径:{@code /api/blade-transport/archive/manage/vehicle/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/vehicle/**}),
* 同时兼容未去前缀直连。
* <p>
* 与管理端 {@code /transport-vehicle/**} 的关系、以及为什么必须回业务 403 而不是 401,
* 见 {@link ManageArchiveDriverController} 的类注释。
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/archive/manage/vehicle", "/blade-transport/archive/manage/vehicle"})
@Tag(name = "调度端车辆档案", description = "小程序调度端车辆管理")
public class ManageArchiveVehicleController extends BladeController {
private static final int DEFAULT_CURRENT = 1;
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 100;
/**
* 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。
*/
private static final int FORBIDDEN_CODE = 403;
private static final String FORBIDDEN_MSG = "仅调度端管理员可管理车辆档案";
private final ITransportVehicleService transportVehicleService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
/**
* 分页
*/
@GetMapping("/page")
@ApiOperationSupport(order = 1)
@Operation(summary = "车辆档案分页", description = "keyword 同时模糊匹配车牌、VIN 与所有人;status 不传为启用停用全量;expireStatus 取 within30 / expired")
public R<IPage<TransportVehicleVO>> page(TransportVehicleVO vehicle, Query query) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
fillExpiryDate(vehicle);
return R.data(transportVehicleService.selectTransportVehiclePage(Condition.getPage(normalizeQuery(query)), vehicle));
}
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "车辆档案详情")
public R<TransportVehicleVO> detail(@Parameter(description = "车辆主键", required = true) @RequestParam Long id) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
TransportVehicle query = new TransportVehicle();
query.setId(id);
return R.data(TransportVehicleWrapper.build().entityVO(transportVehicleService.getOne(Condition.getQueryWrapper(query))));
}
/**
* 新增或修改
*/
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改车辆档案", description = "直接写档案、立即生效,不生成待审快照,也不改动认证状态")
public R submit(@Valid @RequestBody TransportVehicle vehicle) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
// 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入
vehicle.setEffectiveCertRecordId(null);
return R.status(transportVehicleService.submit(vehicle));
}
/**
* 启停用
*/
@PostMapping("/status")
@ApiOperationSupport(order = 4)
@Operation(summary = "启用 / 停用车辆", description = "status:1 启用,2 停用。停用车辆不进车辆检索、司机端车辆列表不再出现")
public R status(@Parameter(description = "主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) {
if (!canManage()) {
return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG);
}
return R.status(transportVehicleService.changeStatus(id, status));
}
private boolean canManage() {
return mpUserDataScopeSupport.isMpDispatcher();
}
private Query normalizeQuery(Query query) {
if (query == null) {
query = new Query();
}
if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) {
query.setCurrent(DEFAULT_CURRENT);
}
if (query.getSize() == null || query.getSize() <= 0) {
query.setSize(DEFAULT_SIZE);
}
if (query.getSize() > MAX_SIZE) {
query.setSize(MAX_SIZE);
}
return query;
}
private void fillExpiryDate(TransportVehicleVO vehicle) {
if (vehicle.getToday() == null) {
vehicle.setToday(LocalDate.now());
}
if (vehicle.getWarningDate() == null) {
vehicle.setWarningDate(vehicle.getToday().plusDays(30));
}
}
}
@@ -147,6 +147,11 @@
<sql id="QueryCondition">
is_deleted = 0
<!-- 小程序列表的单框搜索:姓名或手机号命中其一即可。管理端不传 keyword,条件不生效。 -->
<if test="driver.keyword != null and driver.keyword != ''">
<bind name="driverKeywordLike" value="'%' + driver.keyword + '%'"/>
AND (driver_name LIKE #{driverKeywordLike} OR mobile LIKE #{driverKeywordLike})
</if>
<if test="driver.driverName != null and driver.driverName != ''">
<bind name="driverNameLike" value="'%' + driver.driverName + '%'"/>
AND driver_name LIKE #{driverNameLike}
@@ -14,6 +14,7 @@
<result column="is_deleted" property="isDeleted"/>
<result column="vehicle_type" property="vehicleType"/>
<result column="vehicle_no" property="vehicleNo"/>
<result column="exception_id" property="exceptionId"/>
<result column="maintainer" property="maintainer"/>
<result column="maintenance_time" property="maintenanceTime"/>
<result column="location" property="location"/>
@@ -27,55 +28,63 @@
<result column="mileage_unit" property="mileageUnit"/>
<result column="attachments" property="attachments"/>
<result column="remark" property="remark"/>
<result column="waybill_no" property="waybillNo"/>
<result column="exception_type" property="exceptionType"/>
<result column="exception_reason" property="exceptionReason"/>
</resultMap>
<select id="selectMaintenanceRecordPage" resultMap="maintenanceRecordResultMap">
SELECT
id,
tenant_id,
create_user,
create_dept,
create_time,
update_user,
update_time,
status,
is_deleted,
vehicle_type,
vehicle_no,
maintainer,
maintenance_time,
location,
replaced_part,
cost,
company,
contact,
address,
factory_time,
CASE WHEN mileage = -1 THEN NULL WHEN mileage &lt; 0 THEN 0 ELSE mileage END AS mileage,
mileage_unit,
attachments,
remark
r.id,
r.tenant_id,
r.create_user,
r.create_dept,
r.create_time,
r.update_user,
r.update_time,
r.status,
r.is_deleted,
r.vehicle_type,
r.vehicle_no,
r.exception_id,
r.maintainer,
r.maintenance_time,
r.location,
r.replaced_part,
r.cost,
r.company,
r.contact,
r.address,
r.factory_time,
CASE WHEN r.mileage = -1 THEN NULL WHEN r.mileage &lt; 0 THEN 0 ELSE r.mileage END AS mileage,
r.mileage_unit,
r.attachments,
r.remark,
d.waybill_no,
d.exception_type,
d.exception_reason
FROM
blade_vehicle_maintenance_record
blade_vehicle_maintenance_record r
LEFT JOIN blade_exception_disposal d ON d.id = r.exception_id AND d.is_deleted = 0
WHERE
is_deleted = 0
r.is_deleted = 0
<if test="maintenanceRecord.createDept != null">
AND create_dept = #{maintenanceRecord.createDept}
AND r.create_dept = #{maintenanceRecord.createDept}
</if>
<if test="maintenanceRecord.vehicleType != null and maintenanceRecord.vehicleType != ''">
AND vehicle_type = #{maintenanceRecord.vehicleType}
AND r.vehicle_type = #{maintenanceRecord.vehicleType}
</if>
<if test="maintenanceRecord.vehicleNo != null and maintenanceRecord.vehicleNo != ''">
<bind name="vehicleNoLike" value="'%' + maintenanceRecord.vehicleNo + '%'"/>
AND vehicle_no LIKE #{vehicleNoLike}
AND r.vehicle_no LIKE #{vehicleNoLike}
</if>
<if test="maintenanceRecord.createTimeStart != null">
AND create_time &gt;= #{maintenanceRecord.createTimeStart}
AND r.create_time &gt;= #{maintenanceRecord.createTimeStart}
</if>
<if test="maintenanceRecord.createTimeEnd != null">
AND create_time &lt;= #{maintenanceRecord.createTimeEnd}
AND r.create_time &lt;= #{maintenanceRecord.createTimeEnd}
</if>
ORDER BY create_time DESC
ORDER BY r.create_time DESC
</select>
</mapper>
@@ -0,0 +1,26 @@
package org.springblade.transport.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.system.pojo.entity.Dept;
import java.util.List;
/**
* 组织树只读查询(小程序组织选择器)
* <p>
* 不复用 blade-system 的部门接口:那些接口全挂在 {@code @PreAuth(menu = "dept")} 下,
* 小程序令牌的 roleId 是占位值 "0"、查不到 blade_role_menu,任何小程序账号都会被拒。
* 这里直接按租户读表,只取组织选择需要的四列。
*/
public interface ManageArchiveDeptMapper {
/**
* 租户内启用中的部门,按排序号升序(不含已删除)
*/
@Select("SELECT id, parent_id AS parentId, dept_name AS deptName, sort FROM blade_dept "
+ "WHERE is_deleted = 0 AND status = 1 AND tenant_id = #{tenantId} "
+ "ORDER BY sort, id")
List<Dept> selectEnabledByTenant(@Param("tenantId") String tenantId);
}
@@ -116,6 +116,11 @@
<sql id="QueryCondition">
is_deleted = 0
<!-- 小程序列表的单框搜索:车牌、VIN 或所有人命中其一即可。管理端不传 keyword,条件不生效。 -->
<if test="vehicle.keyword != null and vehicle.keyword != ''">
<bind name="vehicleKeywordLike" value="'%' + vehicle.keyword + '%'"/>
AND (plate_no LIKE #{vehicleKeywordLike} OR vin LIKE #{vehicleKeywordLike} OR owner_name LIKE #{vehicleKeywordLike})
</if>
<if test="vehicle.organizationName != null and vehicle.organizationName != ''">
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 DriverMapper.xml 的同名分支。
@@ -0,0 +1,62 @@
/**
* 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.pojo.vo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 组织树节点(小程序组织选择器)
*
* @author Chill
*/
@Data
@Schema(description = "组织树节点")
public class DeptTreeNodeVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 部门主键:雪花 ID 超出 JS 安全整数,序列化成字符串,前端不做 Number()
*/
@JsonSerialize(using = ToStringSerializer.class)
@Schema(description = "部门主键")
private Long id;
@Schema(description = "部门名称")
private String name;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@Schema(description = "子部门")
private List<DeptTreeNodeVO> children = new ArrayList<>();
}
@@ -74,12 +74,13 @@ public interface ICertAuditService {
/**
* 审核一条认证记录
*
* @param recordId 认证记录主键
* @param pass true 通过,false 驳回
* @param rejectReason 驳回原因;驳回时必填,≤200 字
* @param recordId 认证记录主键
* @param pass true 通过,false 驳回
* @param rejectReason 驳回原因;驳回时必填,≤200 字
* @param auditedSnapshotJson 审核员修订后的全量认证内容;空或空白表示不修订,驳回时忽略
* @return 是否成功
*/
boolean audit(Long recordId, boolean pass, String rejectReason);
boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson);
/**
* 当前登录用户的审核能力:能否查看、能否审核
@@ -34,11 +34,11 @@ import java.util.List;
public interface IDriverAppService {
/**
* 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile)
* 当前登录用户对应的司机档案(按 user_id 匹配 blade_transport_driver.user_id)
*
* @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析
* @return 司机档案;未登录抛异常,查不到返回 null
*/
DriverVO currentByPhone(String mobile);
DriverVO current();
/**
* 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no)
@@ -49,10 +49,9 @@ public interface IDriverAppService {
* 当前登录用户对应的司机**实体**
* <p>
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
* 匹配不到再按 {@code user_id} 兜底。
* 故这里返回实体而非视图对象。解析口径与 {@link #current()} 一致。
*
* @return 司机实体;未登录或查不到时返回 null
* @return 司机实体;未登录抛异常,查不到返回 null
*/
Driver currentDriverEntity();
@@ -111,4 +111,16 @@ public interface IDriverService extends BaseService<Driver> {
*/
List<DriverExcel> exportDriver(Wrapper<Driver> queryWrapper);
/**
* 按手机号解析司机档案 ID
* <p>
* 归属主键是档案 ID,手机号只是派单时的号码快照:只有租户内唯一命中才认,
* 0 条(承运商司机、未登录过小程序的司机)与多条(历史脏数据)都返回 null,不抛异常。
*
* @param tenantId 租户ID,为空时取当前登录租户
* @param mobile 手机号
* @return 司机档案ID;无法唯一确定时为 null
*/
Long resolveDriverIdByMobile(String tenantId, String mobile);
}
@@ -28,6 +28,7 @@ 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.dto.ExceptionMaintenanceRecordRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -75,4 +76,10 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
*/
void adjustRoute(ExceptionDisposalRouteAdjustRequest request);
/**
* 录入关联维修记录:写一条维修记录并追加一条跟进记录,不结案。
* 一条异常至多一条维修记录;已撤销的异常不允许录入。
*/
void submitMaintenanceRecord(ExceptionMaintenanceRecordRequest request);
}
@@ -0,0 +1,45 @@
/**
* 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.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import java.util.List;
/**
* 调度端档案统计
*/
public interface IManageArchiveService {
/**
* 司机与车辆档案的证件有效期统计
*/
ArchiveManageStatsVO stats();
/**
* 组织树:启用中的部门,已剔除「外部组织」整支
*/
List<DeptTreeNodeVO> deptTree();
}
@@ -41,7 +41,7 @@ public interface ITripQueryService {
* <p>
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @param query 查询条件,收口由 driverId(司机档案)或 allDrivers 给出
* @return 运次分页
*/
IPage<TripVO> page(TripQueryVO query);
@@ -26,6 +26,7 @@
package org.springblade.transport.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -138,20 +139,24 @@ public class CertAuditServiceImpl implements ICertAuditService {
detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser()));
detail.setRejectReason(record.getRejectReason());
detail.setPendingSnapshotJson(record.getSnapshotJson());
// 审核员上次填过的修订版:详情页要能回显,否则重开页面就丢了
detail.setAuditedSnapshotJson(record.getAuditedSnapshotJson());
// 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理
detail.setEffectiveRecordId(effectiveId);
detail.setCertified(CertificationRuleSupport.isCertified(effectiveId));
if (Func.isNotEmpty(effectiveId)) {
TransportCertRecord effective = certRecordService.getById(effectiveId);
detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson());
// 生效一侧同样修订版优先:审核页是拿「新提交」和「当前生效内容」做 diff 的,
// 这里取旧值会让审核员看到一份从未生效过的差异
detail.setEffectiveSnapshotJson(CertificationRuleSupport.effectiveSnapshotJson(effective));
}
return detail;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean audit(Long recordId, boolean pass, String rejectReason) {
public boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson) {
TransportCertRecord record = requireRecord(recordId);
// 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」,
@@ -165,6 +170,17 @@ public class CertAuditServiceImpl implements ICertAuditService {
+ TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)");
}
// 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕,
// 否则历史里会出现一条「已驳回却带着生效内容」的记录。
String revision = pass ? blankToNull(auditedSnapshotJson) : null;
// 校验放在写库之前——内容不可用或改动了身份字段就整体回滚,不能先落一条状态再报错。
if (revision != null) {
requireUsableRevision(record, revision);
requireIdentityUnchanged(record, revision);
}
// 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。
String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : revision;
Long auditor = AuthUtil.getUserId();
LocalDateTime auditTime = LocalDateTime.now();
@@ -177,11 +193,13 @@ public class CertAuditServiceImpl implements ICertAuditService {
: CertificationRuleSupport.reasonForReject(rejectReason));
patch.setAuditUser(auditor);
patch.setAuditTime(auditTime);
// 未修订时留 null:框架的 not_null 更新策略会跳过该列,库里保持 NULL 而不是空串
patch.setAuditedSnapshotJson(revision);
certRecordService.updateById(patch);
// 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE,
// 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。
updateCertTarget(record, pass, rejectReason);
updateCertTarget(record, pass, rejectReason, effectiveSnapshotJson);
return true;
}
@@ -212,11 +230,12 @@ public class CertAuditServiceImpl implements ICertAuditService {
* </ul>
* 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
*
* @param record 被审核的记录
* @param pass 是否通过
* @param rejectReason 驳回原因
* @param record 被审核的记录
* @param pass 是否通过
* @param rejectReason 驳回原因
* @param effectiveSnapshotJson 本次生效内容(修订版优先,否则司机原提交)
*/
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason, String effectiveSnapshotJson) {
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
@@ -232,7 +251,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
// 与指针合并成同一次 UPDATE。
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
applySnapshotToVehicle(vehicle, effectiveSnapshotJson);
} else {
vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
}
@@ -255,7 +274,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
// 与指针合并成同一次 UPDATE。
applySnapshotToDriver(driver, record.getSnapshotJson());
applySnapshotToDriver(driver, effectiveSnapshotJson);
} else {
driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
}
@@ -332,6 +351,122 @@ public class CertAuditServiceImpl implements ICertAuditService {
}
}
/**
* 校验审核员填写的修订版内容,不满足时抛错
* <p>
* 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构,
* 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写:
* 司机看身份证号与驾驶证号,车辆看行驶证号与核定载质量。
* <p>
* 不满足时抛错、整体回滚——审核员填错 JSON 是操作失误,不能悄悄降级成「不修订」,
* 那样他会以为自己的修订已经生效。
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
*/
private void requireUsableRevision(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest snapshot = toSnapshot(parsed, VehicleCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg()))) {
throw new ServiceException("修订内容缺少行驶证号与核定载质量,无法写入车辆档案");
}
return;
}
PersonCertSubmitRequest snapshot = toSnapshot(parsed, PersonCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) {
throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案");
}
}
/**
* 身份字段不可修订:修订版改动了身份证号、驾驶证号(司机)或车牌号(车辆)时拒绝
* <p>
* 审核只认定证件内容,身份字段标识「是谁」,改了审核的就是另一个对象。
* 原提交里该字段为空时(旧数据、空快照)没有可比对的原值,不比对,允许补全。
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
*/
private void requireIdentityUnchanged(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (parsed == null) {
return;
}
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), VehicleCertSubmitRequest.class);
if (original == null) {
return;
}
VehicleCertSubmitRequest revised = toSnapshot(parsed, VehicleCertSubmitRequest.class);
requireSameIdentity(original.getPlateNo(), revised.getPlateNo(), "修订内容不能修改车牌号");
return;
}
PersonCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), PersonCertSubmitRequest.class);
if (original == null) {
return;
}
String message = "修订内容不能修改身份证号与驾驶证号";
PersonCertSubmitRequest revised = toSnapshot(parsed, PersonCertSubmitRequest.class);
requireSameIdentity(original.getIdCardNo(), revised.getIdCardNo(), message);
requireSameIdentity(original.getDriverFileNo(), revised.getDriverFileNo(), message);
}
/**
* 原提交有值时,修订版必须与之一致;原提交为空时允许补全
*
* @param original 原提交里的身份字段值
* @param revised 修订版里的身份字段值
* @param message 不一致时抛出的文案
*/
private void requireSameIdentity(String original, String revised, String message) {
if (Func.isEmpty(original)) {
return;
}
if (!original.equals(revised)) {
throw new ServiceException(message);
}
}
/**
* 反序列化审核员填写的修订版 JSON
*
* @param revision 已去空白的修订版内容
* @return 解析结果;内容为字面量 null 时返回 null
*/
private JSONObject parseRevision(String revision) {
try {
return JSON.parseObject(revision);
} catch (Exception exception) {
throw new ServiceException("修订内容格式不正确,请检查后重试");
}
}
/**
* 解析原提交快照;为空或无法解析时返回 null(此时没有可比对的原值)
*
* @param snapshotJson 原提交内容
* @param clazz 目标结构
* @param <T> 提交结构类型
* @return 解析结果;为空或解析失败时返回 null
*/
private <T> T parseSnapshotQuietly(String snapshotJson, Class<T> clazz) {
if (Func.isEmpty(snapshotJson)) {
return null;
}
try {
return JSON.parseObject(snapshotJson, clazz);
} catch (Exception exception) {
return null;
}
}
private <T> T toSnapshot(JSONObject parsed, Class<T> clazz) {
return parsed == null ? null : parsed.toJavaObject(clazz);
}
/**
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
* <p>
@@ -345,33 +480,34 @@ public class CertAuditServiceImpl implements ICertAuditService {
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
* 因此不会把管理端填过、司机端没填的字段清空。
* <p>
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
* 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号,
* 必须阻断。
* 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**:
* 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行,
* 等于给一条空记录盖上已通过的章,状态与内容互相矛盾。
* 内容确实为空的历史记录,由审核员在审核时补全后重审即可。
* 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。
*
* @param driver 待更新的司机 patch
* @param snapshotJson 该条记录的全量提交内容
*/
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
if (Func.isEmpty(snapshotJson)) {
return;
throw new ServiceException("该认证记录没有提交内容,无法写入司机档案;请让司机重新提交一次认证");
}
PersonCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
} catch (Exception exception) {
return;
throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证");
}
if (snapshot == null) {
return;
throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证");
}
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就拒绝。
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照),"
+ "未写入司机档案;请重新提交一次认证");
}
// 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
// 建档查重与按证件号识别同一人都会失效
@@ -27,10 +27,7 @@ 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.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
@@ -55,19 +52,24 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class DriverAppServiceImpl implements IDriverAppService {
/**
* 启用状态。框架基类只有 status 列与 BladeConstant.DB_STATUS_NORMAL = 1,没有枚举;
* 2 是「停用」,业务侧一律写字面量常量。
*/
private static final int ENABLED_STATUS = 1;
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final IMpUserClient mpUserClient;
@Override
public DriverVO currentByPhone(String mobile) {
Driver driver = currentDriver(mobile);
public DriverVO current() {
Driver driver = currentDriver();
return driver == null ? null : DriverWrapper.build().entityVO(driver);
}
@Override
public List<DriverVehicleCardVO> myVehicles() {
Driver driver = currentDriver(null);
Driver driver = currentDriver();
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
return List.of();
}
@@ -78,6 +80,8 @@ public class DriverAppServiceImpl implements IDriverAppService {
// 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异)
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, plates);
// 停用车辆不再出现在司机端:管理员停用即意味着这辆车不该再被司机拿去接单
plateQuery.eq(TransportVehicle::getStatus, ENABLED_STATUS);
List<TransportVehicle> vehicles = transportVehicleService.list(
plateQuery.orderByDesc(TransportVehicle::getUpdateTime));
// 去重(精确与规范化可能命中同一条)
@@ -92,28 +96,23 @@ public class DriverAppServiceImpl implements IDriverAppService {
@Override
public Driver currentDriverEntity() {
return currentDriver(null);
return currentDriver();
}
private Driver currentDriver(String mobileHint) {
/**
* 当前司机的唯一解析入口:只按登录用户的 {@code user_id} 查档案。
* <p>
* 手机号不能用来认人:同一号码可能存在多条档案,按号码查「最近更新的一条」
* 取到的可能是别人的档案;号码本身也会被改。
*/
private Driver currentDriver() {
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;
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
@@ -178,53 +177,4 @@ public class DriverAppServiceImpl implements IDriverAppService {
return date == null ? "" : date.toString();
}
/**
* 解析用于匹配司机档案的手机号。
* 优先级:前端传入且与本人一致的 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.isEmpty(selfPhone)) {
// 解析不出本人号码时不能拿前端传的 mobile 去查:
// currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。
// 这里返回空让它退回按 user_id 查,宁可查不到也不越权。
return null;
}
if (!selfPhone.equals(hint)) {
throw new ServiceException("只能查询本人司机档案");
}
return hint;
}
return selfPhone;
}
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (isMobileLike(account)) {
return account.trim();
}
// 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串,
// 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null) {
String userAccount = result.getData().getAccount();
if (isMobileLike(userAccount)) {
return userAccount.trim();
}
}
return Func.isEmpty(account) ? null : account.trim();
}
/**
* 是否是「可作为司机档案手机号使用」的号码串。
* <p>
* 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号
* (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。
*/
private boolean isMobileLike(String value) {
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
}
}
@@ -209,9 +209,11 @@ public class DriverCertServiceImpl implements IDriverCertService {
Driver driver = requireCurrentDriver();
// 当前司机实体已加载过,直接读它身上的指针,不再回查一次
TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId());
// 生效内容以修订版为准:审核员改过的值才是档案里真正生效的那份,
// 仍读 snapshot_json 会让司机端拿旧值覆盖审核员的改动
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class);
: JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), PersonCertSubmitRequest.class);
}
@Override
@@ -269,10 +271,14 @@ public class DriverCertServiceImpl implements IDriverCertService {
if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权查看该车辆的认证信息");
}
// 停用车辆不再对司机端开放:司机列表里已经不显示它,详情/认证入口也不该能进
if (Func.isNotEmpty(vehicle.getStatus()) && vehicle.getStatus() != 1) {
throw new ServiceException("该车辆已停用,暂不可查看,请联系调度启用");
}
TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId());
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class);
: JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), VehicleCertSubmitRequest.class);
}
@Override
@@ -406,6 +412,7 @@ public class DriverCertServiceImpl implements IDriverCertService {
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
requireEnabledVehicle(vehicle);
return vehicle;
}
@@ -426,11 +433,24 @@ public class DriverCertServiceImpl implements IDriverCertService {
// 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属
throw new ServiceException("该车牌已归属其他司机,请联系管理员");
}
requireEnabledVehicle(existing);
return existing;
}
return createVehicleForDriver(driver, request, normalized);
}
/**
* 停用车辆不接受司机的认证提交
* <p>
* 停用是调度端的管理动作(车辆停驶、报废待办),司机端列表已不再显示停用车;
* 若这里放行,司机仍能凭车牌把停用车提交认证,等于绕过停用。
*/
private void requireEnabledVehicle(TransportVehicle vehicle) {
if (vehicle != null && Func.isNotEmpty(vehicle.getStatus()) && vehicle.getStatus() != 1) {
throw new ServiceException("该车辆已停用,请联系调度启用后再认证");
}
}
/**
* 按车牌(容忍写法差异)查已登记的车辆
*
@@ -25,6 +25,7 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -171,6 +172,31 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
return bindExistingProfile(existing.getId(), mpUserId);
}
@Override
public Long resolveDriverIdByMobile(String tenantId, String mobile) {
String resolvedTenantId = trimToNull(tenantId);
if (resolvedTenantId == null) {
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
}
String mobileSnapshot = trimToNull(mobile);
// 脱敏手机号(如 138****8000)与空号都不是可用业务数据,查不到也不该查
if (resolvedTenantId == null || mobileSnapshot == null || mobileSnapshot.contains("*")) {
return null;
}
// 手机号会被二次放号,同租户内可能残留多条历史档案:只有唯一命中才认,不猜
List<Driver> matchedDrivers = list(Wrappers.<Driver>lambdaQuery()
.select(Driver::getId)
.eq(Driver::getTenantId, resolvedTenantId)
.eq(Driver::getMobile, mobileSnapshot));
if (matchedDrivers.size() != 1) {
log.warn("司机档案解析未唯一命中,driver_id 留空。租户={} 手机号={} 命中={} 条",
resolvedTenantId, DesensitizedUtil.mobilePhone(mobileSnapshot), matchedDrivers.size());
return null;
}
Driver matchedDriver = matchedDrivers.get(0);
return matchedDriver == null ? null : matchedDriver.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverSyncMobileVO syncMobile(String tenantId, Long mpUserId, String oldPhone, String newPhone) {
@@ -33,10 +33,7 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -154,7 +151,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IMpUserClient mpUserClient;
private final IProcessConfigService processConfigService;
private final ITransportVehicleService transportVehicleService;
private final CertificationGate certificationGate;
@@ -166,12 +162,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO currentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return null;
}
// 进行中:running,或无需确认接单但仍为 pending 的历史数据
List<Waybill> candidates = waybillService.list(scopedQuery(phone)
List<Waybill> candidates = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING))
.orderByDesc(Waybill::getUpdateTime)
.last("LIMIT 20"));
@@ -187,12 +183,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillPreviewVO pendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return preview;
}
int limit = normalizePreviewSize(size);
List<Waybill> pendingList = waybillService.list(scopedQuery(phone)
List<Waybill> pendingList = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, PENDING_STATUSES)
.orderByDesc(Waybill::getCreateTime));
List<Waybill> needAccept = pendingList.stream()
@@ -207,11 +203,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillTabCountsVO tabCounts() {
DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return vo;
}
List<Waybill> waybills = waybillService.list(scopedQuery(phone)
List<Waybill> waybills = waybillService.list(scopedQuery(driver.getId())
.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES));
long pending = 0;
long doing = 0;
@@ -243,12 +239,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
Page<DriverWaybillCardVO> empty = new Page<>(pageNo, pageSize);
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return empty;
}
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(phone);
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(driver.getId());
// 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页
applyStatusFilterForQuery(wrapper, status);
applyKeyword(wrapper, keyword);
@@ -271,18 +267,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO detail(Long id) {
requireCurrentDriver();
Waybill waybill = loadDriverWaybill(id);
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO tripCurrentTask() {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return null;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
// 待确认(待回单确认)的趟车司机仍需回到卡片上继续操作,与进行中并列作为当前任务
query.setStatuses(List.of(TRIP_STATUS_RUNNING, TRIP_STATUS_PENDING_RETURN_CONFIRM));
query.setCurrent(1);
@@ -294,11 +289,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return preview;
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setStatus(TRIP_STATUS_PENDING);
query.setCurrent(1);
query.setSize(normalizePreviewSize(size));
@@ -313,11 +308,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillTabCountsVO tripTabCounts() {
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return counts;
}
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(driver.getId()));
counts.setPending(tripCounts.getPending());
counts.setDoing(tripCounts.getDoing());
counts.setConfirm(tripCounts.getConfirm());
@@ -328,11 +323,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
Driver driver = currentDriver();
if (driver == null) {
return new Page<>(normalizePageNo(current), normalizePageSize(size));
}
TripQueryVO query = TripQueryVO.forDriver(phone);
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setStatus(status);
query.setKeyword(keyword);
query.setCurrent(current);
@@ -347,15 +342,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public DriverWaybillCardVO tripDetail(String tripId) {
requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
Driver driver = requireCurrentDriver();
TripQueryVO query = TripQueryVO.forDriver(driver.getId());
query.setTripId(tripId);
return toTripCard(tripQueryService.detail(query), true);
}
@Override
public WaybillTrackVO track(Long id) {
requireCurrentDriver();
Waybill waybill = loadDriverWaybill(id);
LocalDate[] range = resolveTrackDateRange(waybill);
return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString());
@@ -742,7 +736,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
*/
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
TripQueryVO query = TripQueryVO.forDriver(requireCurrentDriver().getId());
query.setTripId(tripId);
TripVO trip = tripQueryService.detail(query);
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
@@ -822,13 +816,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
String phone = currentUserPhone();
if (Func.isEmpty(phone)) {
throw new ServiceException("当前用户手机号为空,无法查找运单");
}
Driver driver = requireCurrentDriver();
// MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后
String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected)
Waybill waybill = waybillService.getOne(scopedQuery(driver.getId(), includeRejected)
.eq(Waybill::getId, id).last(tail));
if (waybill == null) {
throw new ServiceException("运单不存在或无权操作");
@@ -885,14 +876,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号,
* 司机可见运单范围:运单归属档案(driver_id)就是当前司机档案,
* 且该运单未被这个司机拒单。
* <p>
* driver_id 必须有值且大于 0 才算有主:存量数据的 -1 是哨兵值,与 0 一样等同无主。
* 运单上的 driver_phone 只是派单快照,不参与归属判定——司机改号不影响可见性。
* <p>
* 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。
* 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。
*/
private LambdaQueryWrapper<Waybill> scopedQuery(String phone) {
return scopedQuery(phone, false);
private LambdaQueryWrapper<Waybill> scopedQuery(Long driverId) {
return scopedQuery(driverId, false);
}
/**
@@ -900,11 +894,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
* 拒单的运单不可能处于运输中,收口拦不到合法操作,
* 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤
*/
private LambdaQueryWrapper<Waybill> scopedQuery(String phone, boolean includeRejected) {
String normalizedPhone = Func.toStr(phone, "").trim();
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery().and(w -> w
.eq(Waybill::getDriverPhone, normalizedPhone)
.or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone));
private LambdaQueryWrapper<Waybill> scopedQuery(Long driverId, boolean includeRejected) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.gt(Waybill::getDriverId, 0)
.eq(Waybill::getDriverId, driverId);
if (!includeRejected) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
@@ -926,69 +919,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
* 当前用户手机号:司机档案 mobile 优先,其次小程序用户账号。
* 当前登录司机:只按登录用户 ID 查司机档案,查不到即「无归属」。
* <p>
* 派单时运单 driver_phone 取自司机档案 mobile,故档案有手机号就以它为准。
*/
private String currentUserPhone() {
Driver driver = currentDriver();
if (driver != null && isMobileLike(driver.getMobile())) {
return driver.getMobile().trim();
}
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
return null;
}
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null
&& isMobileLike(result.getData().getAccount())) {
return result.getData().getAccount().trim();
}
return null;
}
/**
* 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile
* 归属判据:driver_id = 当前登录人的司机档案 id;driver_phone 不参与判定。
*/
private Driver currentDriver() {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
return null;
}
Driver driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
if (driver != null) {
return driver;
}
String phone = resolveSelfPhone(userId);
if (Func.isNotEmpty(phone)) {
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getMobile, phone)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 1"));
}
return null;
}
/**
* 解析当前小程序用户可用于匹配司机档案的手机号
*/
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (isMobileLike(account)) {
return account.trim();
}
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null
&& isMobileLike(result.getData().getAccount())) {
return result.getData().getAccount().trim();
}
return Func.isEmpty(account) ? null : account.trim();
}
private boolean isMobileLike(String value) {
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
}
private List<String> splitPlates(String drivingVehicle) {
@@ -1308,8 +1250,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Override
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
// 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单司机手机号
requireCurrentDriver();
// 归属口径与司机打卡、司机接单一致:运单 driver_id 必须是当前登录司机的档案(查不到档案即无归属)
loadDriverWaybill(waybillId);
}
@@ -41,22 +41,28 @@ 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.dto.ExceptionMaintenanceRecordRequest;
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.MaintenanceRecord;
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.pojo.vo.MaintenanceRecordVO;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IMaintenanceRecordService;
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.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
@@ -92,6 +98,12 @@ public class ExceptionDisposalServiceImpl
/** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
private static final int REJECT_REASON_MAX_LENGTH = 200;
/** 异常子流程录入的维修记录固定为车辆:入口只对「车辆故障维修」类异常开放 */
private static final String MAINTENANCE_VEHICLE_TYPE = "车辆";
/** 维修记录跟进说明前缀;金额必填,维修单位按实际填写拼在后面 */
private static final String MAINTENANCE_FOLLOW_PREFIX = "已录入维修记录:费用";
/** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
private static final int ROUTE_JSON_MAX_LENGTH = 8000;
private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
@@ -119,17 +131,20 @@ public class ExceptionDisposalServiceImpl
private final IDriverWaybillService driverWaybillService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
private final LoadingManageMapper loadingManageMapper;
private final IMaintenanceRecordService maintenanceRecordService;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService,
IDriverWaybillService driverWaybillService,
MpUserDataScopeSupport mpUserDataScopeSupport,
LoadingManageMapper loadingManageMapper) {
LoadingManageMapper loadingManageMapper,
IMaintenanceRecordService maintenanceRecordService) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
this.driverWaybillService = driverWaybillService;
this.mpUserDataScopeSupport = mpUserDataScopeSupport;
this.loadingManageMapper = loadingManageMapper;
this.maintenanceRecordService = maintenanceRecordService;
}
@Override
@@ -146,6 +161,7 @@ public class ExceptionDisposalServiceImpl
ExceptionDisposalVO vo = convertVO(entity);
fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
vo.setFollowRecords(followRecords(id));
vo.setMaintenanceRecord(maintenanceRecord(id));
return vo;
}
@@ -527,6 +543,76 @@ public class ExceptionDisposalServiceImpl
updateById(disposal);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void submitMaintenanceRecord(ExceptionMaintenanceRecordRequest request) {
if (request == null || request.getId() == null) {
throw new ServiceException("请选择需要录入维修记录的异常");
}
ExceptionDisposal disposal = getExisting(request.getId());
if (STATUS_REVOKED.equals(disposal.getDisposalStatus())) {
throw new ServiceException("已撤销的异常不允许录入维修记录");
}
if (Func.isBlank(disposal.getVehicleNo())) {
throw new ServiceException("异常未记录车牌号,无法录入维修记录");
}
// 唯一索引占位后无法再插入,先按 exception_id 拦住(含已逻辑删除的记录)
long recorded = maintenanceRecordService.count(Wrappers.<MaintenanceRecord>lambdaQuery()
.eq(MaintenanceRecord::getExceptionId, disposal.getId()));
if (recorded > 0) {
throw new ServiceException("该异常已录入维修记录");
}
MaintenanceRecord record = new MaintenanceRecord();
record.setExceptionId(disposal.getId());
record.setVehicleType(MAINTENANCE_VEHICLE_TYPE);
record.setVehicleNo(disposal.getVehicleNo());
record.setMaintenanceTime(request.getMaintenanceTime());
record.setLocation(request.getLocation());
record.setMaintainer(request.getMaintainer());
record.setCompany(request.getCompany());
record.setContact(request.getContact());
record.setReplacedPart(request.getReplacedPart());
record.setCost(request.getCost());
record.setMileage(request.getMileage());
record.setFactoryTime(request.getFactoryTime());
record.setAddress(request.getAddress());
record.setRemark(request.getRemark());
record.setAttachments(request.getAttachments());
// 复用维修记录自身的校验与落库(车船类型、车牌、费用、出厂时间等口径与 PC 一致)
try {
maintenanceRecordService.submit(record);
} catch (DuplicateKeyException exception) {
// 并发重复提交:前一条尚在事务中、计数看不见,唯一索引是最终防线
throw new ServiceException("该异常已录入维修记录");
}
String followContent = maintenanceFollowContent(record.getCost(), record.getCompany());
insertFollowRecord(disposal.getId(), followContent);
disposal.setLatestFollowContent(followContent);
// 录入维修记录不结案:已了结的异常只补记录与最近跟进,不回退状态
if (!WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
disposal.setDisposalStatus(STATUS_PROCESSING);
}
updateById(disposal);
}
/** 跟进说明:金额必填,维修单位为空时只写金额 */
private String maintenanceFollowContent(BigDecimal cost, String company) {
String amount = Func.isEmpty(cost) ? "" : cost.setScale(2, RoundingMode.HALF_UP).toPlainString();
String content = MAINTENANCE_FOLLOW_PREFIX + amount + "元";
return Func.isBlank(company) ? content : content + ",维修单位" + company.trim();
}
/** 异常关联的维修记录:一条异常至多一条,逻辑删除的视为未录入 */
private MaintenanceRecordVO maintenanceRecord(Long disposalId) {
MaintenanceRecord record = maintenanceRecordService.getOne(Wrappers.<MaintenanceRecord>lambdaQuery()
.eq(MaintenanceRecord::getExceptionId, disposalId)
.eq(MaintenanceRecord::getIsDeleted, 0)
.last("LIMIT 1"));
return record == null ? null : BeanUtil.copyProperties(record, MaintenanceRecordVO.class);
}
/** 已了结(已完成 / 已撤销)的异常不允许再次处置 */
private void assertNotSettled(ExceptionDisposal disposal) {
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
@@ -4,6 +4,7 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -11,6 +12,7 @@ import jakarta.annotation.Resource;
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;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
@@ -29,6 +31,7 @@ import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
@@ -89,6 +92,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
@Resource
private IProcessConfigService processConfigService;
@Resource
private IDriverService driverService;
@Override
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
@@ -139,16 +145,17 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
List<Long> oldWaybillIds = new ArrayList<>();
// 新建暂存为草稿;编辑已有单据时保持原业务状态(如待执行),避免被改回草稿
String businessStatus = STATUS_DRAFT;
if (Func.isNotEmpty(loadingManage.getId())) {
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
if (Func.isNotEmpty(oldRecord.getBusinessStatus())) {
businessStatus = oldRecord.getBusinessStatus();
LoadingManage stored = Func.isEmpty(loadingManage.getId()) ? null : loadEditable(loadingManage.getId(), true);
if (stored != null) {
oldWaybillIds = waybillIds(stored.getWaybillIdsJson());
loadingManage.setLoadingNo(stored.getLoadingNo());
loadingManage.setDeptId(stored.getDeptId());
loadingManage.setDeptName(stored.getDeptName());
if (Func.isNotEmpty(stored.getBusinessStatus())) {
businessStatus = stored.getBusinessStatus();
}
}
applyDriverOwnership(loadingManage, stored);
prepareCreateOrUpdate(loadingManage);
loadingManage.setBusinessStatus(businessStatus);
if (Func.isEmpty(loadingManage.getLoadingNo())) {
@@ -200,6 +207,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
}
applyDriverOwnership(loadingManage, oldRecord);
prepareCreateOrUpdate(loadingManage);
if (Func.isEmpty(loadingManage.getLoadingNo())) {
loadingManage.setLoadingNo(nextCode());
@@ -319,6 +327,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCargoName(first.getCargoName());
loadingManage.setVehicleNo(first.getVehicleNo());
loadingManage.setTrailerVehicleNo(first.getTrailerVehicleNo());
loadingManage.setDriverId(first.getDriverId());
loadingManage.setDriverName(first.getDriverName());
loadingManage.setDriverPhone(first.getDriverPhone());
loadingManage.setEscortName(first.getEscortName());
@@ -478,6 +487,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
target.setTrailerVehicleNo(source.getTrailerVehicleNo());
target.setDriverName(source.getDriverName());
target.setDriverPhone(source.getDriverPhone());
target.setDriverId(source.getDriverId());
target.setEscortName(source.getEscortName());
target.setEscortPhone(source.getEscortPhone());
target.setCarrierType(source.getCarrierType());
@@ -505,6 +515,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
target.setBusinessStatus(STATUS_DRAFT);
target.setLoadingNo(nextCode());
prepare(target);
applyDriverOwnership(target, null);
validate(target, true);
save(target);
return detail(target.getId());
@@ -521,6 +532,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
throw new ServiceException("仅司机拒绝接单后允许重新派单");
}
prepare(loadingManage);
applyDriverOwnership(loadingManage, oldRecord);
validate(loadingManage, false);
List<Long> oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
@@ -781,6 +793,32 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
/**
* 写司机归属:档案 ID 只在需要时解析,已落在单据上的归属不会被无关保存抹掉。
* <p>
* 判定顺序:调用方带了正数档案 ID 就用它;更新时 driver_phone 与库中一致且库中 driver_id 非空就保留库中值;
* 其余情况(新建、换号、原本无主)按手机号在租户内唯一匹配司机档案,匹配不到落 null 并留 WARN,不阻断保存。
*/
private void applyDriverOwnership(LoadingManage loadingManage, LoadingManage stored) {
Long driverId = loadingManage.getDriverId();
if (driverId != null && driverId > 0) {
return;
}
if (stored != null && Func.isNotEmpty(stored.getDriverId())
&& Objects.equals(TransportBusinessSupport.trimToNull(stored.getDriverPhone()), loadingManage.getDriverPhone())) {
loadingManage.setDriverId(stored.getDriverId());
return;
}
String tenantId = Func.isNotEmpty(loadingManage.getTenantId()) ? loadingManage.getTenantId() : AuthUtil.getTenantId();
Long resolvedDriverId = driverService.resolveDriverIdByMobile(tenantId, loadingManage.getDriverPhone());
loadingManage.setDriverId(resolvedDriverId);
if (resolvedDriverId == null) {
log.warn("配载单 {} 的司机手机号未唯一匹配司机档案,driver_id 落空。租户={} 手机号={}",
Func.isEmpty(loadingManage.getLoadingNo()) ? "(未编号)" : loadingManage.getLoadingNo(),
tenantId, DesensitizedUtil.mobilePhone(loadingManage.getDriverPhone()));
}
}
private void validate(LoadingManage loadingManage, boolean draftMode) {
TransportBusinessSupport.validateRequired(loadingManage.getLoadingSubNos(), "配载子单号不能为空");
TransportBusinessSupport.validateRequired(loadingManage.getTransportType(), "运输类型不能为空");
@@ -914,6 +952,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
return;
}
List<ContractManage> contracts = availableCarrierContracts(projectIdsOf(waybills));
// 项目下没有任何可用承运商合同时不存在乙方交集约束;承运类型为「承运商」由 validateCarrierContract 要求选择合同
if (Func.isEmpty(contracts)) {
return;
}
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
if (allowedPartyBs.isEmpty()) {
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单");
@@ -1033,8 +1075,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (waybill == null || Func.isEmpty(projectContracts)) {
return Set.of();
}
// 客户合同乙方即我方,应等于承运商合同甲方
String partyB = customerPartyBMap.get(waybill.getContractId());
Long contractId = waybill.getContractId();
// 运单没有客户合同时不按乙方过滤;客户合同乙方即我方,应等于承运商合同甲方
String partyB = contractId == null ? "" : normalizePartyName(customerPartyBMap.get(contractId));
return projectContracts.stream()
.filter(contract -> Objects.equals(contract.getProjectId(), waybill.getProjectId()))
.filter(contract -> Func.isEmpty(partyB) || Objects.equals(normalizePartyName(contract.getPartyA()), partyB))
@@ -1151,6 +1194,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
.set(Waybill::getCarrierContractId, loadingManage.getCarrierContractId())
.set(Waybill::getCarrierName, loadingManage.getCarrierName())
.set(Waybill::getDriverId, loadingManage.getDriverId())
.set(Waybill::getDriverName, loadingManage.getDriverName())
.set(Waybill::getDriverPhone, loadingManage.getDriverPhone())
.set(Waybill::getVehicleNo, loadingManage.getVehicleNo())
@@ -1265,6 +1309,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
.set(Waybill::getCarrierContractId, loadingManage.getCarrierContractId())
.set(Waybill::getCarrierName, loadingManage.getCarrierName())
.set(Waybill::getDriverId, loadingManage.getDriverId())
.set(Waybill::getDriverName, loadingManage.getDriverName())
.set(Waybill::getDriverPhone, loadingManage.getDriverPhone())
.set(Waybill::getVehicleNo, loadingManage.getVehicleNo())
@@ -27,6 +27,7 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil;
@@ -81,6 +82,18 @@ public class MaintenanceRecordServiceImpl extends BaseServiceImpl<MaintenanceRec
return saveOrUpdate(maintenanceRecord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteLogic(List<Long> ids) {
if (Func.isNotEmpty(ids)) {
// 唯一索引不因逻辑删除释放:不清空 exception_id,异常侧会永远显示「已录入」却查不到记录
update(Wrappers.<MaintenanceRecord>lambdaUpdate()
.set(MaintenanceRecord::getExceptionId, null)
.in(MaintenanceRecord::getId, ids));
}
return super.deleteLogic(ids);
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<MaintenanceRecordExcel> importMaintenanceRecord(List<MaintenanceRecordExcel> data) {
@@ -0,0 +1,102 @@
/**
* 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 lombok.AllArgsConstructor;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.transport.mapper.ManageArchiveDeptMapper;
import org.springblade.transport.pojo.vo.ArchiveManageStatsVO;
import org.springblade.transport.pojo.vo.DeptTreeNodeVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IManageArchiveService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 调度端档案统计
*/
@Service
@AllArgsConstructor
public class ManageArchiveServiceImpl implements IManageArchiveService {
/**
* 组织选择器不提供这一支:web 端司机/车辆表单同样剔除「外部组织」。
*/
private static final String EXTERNAL_ORG_NAME = "外部组织";
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final ManageArchiveDeptMapper manageArchiveDeptMapper;
@Override
public ArchiveManageStatsVO stats() {
// 复用管理端同一套统计:today / warningDate 由各 service 的 prepareQuery 自己填,
// 不传筛选条件即全量计数(与小程序侧 searchDrivers 同口径,未按组织收口)
ArchiveManageStatsVO vo = new ArchiveManageStatsVO();
vo.setDriver(driverService.expiryStat(new DriverVO()));
vo.setVehicle(transportVehicleService.expiryStat(new TransportVehicleVO()));
return vo;
}
@Override
public List<DeptTreeNodeVO> deptTree() {
List<Dept> rows = manageArchiveDeptMapper.selectEnabledByTenant(AuthUtil.getTenantId());
Map<Long, DeptTreeNodeVO> nodes = new LinkedHashMap<>();
for (Dept row : rows) {
DeptTreeNodeVO node = new DeptTreeNodeVO();
node.setId(row.getId());
node.setName(Func.toStr(row.getDeptName()));
nodes.put(row.getId(), node);
}
List<DeptTreeNodeVO> roots = new ArrayList<>();
for (Dept row : rows) {
DeptTreeNodeVO node = nodes.get(row.getId());
DeptTreeNodeVO parent = row.getParentId() == null ? null : nodes.get(row.getParentId());
// 父部门停用或删除时该节点作为根保留,避免整支在树上消失
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
}
removeExternalOrg(roots);
return roots;
}
private static void removeExternalOrg(List<DeptTreeNodeVO> nodes) {
nodes.removeIf(node -> EXTERNAL_ORG_NAME.equals(Func.toStr(node.getName()).trim()));
for (DeptTreeNodeVO node : nodes) {
removeExternalOrg(node.getChildren());
}
}
}
@@ -38,6 +38,7 @@ import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeBadgesVO;
@@ -58,6 +59,7 @@ import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
@@ -122,6 +124,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String TRANSPORT_COMMON = "common";
private static final String TRANSPORT_LOAD = "load";
/** 启用状态(base_entity.status):停用的司机不进派单检索、停用的车不进选车列表 */
private static final int ENABLED_STATUS = 1;
private static final int FEED_LIMIT = 20;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
@@ -141,6 +146,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private final IDriverWaybillService driverWaybillService;
private final ITripQueryService tripQueryService;
private final ILoadingManageService loadingManageService;
private final ITransportVehicleService transportVehicleService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
@Override
@@ -164,7 +170,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
home.setStats(stats());
AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
badges.setException(home.getStats().getException());
// 快捷入口的红点是待办数,只数待处理:处置中的已经在跟,不再提醒
badges.setException(countPendingExceptions());
badges.setRisk(countPendingRisks());
badges.setPendingReassign(countPendingReassignments());
home.setBadges(badges);
@@ -859,6 +866,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
// 停用司机不可派单:不进调度检索。停用只拦派单,不拦登录(司机仍能进小程序看自己的运单)
.eq(Driver::getStatus, ENABLED_STATUS)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 20");
if (Func.isNotBlank(key)) {
@@ -877,27 +886,63 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
@Override
public List<AdminVehicleOptionVO> searchVehicles(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.isNotNull(Driver::getDrivingVehicle)
.ne(Driver::getDrivingVehicle, "")
.orderByDesc(Driver::getUpdateTime)
// 权威来源是车辆档案表:此前查的是司机表的 driving_vehicle 文本,既看不见没有司机绑定的车,
// 也无从判断「是否停用」——停用的车会照样出现在派单选车列表里。
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.<TransportVehicle>lambdaQuery()
.eq(TransportVehicle::getIsDeleted, 0)
.eq(TransportVehicle::getStatus, ENABLED_STATUS)
.orderByDesc(TransportVehicle::getUpdateTime)
.last("LIMIT 30");
if (Func.isNotBlank(key)) {
wrapper.like(Driver::getDrivingVehicle, key);
wrapper.like(TransportVehicle::getPlateNo, key);
}
java.util.LinkedHashMap<String, AdminVehicleOptionVO> map = new java.util.LinkedHashMap<>();
for (Driver d : driverService.list(wrapper)) {
String plate = Func.toStr(d.getDrivingVehicle(), "").trim();
if (Func.isBlank(plate) || map.containsKey(plate)) {
List<TransportVehicle> vehicles = transportVehicleService.list(wrapper);
if (Func.isEmpty(vehicles)) {
return List.of();
}
Map<String, String> driverNameByPlate = driverNamesByPlate(vehicles);
List<AdminVehicleOptionVO> options = new ArrayList<>();
for (TransportVehicle vehicle : vehicles) {
String plate = Func.toStr(vehicle.getPlateNo(), "").trim();
if (Func.isBlank(plate)) {
continue;
}
AdminVehicleOptionVO vo = new AdminVehicleOptionVO();
vo.setVehicleNo(plate);
vo.setDriverName(Func.toStr(d.getDriverName(), ""));
map.put(plate, vo);
vo.setDriverName(Func.toStr(driverNameByPlate.get(plate.toUpperCase()), ""));
options.add(vo);
}
return new java.util.ArrayList<>(map.values());
return options;
}
/**
* 车牌 → 绑定司机姓名
* <p>
* 绑定关系是司机侧按车牌文本声明的({@code driver.driving_vehicle}),车辆行不存司机,
* 所以选车列表要回到司机表补名字;多车牌串的司机匹配不到,这条既有缺陷不在本次改动内。
*/
private Map<String, String> driverNamesByPlate(List<TransportVehicle> vehicles) {
List<String> plates = vehicles.stream()
.map(TransportVehicle::getPlateNo)
.filter(Func::isNotEmpty)
.map(String::toUpperCase)
.distinct()
.toList();
if (plates.isEmpty()) {
return Map.of();
}
List<Driver> drivers = driverService.list(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.in(Driver::getDrivingVehicle, plates)
.select(Driver::getDriverName, Driver::getDrivingVehicle));
Map<String, String> names = new LinkedHashMap<>();
for (Driver driver : drivers) {
if (Func.isEmpty(driver.getDrivingVehicle()) || Func.isEmpty(driver.getDriverName())) {
continue;
}
names.putIfAbsent(driver.getDrivingVehicle().toUpperCase(), driver.getDriverName());
}
return names;
}
/** 运输方式字典值 → 展示文案 */
@@ -1169,6 +1214,14 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return exceptionDisposalService.count(wrapper);
}
/** 快捷入口「异常处置」红点:只数待处理(pending),处置中已不算待办 */
private long countPendingExceptions() {
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING);
applyExceptionDeptScope(wrapper);
return exceptionDisposalService.count(wrapper);
}
private long countPendingRisks() {
LambdaQueryWrapper<RiskDisposal> wrapper = Wrappers.<RiskDisposal>lambdaQuery()
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING);
@@ -4,8 +4,10 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.jackson.JsonUtil;
@@ -24,6 +26,7 @@ import org.springblade.transport.pojo.vo.MasterOrderCarrierVO;
import org.springblade.transport.pojo.vo.MasterOrderVO;
import org.springblade.transport.service.IMasterOrderService;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillService;
@@ -50,6 +53,7 @@ import java.util.Objects;
* @author Chill
*/
@Service
@Slf4j
public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, MasterOrder> implements IMasterOrderService {
private static final String CARRIER_SELF = "自运";
@@ -58,13 +62,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
private final ITransportPlanService transportPlanService;
private final IProjectApplyService projectApplyService;
private final IContractManageService contractManageService;
private final IDriverService driverService;
public MasterOrderServiceImpl(IWaybillService waybillService, ITransportPlanService transportPlanService, IProjectApplyService projectApplyService,
IContractManageService contractManageService) {
IContractManageService contractManageService, IDriverService driverService) {
this.waybillService = waybillService;
this.transportPlanService = transportPlanService;
this.projectApplyService = projectApplyService;
this.contractManageService = contractManageService;
this.driverService = driverService;
}
@Override
@@ -263,6 +269,17 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
waybill.setTransportType(string(dispatch, "transportType")); waybill.setCarrierType(string(dispatch, "carrierType"));
waybill.setCarrierContractId(longValue(dispatch, "carrierContractId")); waybill.setCarrierName(string(dispatch, "carrierName")); waybill.setDriverName(string(dispatch, "driverName"));
waybill.setDriverPhone(string(dispatch, "driverPhone")); waybill.setVehicleNo(string(dispatch, "vehicleNo")); waybill.setTrailerVehicleNo(string(dispatch, "trailerVehicleNo"));
// 归属主键是档案 ID:调度带了就用它,否则按手机号解析;解析入口只返回 null 或唯一命中的
// 档案 ID,承运商司机解析为空也照常派单,落空时留 WARN
Long driverId = longValue(dispatch, "driverId");
if (driverId == null || driverId <= 0) {
driverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone());
if (driverId == null) {
log.warn("总单 {} 派单的司机手机号未唯一匹配司机档案,driver_id 落空(不阻断派单)。租户={} 手机号={}",
masterOrder.getMasterNo(), waybill.getTenantId(), DesensitizedUtil.mobilePhone(waybill.getDriverPhone()));
}
}
waybill.setDriverId(driverId);
waybill.setCaptainName(string(dispatch, "captainName")); waybill.setCabinNo(string(dispatch, "cabinNo")); waybill.setContainerNo(string(dispatch, "containerNo"));
waybill.setEscortName(string(dispatch, "escortName")); waybill.setEscortPhone(string(dispatch, "escortPhone")); waybill.setMileage(nullableDecimal(dispatch, "mileage"));
waybill.setDepartureName(string(dispatch, "departureName")); waybill.setDepartureAddress(string(dispatch, "departureAddress"));
@@ -71,7 +71,6 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
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 int CERTIFICATION_PENDING = 0;
private static final int CERTIFICATION_APPROVED = 1;
private static final int CERTIFICATION_REJECTED = 2;
@@ -133,8 +132,9 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
prepare(vehicle);
validate(vehicle);
checkUniquePlateNo(vehicle);
vehicle.setCertificationStatus(CERTIFICATION_PENDING);
vehicle.setCertificationRejectReason(null);
// 保存档案不碰认证状态:认证状态与生效快照只由认证审核流水维护。
// 这里曾强制置 certification_status = 0(审核中),于是管理端每存一次档案,
// 已认证车辆就被改回「审核中」,而审核页并没有对应的待审记录——一个退不出去的死状态。
return saveOrUpdate(vehicle);
}
@@ -389,22 +389,22 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
* 运次可见范围:已配载段按配载单的 driver_id 收口,未配载段按运单的 driver_id 收口。
* <p>
* 手机号允许带空格,与运单列表的收口判据一致。
* 归属判据只有司机档案 ID(null 或非正数视为无主),driver_phone 不参与判定。
* 无主的运次由 {@link #scoped} 在外层挡掉。
*/
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (condition.isAllDrivers()) {
return;
}
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
Long driverId = condition.getDriverId();
wrapper.and(scope -> scope
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
+ " WHERE lm.is_deleted = 0 AND lm.driver_id = {0}))", driverId)
.or(lone -> lone
.apply("IFNULL(loading_no, '') = ''")
.and(row -> row.eq(Waybill::getDriverPhone, phone)
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
.and(row -> row.eq(Waybill::getDriverId, driverId))));
}
/**
@@ -786,7 +786,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
* 按运次主键取配载单:主键命中配载单即配载运次,再按配载单的司机档案 ID 收口
*/
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
LoadingManage loading = loadingManageService.getById(id);
@@ -796,7 +796,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
if (condition.isAllDrivers()) {
return loading;
}
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
return ownedBy(loading.getDriverId(), condition.getDriverId()) ? loading : null;
}
/**
@@ -813,19 +813,23 @@ public class TripQueryServiceImpl implements ITripQueryService {
if (condition.isAllDrivers()) {
return waybill;
}
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
return ownedBy(waybill.getDriverId(), condition.getDriverId()) ? waybill : null;
}
/** 收口条件是否成立:管理端不收口;司机端必须给出有效的档案 ID,缺省一律拒绝 */
private boolean scoped(TripQueryVO condition) {
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
Long driverId = condition.getDriverId();
return condition.isAllDrivers() || (driverId != null && driverId > 0);
}
private boolean samePhone(String left, String right) {
String expected = Func.toStr(right, "").trim().replace(" ", "");
if (Func.isEmpty(expected)) {
return false;
}
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
/**
* 归属判据:行的司机档案 ID 与收口条件相等。
* <p>
* 条件侧缺省(空、0、负数)一律不可见——没有有效档案 ID 就等于没有收口条件,
* 此时任何行都不能算作「本人」,否则业务主键未回填的历史数据会把运次漏给所有人。
*/
private boolean ownedBy(Long ownerId, Long expectedId) {
return expectedId != null && expectedId > 0 && expectedId.equals(ownerId);
}
private TripQueryVO condition(TripQueryVO query) {
@@ -835,7 +839,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
private TripQueryVO withoutTab(TripQueryVO source) {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setDriverId(source.getDriverId());
copy.setAllDrivers(source.isAllDrivers());
copy.setDeptIds(source.getDeptIds());
copy.setProjectIds(source.getProjectIds());
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
@@ -41,6 +42,7 @@ import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.ILoadingManageService;
@@ -68,6 +70,7 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors;
/** 运单批次服务实现。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImportBatchMapper, WaybillImportBatch> implements IWaybillImportBatchService {
@@ -101,6 +104,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
private final IReceivablePayableDetailService receivablePayableDetailService;
private final ILoadingManageService loadingManageService;
private final ISysClient sysClient;
private final IDriverService driverService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -207,7 +211,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
List<Waybill> rowWaybills = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
try {
rowWaybills.add(buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft));
Waybill builtWaybill = buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft);
fillDriverOwnership(builtWaybill, index + 1);
rowWaybills.add(builtWaybill);
} catch (Exception exception) {
throw new ServiceException("第" + (index + 1) + "行" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
@@ -485,6 +491,26 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
return id == null || id <= 0L ? null : id;
}
/**
* 补齐导入运单的司机档案 ID:行内有(老系统导出)就用,否则按手机号解析。
* 解析不到不阻断导入,只留行级 WARN:承运商司机与未登录过小程序的司机本来就没有档案。
*/
private void fillDriverOwnership(Waybill waybill, int rowNo) {
Long driverId = waybill.getDriverId();
if (driverId != null && driverId > 0) {
return;
}
// 解析入口只返回 null 或唯一命中的档案 ID,不需要再按哨兵归一
Long resolvedDriverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone());
if (resolvedDriverId != null) {
waybill.setDriverId(resolvedDriverId);
return;
}
waybill.setDriverId(null);
log.warn("第{}行 运单 {} 的司机手机号未唯一匹配司机档案,driver_id 留空(不阻断导入)",
rowNo, waybill.getWaybillNo());
}
private Waybill buildWaybill(Map<String, Object> row, WaybillImportBatch batch, Long carrierContractId, boolean draft) {
if (row == null) throw new ServiceException("运单数据不正确");
Waybill waybill = new Waybill();
@@ -22,6 +22,7 @@
*/
package org.springblade.transport.service.impl;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -61,6 +62,7 @@ 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.IDriverService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IProjectApplyService;
@@ -110,6 +112,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@jakarta.annotation.Resource
private ILoadingManageService loadingManageService;
@jakarta.annotation.Resource
private IDriverService driverService;
@jakarta.annotation.Resource
private IProcessConfigService processConfigService;
@@ -1610,7 +1615,18 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
TransportBusinessSupport.validateRequired(driverPhone, "手机号不能为空");
TransportBusinessSupport.validateRequired(vehicleNo, "车牌号不能为空");
waybill.setDriverId(request.getDriverId());
// 归属主键是档案 ID,手机号只是派单时的号码快照:调用方带了档案 ID 就用它,
// 否则按手机号解析;解析入口只返回 null 或唯一命中的档案 ID,承运商司机、未建档司机
// 解析为空,派单照旧成功,落空时留 WARN
Long driverId = request.getDriverId();
if (driverId == null || driverId <= 0) {
driverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), driverPhone);
if (driverId == null) {
log.warn("运单 {} 的司机手机号未唯一匹配司机档案,driver_id 落空(不阻断派单)。租户={} 手机号={}",
waybill.getWaybillNo(), waybill.getTenantId(), DesensitizedUtil.mobilePhone(driverPhone));
}
}
waybill.setDriverId(driverId);
waybill.setDriverName(driverName);
waybill.setDriverPhone(driverPhone);
waybill.setVehicleNo(vehicleNo);
@@ -81,6 +81,27 @@ public final class CertificationRuleSupport {
return previousPending;
}
/**
* 取一条记录的「当前生效内容」
* <p>
* 审核员可以在通过时修订内容:修订版存在则以修订版为准,否则回退到司机原提交
* ({@code snapshot_json} 永不修改,它是司机当时提交了什么的历史证据)。
* <p>
* 写回档案、司机端读生效值、审核详情对比三处必须用同一套取法,
* 否则会出现「档案是新值、司机端还是旧值」这种自相矛盾。
*
* @param record 认证记录,可为 null
* @return 生效内容 JSON;记录为空、或两者都为空时返回 null
*/
public static String effectiveSnapshotJson(TransportCertRecord record) {
if (record == null) {
return null;
}
return Func.isNotBlank(record.getAuditedSnapshotJson())
? record.getAuditedSnapshotJson()
: record.getSnapshotJson();
}
/**
* 判定认证对象是否已认证
* <p>
@@ -27,6 +27,7 @@ package org.springblade.transport.wrapper;
import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.pojo.entity.TransportCertRecord;
@@ -70,6 +71,8 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertR
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser()));
// 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus()));
// 是否经过审核员修订:修订版非空才算,空串与空白都不算(前端清空输入框会传空串)
transportCertRecordVO.setRevised(Func.isNotBlank(transportCertRecordVO.getAuditedSnapshotJson()));
return transportCertRecordVO;
}
@@ -0,0 +1,143 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.service.ICertAuditService;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 认证审核接口的入参绑定测试。
* <p>
* 同一字段 body 与 query 都可传,两处都给时以 body 为准。
*/
class CertificationAuditEndpointTest {
private static final Long RECORD_ID = 5001L;
private static final String REVISION = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"}";
private ICertAuditService certAuditService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
certAuditService = mock(ICertAuditService.class);
when(certAuditService.audit(any(), anyBoolean(), any(), any())).thenReturn(true);
mockMvc = MockMvcBuilders.standaloneSetup(new CertificationAuditController(certAuditService)).build();
}
@Test
@DisplayName("老调用:只带 query 的 id/pass/rejectReason,行为不变")
void legacyQueryOnlyStillWorks() throws Exception {
mockMvc.perform(post("/certification-audit/audit")
.param("id", String.valueOf(RECORD_ID))
.param("pass", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(certAuditService).audit(RECORD_ID, true, null, null);
}
@Test
@DisplayName("新调用:body 携带修订内容,透传到服务层")
void bodyCarriesRevision() throws Exception {
String body = """
{"id": 5001, "pass": true, "auditedSnapshotJson": "{\\"realName\\":\\"李四\\",\\"idCardNo\\":\\"450100199001019999\\"}"}
""";
mockMvc.perform(post("/certification-audit/audit")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(certAuditService).audit(RECORD_ID, true, null, REVISION);
}
@Test
@DisplayName("两种都带:以 body 为准(query 里的旧值不得覆盖修订)")
void bodyWinsOverQuery() throws Exception {
String body = """
{"id": 5001, "pass": true}
""";
mockMvc.perform(post("/certification-audit/audit")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.param("id", "6001")
.param("pass", "false"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(certAuditService).audit(RECORD_ID, true, null, null);
}
@Test
@DisplayName("body 只给一半时,缺的字段回退到 query")
void partialBodyFallsBackToQuery() throws Exception {
String body = """
{"id": 5001, "pass": false}
""";
mockMvc.perform(post("/certification-audit/audit")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.param("rejectReason", "行驶证模糊"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(certAuditService).audit(RECORD_ID, false, "行驶证模糊", null);
}
@Test
@DisplayName("缺少 id 或 pass:按业务失败返回(code=400),不能把 null 传进服务层")
void missingIdOrPassIsRejected() throws Exception {
mockMvc.perform(post("/certification-audit/audit").param("pass", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(post("/certification-audit/audit").param("id", String.valueOf(RECORD_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400));
verify(certAuditService, never()).audit(anyLong(), anyBoolean(), any(), any());
}
}
@@ -96,7 +96,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(recordCaptor.capture());
@@ -118,7 +118,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
@@ -141,7 +141,7 @@ class CertAuditServiceImplTest {
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null));
() -> service.audit(RECORD_ID, true, null, null));
assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage());
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
}
@@ -154,7 +154,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
@@ -169,7 +169,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
@@ -183,7 +183,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
@@ -199,7 +199,7 @@ class CertAuditServiceImplTest {
existing.setDrivingVehicle("桂A12345,桂B67890");
when(driverService.getById(DRIVER_ID)).thenReturn(existing);
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
@@ -216,7 +216,7 @@ class CertAuditServiceImplTest {
when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null));
() -> service.audit(RECORD_ID, true, null, null));
assertTrue(exception.getMessage().contains("未生效"), exception.getMessage());
}
@@ -228,7 +228,7 @@ class CertAuditServiceImplTest {
when(driverService.updateById(any(Driver.class))).thenReturn(false);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null));
() -> service.audit(RECORD_ID, true, null, null));
assertTrue(exception.getMessage().contains("司机"), exception.getMessage());
}
@@ -242,7 +242,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
service.audit(RECORD_ID, false, "证件照片模糊");
service.audit(RECORD_ID, false, "证件照片模糊", null);
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(recordCaptor.capture());
@@ -264,7 +264,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
service.audit(RECORD_ID, false, "运输证过期");
service.audit(RECORD_ID, false, "运输证过期", null);
// 指针字段没有被写成 null,也没有被写成新记录
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
@@ -277,8 +277,8 @@ class CertAuditServiceImplTest {
void rejectRequiresReason() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null));
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " "));
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null, null));
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ", null));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@@ -288,7 +288,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, false, "长".repeat(201)));
() -> service.audit(RECORD_ID, false, "长".repeat(201), null));
}
// ------------------------------------------------------------ 终态保护
@@ -303,9 +303,9 @@ class CertAuditServiceImplTest {
TransportCertRecord record = vehicleRecord(terminal);
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null),
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null),
"状态 " + terminal + " 属终态,不应可再审");
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"),
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回", null),
"状态 " + terminal + " 属终态,不应可再审");
}
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
@@ -315,13 +315,13 @@ class CertAuditServiceImplTest {
@DisplayName("记录不存在:拒绝")
void recordNotFound() {
when(certRecordService.getById(RECORD_ID)).thenReturn(null);
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null));
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null));
}
@Test
@DisplayName("记录主键为空:拒绝")
void recordIdRequired() {
assertThrows(ServiceException.class, () -> service.audit(null, true, null));
assertThrows(ServiceException.class, () -> service.audit(null, true, null, null));
}
// ------------------------------------------------------------ 审核详情(diff 用)
@@ -470,7 +470,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<Wrapper<TransportVehicle>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(transportVehicleService).update(captor.capture());
@@ -494,7 +494,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
service.audit(RECORD_ID, true, null);
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<Wrapper<Driver>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(driverService).update(captor.capture());
@@ -518,7 +518,7 @@ class CertAuditServiceImplTest {
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
service.audit(RECORD_ID, false, " 行驶证模糊 ");
service.audit(RECORD_ID, false, " 行驶证模糊 ", null);
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
@@ -527,6 +527,236 @@ class CertAuditServiceImplTest {
verify(transportVehicleService, never()).update(any());
}
// ------------------------------------------------------------ 修订版
@Test
@DisplayName("修订版:通过时写入新列,且 snapshot_json 一个字都不改")
void approveStoresAuditedSnapshot() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
String revision = vehicleRevision();
service.audit(RECORD_ID, true, null, revision);
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(captor.capture());
assertEquals(revision, captor.getValue().getAuditedSnapshotJson(),
"修订版必须原样留痕,供事后追责");
assertNull(captor.getValue().getSnapshotJson(),
"snapshot_json 是司机当时提交的内容,审核 patch 里不得出现");
}
@Test
@DisplayName("修订版:写回车辆档案的是修订内容,不是司机原提交")
void approveAppliesRevisionToVehicleProfile() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
String revision = vehicleRevision();
service.audit(RECORD_ID, true, null, revision);
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
assertEquals("修订后的所有人", captor.getValue().getOwnerName(), "档案要落修订后的内容");
assertEquals(25000, captor.getValue().getApprovedLoadKg());
assertEquals("450100999999", captor.getValue().getDrivingLicenseNo());
}
@Test
@DisplayName("修订版:司机档案同样以修订内容写回")
void approveAppliesRevisionToDriverProfile() {
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
record.setSnapshotJson("{}");
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\","
+ "\"driverFileNo\":\"450100888899\"}";
service.audit(RECORD_ID, true, null, revision);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
assertEquals("李四", captor.getValue().getDriverName());
assertEquals("450100199001019999", captor.getValue().getIdCardNo());
assertEquals("450100888899", captor.getValue().getDrivingLicenseNo());
}
@Test
@DisplayName("修订版:未提供时不写新列,生效内容仍是司机原提交")
void approveWithoutRevisionLeavesColumnNull() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null, null);
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(recordCaptor.capture());
assertNull(recordCaptor.getValue().getAuditedSnapshotJson(),
"未修订时该列保持 NULL,不能塞一份复制品进来");
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(vehicleCaptor.capture());
assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName());
}
@Test
@DisplayName("修订版:纯空白等于未修订(前端清空输入框就是这种情况)")
void blankRevisionIsTreatedAsAbsent() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, true, null, " ");
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(captor.capture());
assertNull(captor.getValue().getAuditedSnapshotJson());
}
@Test
@DisplayName("修订版:JSON 无法解析时拒绝,且记录与档案都不写(事务回滚)")
void approveRejectsMalformedRevision() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, "{不是JSON"));
assertTrue(exception.getMessage().contains("修订内容格式不正确"), exception.getMessage());
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
}
@Test
@DisplayName("修订版:司机缺身份证号与驾驶证号时拒绝(写回等于什么也没写)")
void approveRejectsIncompleteDriverRevision() {
when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING));
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, "{\"realName\":\"李四\"}"));
assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage());
verify(driverService, never()).updateById(any(Driver.class));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@Test
@DisplayName("修订版:车辆缺行驶证号与核定载质量时拒绝")
void approveRejectsIncompleteVehicleRevision() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, "{\"ownerName\":\"只改了个名字\"}"));
assertTrue(exception.getMessage().contains("核定载质量"), exception.getMessage());
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
}
@Test
@DisplayName("驳回:修订内容完全忽略,新列保持 NULL")
void rejectIgnoresRevision() {
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
service.audit(RECORD_ID, false, "行驶证模糊", vehicleRevision());
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(captor.capture());
assertNull(captor.getValue().getAuditedSnapshotJson(),
"驳回不产生生效内容,留修订版只会让人误读");
assertEquals(TransportCertRecord.STATUS_REJECTED, captor.getValue().getStatus());
}
@Test
@DisplayName("空快照不再静默通过:司机记录没有内容、也没有修订时拒绝")
void approveRejectsEmptyDriverSnapshot() {
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
record.setSnapshotJson(null);
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, null));
assertTrue(exception.getMessage().contains("司机档案"), exception.getMessage());
verify(driverService, never()).updateById(any(Driver.class));
}
@Test
@DisplayName("审核详情:修订版内容随详情下发,生效一侧同样修订版优先")
void detailExposesAuditedSnapshot() {
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
record.setAuditedSnapshotJson("{\"vin\":\"REVISED\"}");
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
TransportCertRecord effective = new TransportCertRecord();
effective.setId(777L);
effective.setSnapshotJson("{\"vin\":\"OLD\"}");
effective.setAuditedSnapshotJson("{\"vin\":\"OLD-REVISED\"}");
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
when(certRecordService.getById(777L)).thenReturn(effective);
CertAuditDetailVO detail = service.detail(RECORD_ID);
assertEquals("{\"vin\":\"REVISED\"}", detail.getAuditedSnapshotJson());
assertEquals("{\"vin\":\"OLD-REVISED\"}", detail.getEffectiveSnapshotJson(),
"生效一侧也修订版优先,否则审核页拿旧值做对比");
}
@Test
@DisplayName("身份不可修订:司机修订改了身份证号时拒绝,记录不被改动")
void approveRejectsDriverIdentityChange() {
when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING));
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\","
+ "\"driverFileNo\":\"450100888888\"}";
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, revision));
assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage());
verify(driverService, never()).updateById(any(Driver.class));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@Test
@DisplayName("身份不可修订:车辆修订改了车牌号时拒绝(车牌不参与写回,也不能被悄悄换掉)")
void approveRejectsVehicleIdentityChange() {
String snapshot = "{\"plateNo\":\"桂A32523\",\"drivingLicenseNo\":\"450100123456\","
+ "\"approvedLoadKg\":18000,\"ownerName\":\"广西现代物流集团有限公司\"}";
when(certRecordService.getById(RECORD_ID))
.thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING, snapshot));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
String revision = "{\"plateNo\":\"桂A99999\",\"drivingLicenseNo\":\"450100999999\","
+ "\"approvedLoadKg\":25000}";
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, revision));
assertTrue(exception.getMessage().contains("车牌号"), exception.getMessage());
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@Test
@DisplayName("身份不可修订:原提交该字段为空时允许补全,正常通过")
void approveAllowsCompletingEmptyIdentity() {
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
record.setSnapshotJson("{\"realName\":\"张三\"}");
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
String revision = "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\","
+ "\"driverFileNo\":\"450100888888\"}";
service.audit(RECORD_ID, true, null, revision);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
assertEquals("450100199001011234", captor.getValue().getIdCardNo(),
"原提交没有身份证号时修订版可以补上");
}
private static String vehicleRevision() {
return "{\"drivingLicenseNo\":\"450100999999\",\"approvedLoadKg\":25000,"
+ "\"ownerName\":\"修订后的所有人\"}";
}
// ------------------------------------------------------------ 辅助
private TransportCertRecord vehicleRecord(Integer status) {
@@ -556,6 +786,19 @@ class CertAuditServiceImplTest {
+ "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}";
}
/**
* 一份字段齐全的司机认证快照
* <p>
* 司机侧的空快照不再「静默跳过」而是拒审,故通过类用例不能再拿 "{}" 走流程;
* 身份证号与驾驶证号是「这份快照是否为当前格式」的判据,缺一不可。
*/
static String completeDriverSnapshot() {
return "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\","
+ "\"driverFileNo\":\"450100888888\",\"driverClass\":\"A2\","
+ "\"headPhoto\":\"http://oss/head.jpg\",\"idCardFrontUrl\":\"http://oss/idcard-front.jpg\","
+ "\"idCardBackUrl\":\"http://oss/idcard-back.jpg\"}";
}
private TransportCertRecord driverRecord(Integer status) {
TransportCertRecord record = new TransportCertRecord();
record.setId(RECORD_ID);
@@ -563,7 +806,7 @@ class CertAuditServiceImplTest {
record.setBizId(DRIVER_ID);
record.setBizKey("张三");
record.setStatus(status);
record.setSnapshotJson("{}");
record.setSnapshotJson(completeDriverSnapshot());
return record;
}
@@ -1,15 +1,16 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
@@ -17,19 +18,23 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportVehicleService;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 司机端车辆卡片(重新认证表单的数据来源)测试。
* 司机端「当前司机」解析与车辆卡片(重新认证表单的数据来源)测试。
* <p>
* 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
* 解析侧盯住 ADR-0028 的唯一入口:登录用户 → {@code user_id} → 档案,手机号只是档案上的一个字段,
* 不参与查找;卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
* 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字);
* 标了长期有效的字段不能再下发一个作废的止期。
*/
@@ -42,11 +47,17 @@ class DriverAppServiceImplTest {
private ITransportVehicleService transportVehicleService;
private DriverAppServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 实体元数据就绪后,LambdaQueryWrapper 才能把 Driver::getUserId 解析成列名
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Driver.class);
}
@BeforeEach
void setUp() {
driverService = mock(IDriverService.class);
transportVehicleService = mock(ITransportVehicleService.class);
service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class));
service = new DriverAppServiceImpl(driverService, transportVehicleService);
}
@Test
@@ -85,33 +96,69 @@ class DriverAppServiceImplTest {
assertEquals("2028-12-04", card.getRoadTransportCertStartDate());
}
// ---------------------------------------------------------------- 手机号解析
// ---------------------------------------------------------------- 当前司机解析
@Test
@DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案")
void twelveDigitAccountIsUsableAsPhone() {
assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName());
@DisplayName("同号两条档案时取登录人自己那条——按手机号取「最近更新的一条」会把冒名者当成当前司机")
void currentDriverComesFromUserIdNotMobile() {
Driver owner = driver(9001L, USER_ID, "黄大富", MOBILE);
owner.setUpdateTime(new Date(1735689600000L));
Driver impostor = driver(9002L, 7002L, "冒名者", MOBILE);
impostor.setUpdateTime(new Date(1767225600000L));
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenAnswer(invocation -> {
LambdaQueryWrapper<Driver> wrapper = invocation.getArgument(0);
String sqlSegment = wrapper.getSqlSegment();
if (sqlSegment.contains("mobile")) {
return impostor;
}
return sqlSegment.contains("user_id") ? owner : null;
});
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
// 司机账号本身就是手机号:手机号优先的实现正是靠它命中那条同号档案
authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE);
assertEquals("黄大富", service.current().getDriverName());
}
}
@Test
@DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权")
void maskedUserPhoneFallsBackToAccount() {
MpUser user = new MpUser();
user.setPhone("175****12082");
user.setAccount("175874212082");
@DisplayName("没有档案时返回空数据而不是异常——没档案的登录用户看到的是空页面、也不能操作")
void missingProfileReturnsEmptyInsteadOfFailing() {
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(null);
assertEquals("黄大富", currentByPhone(null, null, user).getDriverName());
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
assertNull(service.current(), "查不到档案时档案为空");
assertNull(service.currentDriverEntity(), "查不到档案时实体为空");
assertTrue(service.myVehicles().isEmpty(), "查不到档案时车辆列表为空");
}
}
@Test
@DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案")
void otherPeoplesMobileIsRejected() {
assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class,
() -> currentByPhone("13900000001", "175874212082", null)).getMessage());
@DisplayName("未登录直接抛异常,不能当成「没有档案」静默返回空")
void anonymousIsRejected() {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(null);
assertEquals("未登录", assertThrows(ServiceException.class,
() -> service.current()).getMessage());
assertEquals("未登录", assertThrows(ServiceException.class, service::myVehicles).getMessage());
assertEquals("未登录", assertThrows(ServiceException.class, service::currentDriverEntity).getMessage());
}
}
// ---------------------------------------------------------------- 辅助
private Driver driver(Long id, Long userId, String driverName, String mobile) {
Driver driver = new Driver();
driver.setId(id);
driver.setUserId(userId);
driver.setDriverName(driverName);
driver.setMobile(mobile);
return driver;
}
/** 造一辆只关心有效期三个字段的车 */
private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) {
TransportVehicle vehicle = new TransportVehicle();
@@ -129,13 +176,9 @@ class DriverAppServiceImplTest {
return vehicle;
}
/** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */
/** 按当前登录司机跑一次「我的车辆」,取第一张卡片 */
private DriverVehicleCardVO firstCard(TransportVehicle vehicle) {
Driver driver = new Driver();
driver.setId(9001L);
driver.setUserId(USER_ID);
driver.setDriverName("韦司机");
driver.setMobile(MOBILE);
Driver driver = driver(9001L, USER_ID, "韦司机", MOBILE);
driver.setDrivingVehicle(vehicle.getPlateNo());
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
@@ -143,39 +186,9 @@ class DriverAppServiceImplTest {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
// 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心
authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE);
List<DriverVehicleCardVO> cards = service.myVehicles();
assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车");
return cards.get(0);
}
}
/**
* 按给定的登录态跑一次 currentByPhone。
*
* @param mobileHint 前端传入的 mobile,可为 null
* @param account JWT 里的账号,可为 null
* @param user 小程序用户,可为 null(表示走不到小程序用户中心)
*/
private Driver currentByPhone(String mobileHint, String account, MpUser user) {
IMpUserClient mpUserClient = mock(IMpUserClient.class);
if (user != null) {
when(mpUserClient.userById(USER_ID)).thenReturn(R.data(user));
}
DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, mpUserClient);
Driver driver = new Driver();
driver.setId(9002L);
driver.setUserId(USER_ID);
driver.setDriverName("黄大富");
driver.setMobile("175874212082");
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
authUtil.when(AuthUtil::getUserAccount).thenReturn(account);
return scoped.currentByPhone(mobileHint);
}
}
}
@@ -358,6 +358,56 @@ class DriverCertServiceImplTest {
assertNull(service.getEffectivePersonCert());
}
@Test
@DisplayName("读生效值:有修订版时返回修订版内容(司机端不能拿旧值覆盖审核员的改动)")
void getEffectivePersonCertPrefersRevision() {
Long effectiveId = 777L;
Driver driver = driver(PLATE);
driver.setEffectiveCertRecordId(effectiveId);
when(driverAppService.currentDriverEntity()).thenReturn(driver);
when(certRecordService.getById(effectiveId)).thenReturn(pointedRecord(effectiveId,
"司机原提交", "审核员修订"));
PersonCertSubmitRequest result = service.getEffectivePersonCert();
assertEquals("审核员修订", result.getRealName());
}
@Test
@DisplayName("读生效值:车辆同理,修订版优先")
void getEffectiveVehicleCertPrefersRevision() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
when(certRecordService.getById(777L)).thenReturn(pointedVehicleRecord(777L,
"司机原提交", "审核员修订"));
VehicleCertSubmitRequest result = service.getEffectiveVehicleCert(VEHICLE_ID);
assertEquals("审核员修订", result.getOwnerName());
}
private TransportCertRecord pointedRecord(Long id, String submittedName, String revisedName) {
PersonCertSubmitRequest submitted = new PersonCertSubmitRequest();
submitted.setRealName(submittedName);
PersonCertSubmitRequest revised = new PersonCertSubmitRequest();
revised.setRealName(revisedName);
TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED);
record.setSnapshotJson(JSON.toJSONString(submitted));
record.setAuditedSnapshotJson(JSON.toJSONString(revised));
return record;
}
private TransportCertRecord pointedVehicleRecord(Long id, String submittedName, String revisedName) {
VehicleCertSubmitRequest submitted = new VehicleCertSubmitRequest();
submitted.setOwnerName(submittedName);
VehicleCertSubmitRequest revised = new VehicleCertSubmitRequest();
revised.setOwnerName(revisedName);
TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED);
record.setSnapshotJson(JSON.toJSONString(submitted));
record.setAuditedSnapshotJson(JSON.toJSONString(revised));
return record;
}
@Test
@DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)")
void resubmitKeepsEffectiveValue() {
@@ -12,7 +12,6 @@ import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -95,7 +94,6 @@ class DriverNodePunchPhotoTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -92,7 +91,6 @@ class DriverPunchFinishFlagTest {
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -10,7 +10,6 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -87,7 +86,6 @@ class DriverPunchNodeCardTest {
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -0,0 +1,131 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.feign.ISysClient;
import org.springblade.transport.mapper.DriverMapper;
import org.springblade.transport.pojo.entity.Driver;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 手机号 -> 司机档案 ID 解析入口的测试。
* <p>
* 归属主键是档案 ID,手机号只是派单时的号码快照:只有租户内唯一命中才认,0 条与多条都不认。
* 解析结果是"能不能写归属"的判断,不是业务校验,因此任何入参都只返回 null,不抛异常。
*/
class DriverResolveMobileTest {
private static final String TENANT_ID = "000000";
private static final String MOBILE = "13800138000";
private DriverMapper driverMapper;
private DriverServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Driver.class);
}
@BeforeEach
void setUp() throws Exception {
driverMapper = mock(DriverMapper.class);
service = new DriverServiceImpl(mock(IMpUserClient.class), mock(ISysClient.class));
inject("baseMapper", driverMapper);
}
@Test
@DisplayName("租户内唯一命中:返回档案 ID")
void returnsProfileIdWhenUnique() {
when(driverMapper.selectList(any())).thenReturn(List.of(profile(9001L)));
assertEquals(9001L, service.resolveDriverIdByMobile(TENANT_ID, MOBILE));
}
@Test
@DisplayName("租户内没有档案:返回 null")
void returnsNullWhenAbsent() {
when(driverMapper.selectList(any())).thenReturn(Collections.emptyList());
assertNull(service.resolveDriverIdByMobile(TENANT_ID, MOBILE));
}
@Test
@DisplayName("租户内多条档案(历史同号):返回 null,不猜")
void returnsNullWhenMultiple() {
List<Driver> profiles = new ArrayList<>();
profiles.add(profile(9001L));
profiles.add(profile(9002L));
when(driverMapper.selectList(any())).thenReturn(profiles);
assertNull(service.resolveDriverIdByMobile(TENANT_ID, MOBILE));
}
@Test
@DisplayName("手机号为空或脱敏串:返回 null,且不查库")
void returnsNullWithoutQueryForBlankOrMaskedMobile() {
assertNull(service.resolveDriverIdByMobile(TENANT_ID, null));
assertNull(service.resolveDriverIdByMobile(TENANT_ID, " "));
assertNull(service.resolveDriverIdByMobile(TENANT_ID, "138****8000"));
verify(driverMapper, never()).selectList(any());
}
@Test
@DisplayName("租户为空:取当前登录租户,仍不抛异常")
void fallsBackToCurrentTenant() {
when(driverMapper.selectList(any())).thenReturn(List.of(profile(9001L)));
Long driverId = null;
try (org.mockito.MockedStatic<org.springblade.core.secure.utils.AuthUtil> authUtil
= org.mockito.Mockito.mockStatic(org.springblade.core.secure.utils.AuthUtil.class)) {
authUtil.when(org.springblade.core.secure.utils.AuthUtil::getTenantId).thenReturn(TENANT_ID);
driverId = service.resolveDriverIdByMobile(null, MOBILE);
}
assertEquals(9001L, driverId);
}
private Driver profile(Long id) {
Driver profile = new Driver();
profile.setId(id);
profile.setIsDeleted(0);
profile.setTenantId(TENANT_ID);
profile.setMobile(MOBILE);
return profile;
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}
@@ -105,6 +105,17 @@ class DriverSyncMobileTest {
verify(driverMapper, never()).updateById(any(Driver.class));
}
@Test
@DisplayName("改号链路不持有运单/配载服务:换号只改档案,不回写单据上的手机号快照")
void phoneSyncDoesNotDependOnWaybillServices() {
for (Field field : DriverServiceImpl.class.getDeclaredFields()) {
String typeName = field.getType().getSimpleName();
assertFalse(typeName.contains("Waybill") || typeName.contains("LoadingManage"),
"改号链路引入了 " + typeName + ":换号不得顺手回写运单/配载单的司机快照,"
+ "否则司机档案与历史单据的归属会一起漂移");
}
}
@Test
@DisplayName("档案 mobile 与账号旧号不一致:不动档案,返回可读原因(不抛异常)")
void skipsWhenProfileMobileDiffersFromOldPhone() {
@@ -14,7 +14,6 @@ import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -57,7 +56,10 @@ class DriverTripAcceptRejectTest {
private static final Long USER_ID = 7401L;
private static final Long DRIVER_ID = 9001L;
private static final String PHONE = "17587421208";
/** 派单时落在运次上的号码快照。 */
private static final String SNAPSHOT_PHONE = "17587421208";
/** 司机改号后档案与登录账号上的号码。 */
private static final String CURRENT_PHONE = "17687421208";
private static final String TRIP_ID = "2101146192739459074";
private static final Long PENDING_ID = 2101144166395699202L;
private static final Long SECOND_ID = 2101145869819994113L;
@@ -85,7 +87,6 @@ class DriverTripAcceptRejectTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
certificationGate,
@@ -121,7 +122,8 @@ class DriverTripAcceptRejectTest {
"两张子运单与接单司机一起下发:" + capturedUpdateParams());
assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql());
assertEquals(TRIP_ID, capturedQuery().getTripId());
assertEquals(PHONE, capturedQuery().getDriverPhone(), "归属仍按司机手机号收口");
assertEquals(DRIVER_ID, capturedQuery().getDriverId(),
"运次查询按司机档案 ID 收口:手机号换了也认得出本人");
}
@Test
@@ -340,6 +342,16 @@ class DriverTripAcceptRejectTest {
verify(waybillService, never()).update(any());
}
@Test
@DisplayName("没有司机档案的账号不能运次接单:不按手机号回退、不提供代报")
void tripAcceptWithoutDriverProfileIsRefused() {
when(driverService.getOne(any())).thenReturn(null);
assertEquals("未找到当前登录司机档案", assertThrows(ServiceException.class,
() -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage());
verify(waybillService, never()).update(any());
}
// ------------------------------------------------------------ 夹具
private <T> T runAsDriver(Supplier<T> action) {
@@ -373,7 +385,7 @@ class DriverTripAcceptRejectTest {
private void stubTrip(TripVO.TripWaybillVO[] leading, TripVO.TripWaybillVO... children) {
TripVO trip = new TripVO();
trip.setId(TRIP_ID);
trip.setDriverPhone(PHONE);
trip.setDriverPhone(SNAPSHOT_PHONE);
List<TripVO.TripWaybillVO> all = new java.util.ArrayList<>(List.of(leading));
all.addAll(List.of(children));
trip.setWaybills(all);
@@ -400,7 +412,7 @@ class DriverTripAcceptRejectTest {
driver.setUserId(USER_ID);
driver.setDriverName("黄宇富");
driver.setDrivingVehicle("桂A32523");
driver.setMobile(PHONE);
driver.setMobile(CURRENT_PHONE);
return driver;
}
@@ -10,7 +10,6 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -74,7 +73,6 @@ class DriverTripCardTest {
private IWaybillService waybillService;
private IDriverService driverService;
private IMpUserClient mpUserClient;
private ITripQueryService tripQueryService;
private WaybillEnroutePunchMapper enroutePunchMapper;
private WaybillNodePunchMapper nodePunchMapper;
@@ -94,14 +92,12 @@ class DriverTripCardTest {
void setUp() {
waybillService = mock(IWaybillService.class);
driverService = mock(IDriverService.class);
mpUserClient = mock(IMpUserClient.class);
tripQueryService = mock(ITripQueryService.class);
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
nodePunchMapper = mock(WaybillNodePunchMapper.class);
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mpUserClient,
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -125,7 +121,7 @@ class DriverTripCardTest {
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
given = capturedQuery();
assertEquals(PHONE, given.getDriverPhone());
assertEquals(DRIVER_ID, given.getDriverId(), "运次列表按司机档案 ID 收口");
assertEquals(1, given.getCurrent());
assertEquals(10, given.getSize(), "一页 10 条即 10 个运次");
assertNull(given.getStatus());
@@ -334,10 +330,9 @@ class DriverTripCardTest {
}
@Test
@DisplayName("收口缺失时不放行:空页、零统计、当前任务为空,且不查运次")
void missingPhoneIsRefused() {
@DisplayName("收口缺失时不放行:没有司机档案就空页、零统计、当前任务为空,且不查运次")
void missingDriverProfileIsRefused() {
when(driverService.getOne(any())).thenReturn(null);
when(mpUserClient.userById(any())).thenReturn(null);
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(null, null, null, null));
DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null));
@@ -11,7 +11,6 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -116,7 +115,6 @@ class DriverWaybillAutoCompleteTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -73,7 +72,6 @@ class DriverWaybillCardLocationTest {
service = new DriverWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -55,7 +54,6 @@ class DriverWaybillNodePunchTest {
service = new DriverWaybillServiceImpl(
mock(IWaybillService.class),
mock(IDriverService.class),
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -12,7 +12,6 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -27,26 +26,34 @@ import org.springblade.transport.support.CertificationGate;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机对运单的归属校验(异常上报等按运单操作的前置条件)。
* <p>
* 口径:当前用户手机号匹配运单 {@code driverPhone}。
* 口径:登录用户按 {@code user_id} 解析到司机档案,运单归属键是运单上的 {@code driver_id};
* {@code driver_phone} 只是派单快照,不参与判定。
* 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。
*/
class DriverWaybillOwnershipTest {
private static final Long WAYBILL_ID = 5301L;
private static final Long USER_ID = 7001L;
private static final String PHONE = "13800138000";
private static final Long DRIVER_ID = 9001L;
private static final Long OTHER_DRIVER_ID = 9002L;
/** 派单时落在运单上的号码快照。 */
private static final String SNAPSHOT_PHONE = "13800138000";
/** 司机改号后档案与登录账号上的号码。 */
private static final String CURRENT_PHONE = "13900139000";
private IDriverService driverService;
private IWaybillService waybillService;
@@ -58,6 +65,7 @@ class DriverWaybillOwnershipTest {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, Driver.class);
}
@BeforeEach
@@ -67,7 +75,6 @@ class DriverWaybillOwnershipTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -91,33 +98,26 @@ class DriverWaybillOwnershipTest {
}
@Test
@DisplayName("司机档案与登录态都没有手机号时被拒")
void withoutPhoneIsRejected() {
Driver driver = driver(null);
when(driverService.getOne(any())).thenReturn(driver);
@DisplayName("司机档案只按登录用户 ID 解析,不按手机号回退")
void driverProfileResolvedByUserIdOnly() {
when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE));
when(waybillService.getOne(any())).thenReturn(waybill());
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage());
ArgumentCaptor<LambdaQueryWrapper<Driver>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(driverService, times(1)).getOne(captor.capture());
LambdaQueryWrapper<Driver> wrapper = captor.getValue();
assertTrue(wrapper.getTargetSql().contains("user_id"),
"司机档案必须按登录用户 ID 解析:" + wrapper.getTargetSql());
assertFalse(wrapper.getTargetSql().contains("mobile"),
"司机档案不允许再按手机号回退:" + wrapper.getTargetSql());
}
@Test
@DisplayName("运单司机手机号与当前用户不一致时被拒")
void waybillOfAnotherPhoneIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(PHONE));
when(waybillService.getOne(any())).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("运单不存在或无权操作", exception.getMessage());
}
@Test
@DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛")
void ownWaybillPasses() {
when(driverService.getOne(any())).thenReturn(driver(PHONE));
@DisplayName("司机改号后本人运单仍放行,归属只认 driver_id")
void ownWaybillPassesAfterNumberChange() {
when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE));
when(waybillService.getOne(any())).thenReturn(waybill());
assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID));
@@ -125,17 +125,55 @@ class DriverWaybillOwnershipTest {
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(waybillService).getOne(captor.capture());
LambdaQueryWrapper<Waybill> wrapper = captor.getValue();
assertTrue(wrapper.getTargetSql().contains("driver_phone"),
"归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql());
assertTrue(wrapper.getParamNameValuePairs().values().stream()
.anyMatch(PHONE::equals),
"归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs());
// 护栏:归属查询必须落在运单 driver_id 上,不能再出现手机号比较,否则改号即失效。
assertTrue(wrapper.getTargetSql().contains("driver_id"),
"归属查询必须落在运单司机档案 ID 上:" + wrapper.getTargetSql());
assertFalse(wrapper.getTargetSql().contains("driver_phone"),
"归属查询不能再比较运单司机手机号快照:" + wrapper.getTargetSql());
assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID),
"归属查询必须带上当前司机档案 ID:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getParamNameValuePairs().values().contains(SNAPSHOT_PHONE),
"快照号不参与归属判定:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getParamNameValuePairs().values().contains(CURRENT_PHONE),
"档案手机号不参与归属判定:" + wrapper.getParamNameValuePairs());
}
@Test
@DisplayName("同号换人:同手机号但不同档案看不到对方的运单")
void waybillOwnedByAnotherDriverWithSamePhoneIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE));
// 库里存在该运单,但归属档案是另一个同号档案,按 driver_id 收口后查不到。
when(waybillService.getOne(any())).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(WAYBILL_ID));
assertEquals("运单不存在或无权操作", exception.getMessage());
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(waybillService).getOne(captor.capture());
LambdaQueryWrapper<Waybill> wrapper = captor.getValue();
// 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的
wrapper.getTargetSql();
assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID),
"收口条件必须是当前档案 ID:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getParamNameValuePairs().values().contains(OTHER_DRIVER_ID),
"不能把别人的档案 ID 当查询条件:" + wrapper.getParamNameValuePairs());
}
@Test
@DisplayName("司机档案没有手机号也能按档案 ID 认领自己的运单")
void profileWithoutPhoneStillOwnsByDriverId() {
when(driverService.getOne(any())).thenReturn(driver(null));
when(waybillService.getOne(any())).thenReturn(waybill());
assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID));
}
@Test
@DisplayName("运单ID为空时被拒")
void withoutWaybillIdIsRejected() {
when(driverService.getOne(any())).thenReturn(driver(PHONE));
when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE));
ServiceException exception = assertThrows(ServiceException.class,
() -> runOwnershipCheck(null));
@@ -148,14 +186,14 @@ class DriverWaybillOwnershipTest {
private void runOwnershipCheck(Long waybillId) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE);
authUtil.when(AuthUtil::getUserAccount).thenReturn(CURRENT_PHONE);
service.assertCurrentDriverOwnsWaybill(waybillId);
}
}
private Driver driver(String mobile) {
Driver driver = new Driver();
driver.setId(9001L);
driver.setId(DRIVER_ID);
driver.setUserId(USER_ID);
driver.setDriverName("韦司机");
driver.setMobile(mobile);
@@ -166,7 +204,8 @@ class DriverWaybillOwnershipTest {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610070001");
waybill.setDriverPhone(PHONE);
waybill.setDriverId(DRIVER_ID);
waybill.setDriverPhone(SNAPSHOT_PHONE);
waybill.setIsDeleted(0);
return waybill;
}
@@ -15,7 +15,6 @@ import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -34,6 +33,7 @@ import org.springblade.transport.support.CertificationGate;
import java.util.List;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -58,7 +58,10 @@ class DriverWaybillRejectDetachTest {
private static final Long USER_ID = 7401L;
private static final Long DRIVER_ID = 9001L;
private static final String PLATE = "沪AE31312";
private static final String PHONE = "13800138002";
/** 派单时落在运单上的号码快照。 */
private static final String SNAPSHOT_PHONE = "13800138002";
/** 司机改号后档案与登录账号上的号码。 */
private static final String CURRENT_PHONE = "13900139002";
private static final Long REJECTED_ID = 7201L;
private IDriverService driverService;
@@ -83,7 +86,6 @@ class DriverWaybillRejectDetachTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
certificationGate,
@@ -155,6 +157,29 @@ class DriverWaybillRejectDetachTest {
assertEquals(2, page.getRecords().size());
}
@Test
@DisplayName("司机改号后运单仍可见,卡片仍展示派单时的号码快照")
void numberChangeKeepsWaybillsVisible() {
// 同一个档案:档案与登录账号已换成新号,运单快照还是派单时的旧号
when(waybillService.list(ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any()))
.thenReturn(List.of(waybill(7202L, "YD-20261001-0002", "pending", "pending")));
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.page(1, 10, null, null));
assertEquals(1, page.getTotal(), "改号不影响本人运单的可见性");
assertEquals(SNAPSHOT_PHONE, page.getRecords().get(0).getDriverPhone(),
"卡片展示的仍是运单上的号码快照,不改成本人新号");
}
@Test
@DisplayName("重新派回后运单恢复可见:归属看新的 driver_id,不看历史拒单")
void reassignedWaybillBecomesVisibleAgain() {
when(waybillService.getOne(any()))
.thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "pending", "pending"));
assertDoesNotThrow(() -> runAsDriver(() -> service.detail(REJECTED_ID)));
}
@Test
@DisplayName("已拒单运单的详情不可达:取单条件已把它挡住")
void detailOfRejectedIsUnreachable() {
@@ -218,7 +243,7 @@ class DriverWaybillRejectDetachTest {
driver.setUserId(USER_ID);
driver.setDriverName("黄大富");
driver.setDrivingVehicle(PLATE);
driver.setMobile(PHONE);
driver.setMobile(CURRENT_PHONE);
return driver;
}
@@ -231,7 +256,8 @@ class DriverWaybillRejectDetachTest {
waybill.setId(id);
waybill.setWaybillNo(no);
waybill.setVehicleNo(PLATE);
waybill.setDriverPhone(PHONE);
waybill.setDriverId(DRIVER_ID);
waybill.setDriverPhone(SNAPSHOT_PHONE);
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setIsDeleted(0);
@@ -12,7 +12,6 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
@@ -35,6 +34,7 @@ import java.util.List;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -51,8 +51,12 @@ class DriverWaybillTabStatusTest {
private static final Long WAYBILL_ID = 5901L;
private static final Long USER_ID = 7001L;
private static final Long DRIVER_ID = 9001L;
private static final String PLATE = "桂AAW3256";
private static final String PHONE = "13800138002";
/** 派单时落在运单上的号码快照。 */
private static final String SNAPSHOT_PHONE = "13800138002";
/** 司机改号后档案与登录账号上的号码。 */
private static final String CURRENT_PHONE = "13900139002";
private IWaybillService waybillService;
private IDriverService driverService;
@@ -81,7 +85,6 @@ class DriverWaybillTabStatusTest {
service = new DriverWaybillServiceImpl(
waybillService,
driverService,
mock(IMpUserClient.class),
mock(IProcessConfigService.class),
mock(ITransportVehicleService.class),
mock(CertificationGate.class),
@@ -141,6 +144,27 @@ class DriverWaybillTabStatusTest {
assertEquals(1, counts.getAll());
}
@Test
@DisplayName("司机改号后三档计数不变,计数查询按 driver_id 收口")
void tabCountsAfterNumberChangeScopedByDriverId() {
DriverWaybillTabCountsVO counts = runAsDriver(service::tabCounts);
assertEquals(1, counts.getDoing(), "改号不影响本人运单的分档");
assertEquals(1, counts.getAll());
LambdaQueryWrapper<Waybill> wrapper = firstQueriedWrapper();
// 护栏:计数查询必须落在运单 driver_id 上;比对手机号的话,改号后司机的数字会清零
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("driver_id"), "计数查询必须按司机档案 ID 收口:" + sql);
assertFalse(sql.contains("driver_phone"), "计数查询不能再比较运单手机号快照:" + sql);
assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID),
"计数查询必须带上当前司机档案 ID:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getParamNameValuePairs().values().contains(SNAPSHOT_PHONE),
"快照号不参与计数:" + wrapper.getParamNameValuePairs());
assertFalse(wrapper.getParamNameValuePairs().values().contains(CURRENT_PHONE),
"档案手机号不参与计数:" + wrapper.getParamNameValuePairs());
}
// ------------------------------------------------------------ 夹具
private <T> T runAsDriver(Supplier<T> action) {
@@ -167,6 +191,15 @@ class DriverWaybillTabStatusTest {
return statuses;
}
@SuppressWarnings("unchecked")
private LambdaQueryWrapper<Waybill> firstQueriedWrapper() {
assertEquals(1, queried.size(), "一次列表查询");
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) queried.get(0);
// 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的
wrapper.getSqlSegment();
return wrapper;
}
@SuppressWarnings("unchecked")
private static Wrapper<Waybill> anyWrapper() {
return org.mockito.ArgumentMatchers.<Wrapper<Waybill>>any();
@@ -177,7 +210,8 @@ class DriverWaybillTabStatusTest {
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD202610080002");
waybill.setVehicleNo(PLATE);
waybill.setDriverPhone(PHONE);
waybill.setDriverId(DRIVER_ID);
waybill.setDriverPhone(SNAPSHOT_PHONE);
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
waybill.setIsDeleted(0);
return waybill;
@@ -185,10 +219,10 @@ class DriverWaybillTabStatusTest {
private Driver driver() {
Driver driver = new Driver();
driver.setId(9001L);
driver.setId(DRIVER_ID);
driver.setUserId(USER_ID);
driver.setDrivingVehicle(PLATE);
driver.setMobile(PHONE);
driver.setMobile(CURRENT_PHONE);
return driver;
}
@@ -19,6 +19,7 @@ import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import java.lang.reflect.Field;
import java.util.List;
@@ -81,6 +82,7 @@ class LoadingManageAdmissionTest {
inject("baseMapper", loadingManageMapper);
inject("waybillMapper", waybillMapper);
inject("contractManageService", contractManageService);
inject("driverService", mock(IDriverService.class));
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
}
@@ -292,6 +294,33 @@ class LoadingManageAdmissionTest {
assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage());
}
@Test
@DisplayName("子运单的客户合同快照为空时不按甲方过滤,取不到甲方也不空指针")
void missingCustomerContractDoesNotFilterByPartyA() {
Waybill withSnapshot = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A);
withSnapshot.setContractId(OTHER_CONTRACT_ID);
stubWaybills(withSnapshot, waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_A));
stubContract(CONTRACT_ID, PROJECT_A);
assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L, 2L))));
}
@Test
@DisplayName("项目下没有可用承运商合同时不按乙方交集拦截:自运的多张子运单照常提交")
void noAvailableCarrierContractSkipsPartyBCheck() {
stubWaybills(
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_A));
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of());
LoadingManage payload = submitPayload(List.of(1L, 2L));
payload.setCarrierType("自运");
payload.setCarrierContractId(null);
payload.setDriverName("张三");
payload.setDriverPhone("13800008000");
assertDoesNotThrow(() -> service.submit(payload));
}
// ------------------------------------------------------------ 放行
@Test
@@ -1,5 +1,7 @@
package org.springblade.transport.service.impl;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -15,16 +17,20 @@ import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.transport.mapper.LoadingManageMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.support.LogCapture;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.regex.Matcher;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -32,9 +38,11 @@ import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
@@ -60,6 +68,12 @@ class LoadingManageChildStatusWriteTest {
private static final String LOADING_NO = "PZ-20261018-001";
private static final Long LOADING_ID = 1800000000000000001L;
private static final String DRIVER_PHONE = "13800138000";
private static final String TENANT_ID = "000000";
private static final Long RESOLVED_DRIVER_ID = 9321L;
private static final Long MANUAL_DRIVER_ID = 7001L;
private static final Long OTHER_PROFILE_ID = 8888L;
private static final String OTHER_DRIVER_PHONE = "13900139000";
private static final Long SOURCE_LOADING_ID = 1800000000000000002L;
private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L;
private static final Long NO_ACCEPT_WAYBILL_ID = 3102L;
@@ -80,6 +94,7 @@ class LoadingManageChildStatusWriteTest {
"carrier_type",
"carrier_contract_id",
"carrier_name",
"driver_id",
"driver_name",
"driver_phone",
"vehicle_no",
@@ -104,6 +119,8 @@ class LoadingManageChildStatusWriteTest {
private IProcessConfigService processConfigService;
private IContractManageService contractManageService;
private IReceivablePayableDetailService receivablePayableDetailService;
private IDriverService driverService;
private LoadingManageMapper loadingManageMapper;
private LoadingManageServiceImpl service;
private MockedStatic<AuthUtil> authUtil;
@@ -115,6 +132,7 @@ class LoadingManageChildStatusWriteTest {
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
}
@BeforeEach
@@ -123,14 +141,19 @@ class LoadingManageChildStatusWriteTest {
processConfigService = mock(IProcessConfigService.class);
contractManageService = mock(IContractManageService.class);
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
driverService = mock(IDriverService.class);
loadingManageMapper = mock(LoadingManageMapper.class);
service = spy(new LoadingManageServiceImpl());
inject(service, "waybillMapper", waybillMapper);
inject(service, "processConfigService", processConfigService);
inject(service, "contractManageService", contractManageService);
inject(service, "receivablePayableDetailService", receivablePayableDetailService);
inject(service, "driverService", driverService);
inject(service, "baseMapper", loadingManageMapper);
doReturnTrue(service);
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID);
}
@AfterEach
@@ -309,6 +332,179 @@ class LoadingManageChildStatusWriteTest {
verify(processConfigService, never()).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
}
@Test
@DisplayName("提交配载单:按手机号解析到档案 ID 时,配载单与子单一起记住归属")
void submitWritesResolvedDriverId() {
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID);
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
assertEquals(RESOLVED_DRIVER_ID, capturedLoading().getDriverId());
assertEquals(RESOLVED_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id"),
"子单必须与配载单写同一个归属,否则司机端按归属查不到这张票");
}
@Test
@DisplayName("解析不到档案(承运商司机):提交照常成功,归属留空")
void submitAllowsUnresolvedDriverId() {
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null);
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
assertNull(capturedLoading().getDriverId());
assertNull(statusSyncUpdates().get(0).setValue("driver_id"));
}
@Test
@DisplayName("进行中编辑保留的子单:任务字段更新也带上司机归属,改派后不留旧值")
void runningEditCarriesDriverIdToKeptWaybills() {
LoadingManage record = runningRecord();
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID);
assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID)));
CapturedUpdate taskUpdate = waybillUpdates().stream()
.filter(update -> update.columns().contains("driver_name"))
.findFirst()
.orElseThrow();
assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), taskUpdate.whereIds());
assertEquals(RESOLVED_DRIVER_ID, taskUpdate.setValue("driver_id"));
}
@Test
@DisplayName("复制配载单:沿用源单的司机归属,不按手机号重解析覆盖")
void copyKeepsSourceDriverId() {
LoadingManage source = copySource();
source.setDriverId(555L);
stubCopyDependencies(source);
service.copy(SOURCE_LOADING_ID);
assertEquals(555L, capturedSaved().getDriverId());
}
@Test
@DisplayName("复制配载单:源单没有归属时,用手机号解析出的档案 ID")
void copyFallsBackToResolvedDriverId() {
LoadingManage source = copySource();
stubCopyDependencies(source);
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID);
service.copy(SOURCE_LOADING_ID);
assertEquals(RESOLVED_DRIVER_ID, capturedSaved().getDriverId());
}
@Test
@DisplayName("已归属的单子号码没变:无关字段的保存不重解析,归属原样保留")
void unrelatedEditKeepsStoredDriverId() {
LoadingManage record = runningRecord();
record.setDriverId(RESOLVED_DRIVER_ID);
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
// 这一次谁都解析不到:只要实现还按手机号重解析,归属就会被清空
when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(null);
assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID)));
assertEquals(RESOLVED_DRIVER_ID, capturedLoading().getDriverId());
assertEquals(RESOLVED_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id"));
verify(driverService, never()).resolveDriverIdByMobile(any(), any());
}
@Test
@DisplayName("人工补过归属的单子:号码恰好命中别人的档案,保存也不改归属")
void unrelatedEditKeepsManualOwnership() {
LoadingManage record = runningRecord();
record.setDriverId(MANUAL_DRIVER_ID);
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(OTHER_PROFILE_ID);
assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID)));
assertEquals(MANUAL_DRIVER_ID, capturedLoading().getDriverId(),
"人工归属不能被一次无关保存改到同号的另一个档案上");
assertEquals(MANUAL_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id"));
}
@Test
@DisplayName("-1 哨兵的归属:号码没变的保存原样保留,不抹成 NULL")
void sentinelDriverIdSurvivesUnrelatedEdit() {
LoadingManage record = runningRecord();
record.setDriverId(-1L);
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(null);
assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID)));
assertEquals(-1L, capturedLoading().getDriverId());
}
@Test
@DisplayName("换司机:号码变了就按新号码重新解析,归属跟着换")
void changingPhoneResolvesNewDriverId() {
LoadingManage record = pendingRecord();
record.setDriverId(RESOLVED_DRIVER_ID);
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
when(driverService.resolveDriverIdByMobile(any(), eq(OTHER_DRIVER_PHONE))).thenReturn(OTHER_PROFILE_ID);
LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID);
form.setDriverPhone(OTHER_DRIVER_PHONE);
assertTrue(service.submit(form));
assertEquals(OTHER_PROFILE_ID, capturedLoading().getDriverId());
assertEquals(OTHER_PROFILE_ID, statusSyncUpdates().get(0).setValue("driver_id"));
}
@Test
@DisplayName("号码改成一个解析不到的号:归属落空,子单同步落空")
void changingPhoneToUnresolvedNumberClearsDriverId() {
LoadingManage record = pendingRecord();
record.setDriverId(RESOLVED_DRIVER_ID);
stubLoadingRecord(record);
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
when(driverService.resolveDriverIdByMobile(any(), eq(OTHER_DRIVER_PHONE))).thenReturn(null);
LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID);
form.setDriverPhone(OTHER_DRIVER_PHONE);
assertTrue(service.submit(form));
assertNull(capturedLoading().getDriverId());
assertNull(statusSyncUpdates().get(0).setValue("driver_id"));
}
@Test
@DisplayName("解析不到档案:留一条脱敏 WARN,保存照常成功")
void unresolvedPhoneLogsMaskedWarning() {
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null);
try (LogCapture logs = LogCapture.open(LoadingManageServiceImpl.class)) {
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
String warning = logs.events().stream()
.filter(event -> event.getLevel() == Level.WARN)
.map(ILoggingEvent::getFormattedMessage)
.filter(message -> message.contains("司机档案"))
.findFirst()
.orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages()));
assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning);
assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning);
assertTrue(warning.contains(LOADING_NO), "WARN 要带业务标识(配载单号),实际:" + warning);
}
}
// ------------------------------------------------------------ 夹具
private LoadingManage loadingForm(Long... waybillIds) {
@@ -375,10 +571,54 @@ class LoadingManageChildStatusWriteTest {
return waybill;
}
private String capturedLoadingStatus() {
private LoadingManage runningRecord() {
LoadingManage record = loadingRecord("running", NEED_ACCEPT_WAYBILL_ID);
record.setVehicleNo("桂A12345");
record.setDriverName("张三");
record.setDriverPhone(DRIVER_PHONE);
return record;
}
private LoadingManage pendingRecord() {
LoadingManage record = loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID);
record.setVehicleNo("桂A12345");
record.setDriverName("张三");
record.setDriverPhone(DRIVER_PHONE);
return record;
}
private LoadingManage copySource() {
LoadingManage source = loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID);
source.setLoadingSubNos("YD-" + NEED_ACCEPT_WAYBILL_ID);
source.setTransportType("公路运输");
source.setVehicleNo("桂A12345");
source.setDriverName("张三");
source.setDriverPhone(DRIVER_PHONE);
source.setDepartureAddress("南宁");
source.setArrivalAddress("广州");
return source;
}
private void stubCopyDependencies(LoadingManage source) {
source.setId(SOURCE_LOADING_ID);
doReturn(source).when(service).getById(SOURCE_LOADING_ID);
doReturn(null).when(service).detail(any());
}
private LoadingManage capturedSaved() {
ArgumentCaptor<LoadingManage> captor = ArgumentCaptor.forClass(LoadingManage.class);
verify(service, atLeastOnce()).save(captor.capture());
return captor.getValue();
}
private LoadingManage capturedLoading() {
ArgumentCaptor<LoadingManage> captor = ArgumentCaptor.forClass(LoadingManage.class);
verify(service, atLeastOnce()).saveOrUpdate(captor.capture());
return captor.getValue().getBusinessStatus();
return captor.getValue();
}
private String capturedLoadingStatus() {
return capturedLoading().getBusinessStatus();
}
private void stubWaybills(Waybill... waybills) {
@@ -438,18 +678,28 @@ class LoadingManageChildStatusWriteTest {
}
private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) {
try {
Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("注入测试替身失败:" + fieldName, exception);
Class<?> type = LoadingManageServiceImpl.class;
while (type != null) {
try {
Field field = type.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("注入测试替身失败:" + fieldName, exception);
}
}
throw new IllegalStateException("注入测试替身失败:" + fieldName);
}
/** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */
private record CapturedUpdate(String sqlSet, String whereSql, Map<String, Object> params, List<String> columns) {
private static final java.util.regex.Pattern PARAM_REF =
java.util.regex.Pattern.compile("#\\{ew\\.paramNameValuePairs\\.([^}]+)}");
@SuppressWarnings("unchecked")
static CapturedUpdate of(Wrapper<Waybill> wrapper) {
LambdaUpdateWrapper<Waybill> update = (LambdaUpdateWrapper<Waybill>) wrapper;
@@ -478,8 +728,20 @@ class LoadingManageChildStatusWriteTest {
return null;
}
/** 只认 WHERE 段用到的参数:SET 段的 driver_id 也是 Long,混进来会当成运单 id */
List<Long> whereIds() {
return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList();
Set<String> setParamKeys = new java.util.HashSet<>();
Matcher matcher = PARAM_REF.matcher(sqlSet);
while (matcher.find()) {
setParamKeys.add(matcher.group(1));
}
List<Long> ids = new java.util.ArrayList<>();
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (entry.getValue() instanceof Long longValue && !setParamKeys.contains(entry.getKey())) {
ids.add(longValue);
}
}
return ids;
}
}
}
@@ -0,0 +1,138 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminHomeVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 调度端首页「异常处置」快捷入口的红点:只数待处理(pending)的异常。
* <p>
* 统计卡的「在途异常」仍按未了结(pending + processing)算,两处口径不同:红点是待办提醒,
* 处置中的异常已经在跟进,再算进红点会让它一直清不掉。
* <p>
* 这里走 {@code home()}:红点是聚合接口里的字段,只测计数方法证明不了它接在哪一处。
*/
class ManageAdminHomeBadgeTest {
private static final String DEPT_ID = "1123598813738675201";
private static final Long USER_ID = 1900000000000000001L;
private static final long PENDING_COUNT = 3L;
private static final long INCOMPLETE_COUNT = 7L;
private ManageWaybillServiceImpl service;
private IExceptionDisposalService exceptionService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
}
@BeforeEach
void setUp() {
IWaybillService waybillService = mock(IWaybillService.class);
exceptionService = mock(IExceptionDisposalService.class);
ITripQueryService tripQueryService = mock(ITripQueryService.class);
when(tripQueryService.tabCounts(ArgumentMatchers.any())).thenReturn(new TripTabCountsVO());
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
// 待重新派单的分页查询回它收到的 Page(总数从入参上读,回 null 会空指针)
when(waybillService.page(ArgumentMatchers.any(), ArgumentMatchers.any()))
.thenAnswer(invocation -> invocation.getArgument(0));
// 处置状态的计数被查两次:带 processing 的是「未了结」,只带 pending 的是「待处理」
when(exceptionService.count(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenAnswer(invocation -> coversProcessing(invocation.getArgument(0)) ? INCOMPLETE_COUNT : PENDING_COUNT);
service = new ManageWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
exceptionService,
mock(IRiskDisposalService.class),
mock(IDriverWaybillService.class),
tripQueryService,
mock(ILoadingManageService.class),
mock(org.springblade.transport.service.ITransportVehicleService.class),
mock(MpUserDataScopeSupport.class));
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getUserRole).thenReturn("yewu");
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("红点只数待处理,统计卡的在途异常仍是未了结数")
void exceptionBadgeCountsPendingOnly() {
AdminHomeVO home;
try (MockedStatic<UserDisplayCache> ignored = mockStatic(UserDisplayCache.class)) {
home = service.home();
}
assertEquals(PENDING_COUNT, home.getBadges().getException(), "处置中的异常不再计入红点");
assertEquals(INCOMPLETE_COUNT, home.getStats().getException(), "统计卡口径不变:pending + processing");
ArgumentCaptor<Wrapper<ExceptionDisposal>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(exceptionService, times(2)).count(captor.capture());
List<String> segments = captor.getAllValues().stream()
.map(ManageAdminHomeBadgeTest::sqlSegment)
.collect(Collectors.toList());
assertTrue(segments.stream().anyMatch(segment -> segment.contains("disposal_status IN")),
"统计卡仍要数未了结:处置状态取 pending 与 processing 两档:" + segments);
assertTrue(segments.stream().anyMatch(segment -> segment.contains("disposal_status = ")),
"红点只认待处理:处置状态是等值条件,不再带 processing:" + segments);
}
/** 计数条件里是否含处置中:带 IN 的才是「pending + processing」那次查询 */
private static boolean coversProcessing(Wrapper<ExceptionDisposal> wrapper) {
return sqlSegment(wrapper).contains("disposal_status IN");
}
private static String sqlSegment(Wrapper<ExceptionDisposal> wrapper) {
return ((AbstractWrapper<?, ?, ?>) wrapper).getSqlSegment();
}
}
@@ -85,6 +85,7 @@ class ManageAdminHomeFeedTest {
mock(IDriverWaybillService.class),
mock(ITripQueryService.class),
mock(ILoadingManageService.class),
mock(org.springblade.transport.service.ITransportVehicleService.class),
mock(org.springblade.transport.support.MpUserDataScopeSupport.class));
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
@@ -102,6 +102,7 @@ class ManageTripListDetailTest {
driverWaybillService,
tripQueryService,
loadingManageService,
mock(org.springblade.transport.service.ITransportVehicleService.class),
mock(org.springblade.transport.support.MpUserDataScopeSupport.class));
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
@@ -66,6 +66,7 @@ class ManageWaybillBusinessStatusTest {
mock(IDriverWaybillService.class),
mock(ITripQueryService.class),
mock(ILoadingManageService.class),
mock(org.springblade.transport.service.ITransportVehicleService.class),
mock(org.springblade.transport.support.MpUserDataScopeSupport.class));
when(exceptionDisposalService.list(anyWrapper())).thenReturn(new ArrayList<>());
}
@@ -91,6 +91,7 @@ class ManageWaybillOrgScopeTest {
mock(IDriverWaybillService.class),
tripQueryService,
mock(ILoadingManageService.class),
mock(org.springblade.transport.service.ITransportVehicleService.class),
mpUserDataScopeSupport);
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
@@ -0,0 +1,143 @@
package org.springblade.transport.service.impl;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springblade.transport.pojo.entity.MasterOrder;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LogCapture;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 多联总单派单建运单时写司机档案 ID 的测试。
* <p>
* 派单请求可能来自调度(带档案 ID),也可能只填了姓名手机号(手工派单、承运商司机),
* 后者按手机号解析;解析不到也不能挡住派单。
*/
class MasterOrderDriverIdTest {
private static final String DRIVER_PHONE = "13800138000";
private static final Long RESOLVED_DRIVER_ID = 9321L;
private IWaybillService waybillService;
private IDriverService driverService;
private MasterOrderServiceImpl service;
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
driverService = mock(IDriverService.class);
service = new MasterOrderServiceImpl(waybillService, mock(ITransportPlanService.class),
mock(IProjectApplyService.class), mock(IContractManageService.class), driverService);
when(waybillService.submit(any(Waybill.class))).thenReturn(true);
}
@Test
@DisplayName("调度带了档案 ID:按它写,不再按手机号解析")
void keepsExplicitDriverId() throws Exception {
Map<String, Object> dispatch = dispatch();
dispatch.put("driverId", 7001L);
assertTrue(createWaybill(dispatch));
assertEquals(7001L, submittedWaybill().getDriverId());
verify(driverService, never()).resolveDriverIdByMobile(any(), any());
}
@Test
@DisplayName("只填了手机号:派单时解析出档案 ID 写入运单")
void resolvesDriverIdFromPhone() throws Exception {
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID);
assertTrue(createWaybill(dispatch()));
assertEquals(RESOLVED_DRIVER_ID, submittedWaybill().getDriverId());
}
@Test
@DisplayName("解析不到档案(承运商司机):运单照常提交,归属留空")
void allowsDispatchWithoutProfile() throws Exception {
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null);
assertTrue(createWaybill(dispatch()));
assertNull(submittedWaybill().getDriverId());
verify(waybillService).submit(any(Waybill.class));
}
@Test
@DisplayName("解析不到档案:落空时留一条脱敏 WARN,派单照常成功")
void unresolvedProfileLogsMaskedWarning() throws Exception {
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null);
try (LogCapture logs = LogCapture.open(MasterOrderServiceImpl.class)) {
assertTrue(createWaybill(dispatch()));
String warning = logs.events().stream()
.filter(event -> event.getLevel() == Level.WARN)
.map(ILoggingEvent::getFormattedMessage)
.filter(message -> message.contains("司机档案"))
.findFirst()
.orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages()));
assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning);
assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning);
assertTrue(warning.contains("ZD-20261027-001"), "WARN 要带业务标识(总单号),实际:" + warning);
}
}
// ------------------------------------------------------------ 夹具
private boolean createWaybill(Map<String, Object> dispatch) throws Exception {
MasterOrder masterOrder = new MasterOrder();
masterOrder.setId(1L);
masterOrder.setMasterNo("ZD-20261027-001");
masterOrder.setProjectId(2101L);
masterOrder.setProjectName("测试项目");
Method method = MasterOrderServiceImpl.class
.getDeclaredMethod("createWaybill", MasterOrder.class, List.class);
method.setAccessible(true);
return (boolean) method.invoke(service, masterOrder, List.of(dispatch));
}
private Map<String, Object> dispatch() {
Map<String, Object> dispatch = new LinkedHashMap<>();
dispatch.put("documentType", "运单");
dispatch.put("transportType", "公路运输");
dispatch.put("carrierType", "自运");
dispatch.put("driverName", "张三");
dispatch.put("driverPhone", DRIVER_PHONE);
dispatch.put("vehicleNo", "桂A12345");
dispatch.put("quantity", 1);
dispatch.put("unitPrice", 100);
return dispatch;
}
private Waybill submittedWaybill() {
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
verify(waybillService).submit(captor.capture());
return captor.getValue();
}
}
@@ -14,6 +14,7 @@ import org.springblade.transport.pojo.entity.TransportPlan;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.MasterOrderVO;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillService;
@@ -67,7 +68,8 @@ class MasterOrderProgressTest {
waybillService,
transportPlanService,
mock(IProjectApplyService.class),
mock(IContractManageService.class));
mock(IContractManageService.class),
mock(IDriverService.class));
inject("baseMapper", masterOrderMapper);
when(transportPlanService.list(anyWrapper())).thenReturn(List.of());
@@ -108,7 +108,7 @@ class PersonCertRoundTripTest {
void submitThenApprove() {
// 1) 先走一遍完整流程,让该司机有一个生效值
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
certAuditService.audit(firstRecordId, true, null);
certAuditService.audit(firstRecordId, true, null, null);
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
"审核通过后应能查到生效值");
@@ -120,7 +120,7 @@ class PersonCertRoundTripTest {
"指针应仍指向原来那条已通过记录");
// 3) 审核通过:生效值才变成新值
certAuditService.audit(secondRecordId, true, null);
certAuditService.audit(secondRecordId, true, null, null);
assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(),
"审核通过后生效值应切到新记录");
assertEquals(secondRecordId, driver.getEffectiveCertRecordId());
@@ -130,10 +130,10 @@ class PersonCertRoundTripTest {
@DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)")
void rejectKeepsEffectiveValue() {
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
certAuditService.audit(firstRecordId, true, null);
certAuditService.audit(firstRecordId, true, null, null);
Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
certAuditService.audit(secondRecordId, false, "证件照片模糊");
certAuditService.audit(secondRecordId, false, "证件照片模糊", null);
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
"驳回只终结该次提交,既得认证必须保持有效");
@@ -149,7 +149,7 @@ class PersonCertRoundTripTest {
assertNull(driverCertService.getEffectivePersonCert(),
"待审期间不应有生效值");
certAuditService.audit(recordId, true, null);
certAuditService.audit(recordId, true, null, null);
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName());
}
@@ -171,18 +171,18 @@ class PersonCertRoundTripTest {
// 被作废的那条不能再被审核(否则会把已作废的记录变成生效值)
org.junit.jupiter.api.Assertions.assertThrows(
org.springblade.core.log.exception.ServiceException.class,
() -> certAuditService.audit(first, true, null));
() -> certAuditService.audit(first, true, null, null));
}
@Test
@DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)")
void approvedRecordCannotBeReaudited() {
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
certAuditService.audit(recordId, true, null);
certAuditService.audit(recordId, true, null, null);
org.junit.jupiter.api.Assertions.assertThrows(
org.springblade.core.log.exception.ServiceException.class,
() -> certAuditService.audit(recordId, false, "事后反悔"));
() -> certAuditService.audit(recordId, false, "事后反悔", null));
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
"重复审核被拒后,生效值不受影响");
}
@@ -314,7 +314,7 @@ class PersonCertRoundTripTest {
request.setQualificationBack("http://example.com/q-back.jpg");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, true, null);
certAuditService.audit(recordId, true, null, null);
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
@@ -342,7 +342,7 @@ class PersonCertRoundTripTest {
request.setAddressRegion(" ");
request.setAddress("");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, true, null);
certAuditService.audit(recordId, true, null, null);
// 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉
assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(),
@@ -358,7 +358,7 @@ class PersonCertRoundTripTest {
driver.setIdCardNo("110101199001011234");
Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
certAuditService.audit(recordId, true, null);
certAuditService.audit(recordId, true, null, null);
// 审核通过即认可这次申报:档案里必须也是申报的姓名,
// 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字
@@ -377,7 +377,7 @@ class PersonCertRoundTripTest {
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999"));
ServiceException exception = assertThrows(ServiceException.class,
() -> certAuditService.audit(recordId, true, null));
() -> certAuditService.audit(recordId, true, null, null));
assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage());
// 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审);
// 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来
@@ -392,25 +392,27 @@ class PersonCertRoundTripTest {
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
request.setHeadPhoto("http://example.com/new-head.jpg");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, false, "证件照片模糊");
certAuditService.audit(recordId, false, "证件照片模糊", null);
assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(),
"驳回只终结该次提交,档案内容不得被改动");
}
@Test
@DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过")
void legacySnapshotSkipsWriteBackWithoutFailingAudit() {
// 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
// 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败
@DisplayName("历史快照:读不懂就拒绝通过并回滚(不再静默放行)")
void legacySnapshotBlocksAuditInsteadOfPassing() {
// 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null。
// 「通过」的语义是「把这批内容认定为生效值」,内容读不懂就无从认定,
// 必须拒绝——静默放行等于给一条空记录盖上已通过的章。
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
TransportCertRecord record = recordStore.get(recordId);
record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}");
certAuditService.audit(recordId, true, null);
ServiceException exception = assertThrows(ServiceException.class,
() -> certAuditService.audit(recordId, true, null, null));
assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(),
"快照读不懂不该阻断审核");
assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换");
assertTrue(exception.getMessage().contains("不匹配"), exception.getMessage());
// 内存版记录表没有事务,这里只验最关键的一条:生效指针不能被切过去
assertNull(driver.getEffectiveCertRecordId(), "审核失败不得切换生效指针");
}
}
@@ -50,7 +50,10 @@ import static org.mockito.Mockito.when;
*/
class TripQueryServiceImplTest {
/** 派单时的司机手机号快照:只用于展示,归属判据是下面的档案 ID */
private static final String PHONE = "17587421208";
private static final Long DRIVER_ID = 9001L;
private static final Long OTHER_DRIVER_ID = 9002L;
private static final String LOADING_NO = "PZ-20261017-001";
private static final Long LOADING_ID = 2111350784070275123L;
private static final Long WAYBILL_ID = 2110690671695380513L;
@@ -85,7 +88,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里")
void rowsAreFetchedWithoutDatabaseGrouping() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setStatus(1);
query.setCurrent(1);
query.setSize(10);
@@ -98,9 +101,9 @@ class TripQueryServiceImplTest {
assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql);
assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql);
assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql);
assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql);
assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"),
"未配载段按运单司机收口,且容忍手机号带空格:" + sql);
assertTrue(sql.contains("lm.driver_id = ?"), "配载段按配载单的司机档案 ID 收口:" + sql);
assertTrue(sql.contains("driver_id = ?"), "未配载段按运单的司机档案 ID 收口:" + sql);
assertFalse(sql.contains("driver_phone"), "手机号不再是归属判据:" + sql);
assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql);
assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql);
}
@@ -108,7 +111,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来")
void onlyNeededColumnsAreSelected() {
service.page(TripQueryVO.forDriver(PHONE));
service.page(TripQueryVO.forDriver(DRIVER_ID));
String select = String.valueOf(capturedRowQuery().getSqlSelect());
assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select);
@@ -124,7 +127,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算")
void deptScopeIsAppliedOnRows() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L));
service.page(query);
@@ -134,7 +137,7 @@ class TripQueryServiceImplTest {
Set<Object> values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values());
assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L),
"组织值按参数下发:" + values);
assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效");
assertTrue(capturedRowSql().contains("driver_id"), "组织收口不顶替司机收口:两种收口同时生效");
}
@Test
@@ -152,7 +155,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理")
void deptScopeIsOptional() {
service.page(TripQueryVO.forDriver(PHONE));
service.page(TripQueryVO.forDriver(DRIVER_ID));
assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql());
}
@@ -165,7 +168,7 @@ class TripQueryServiceImplTest {
waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO));
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
TripQueryVO doing = TripQueryVO.forDriver(DRIVER_ID);
doing.setStatus(1);
IPage<TripVO> doingPage = service.page(doing);
@@ -175,11 +178,11 @@ class TripQueryServiceImplTest {
assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())),
"每张卡的 status 都要等于这一档");
TripQueryVO done = TripQueryVO.forDriver(PHONE);
TripQueryVO done = TripQueryVO.forDriver(DRIVER_ID);
done.setStatus(2);
assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList());
TripQueryVO all = TripQueryVO.forDriver(PHONE);
TripQueryVO all = TripQueryVO.forDriver(DRIVER_ID);
all.setStatus(null);
assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)");
}
@@ -192,12 +195,12 @@ class TripQueryServiceImplTest {
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO));
TripQueryVO pending = TripQueryVO.forDriver(PHONE);
TripQueryVO pending = TripQueryVO.forDriver(DRIVER_ID);
pending.setStatus(0);
assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(),
"库值 running 但要接单未接 → 落待接单档");
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
TripQueryVO doing = TripQueryVO.forDriver(DRIVER_ID);
doing.setStatus(1);
assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(),
"库值 pending 但不需要接单 → 落进行中档");
@@ -212,8 +215,8 @@ class TripQueryServiceImplTest {
List.of(
waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES),
waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
TripQueryVO query = TripQueryVO.forDriver(PHONE);
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setKeyword("PZ-20261017");
TripVO trip = service.page(query).getRecords().get(0);
@@ -232,7 +235,7 @@ class TripQueryServiceImplTest {
void unloadedWaybillIsItsOwnTrip() {
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(DRIVER_ID));
assertEquals(1, page.getTotal());
assertEquals(1, page.getRecords().size());
@@ -256,7 +259,7 @@ class TripQueryServiceImplTest {
row.setMasterNo("DL-20261001-0001");
stubRows(row);
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(Boolean.TRUE, trip.getMultimodal(), "挂总单即含多式联运");
assertEquals("common", trip.getTransportOrgType(), "运输类型是组织形态维,与多式联运各算一个值");
@@ -270,9 +273,9 @@ class TripQueryServiceImplTest {
stubRows(
onMasterOrder,
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals("load", trip.getTransportOrgType());
assertEquals(Boolean.TRUE, trip.getMultimodal(), "两个事实叠加,不做互斥单选");
@@ -283,7 +286,7 @@ class TripQueryServiceImplTest {
void unloadedTripStateComesFromFetchedWaybill() {
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(Boolean.TRUE, trip.getRequireAccept());
assertEquals("pending", trip.getAcceptStatus());
@@ -300,7 +303,7 @@ class TripQueryServiceImplTest {
row.setEstimatedEndTime(LocalDate.of(2026, 8, 24));
stubRows(row);
TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO listed = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补");
assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate());
@@ -319,7 +322,7 @@ class TripQueryServiceImplTest {
void loadingMissingIsDropped() {
stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(DRIVER_ID));
assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车");
assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在");
@@ -335,9 +338,9 @@ class TripQueryServiceImplTest {
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO),
waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(String.valueOf(LOADING_ID), trip.getId());
assertEquals(LOADING_NO, trip.getLoadingNo());
@@ -358,9 +361,9 @@ class TripQueryServiceImplTest {
stubRows(
waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单");
assertEquals("pending", trip.getAcceptStatus());
@@ -372,8 +375,8 @@ class TripQueryServiceImplTest {
void repeatedReadsDoNotDriftAndNeverWrite() {
stubOneLoadedTrip();
TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
TripVO first = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
TripVO second = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0);
assertEquals(first.getStatus(), second.getStatus());
assertEquals(first.getAcceptStatus(), second.getAcceptStatus());
@@ -412,19 +415,19 @@ class TripQueryServiceImplTest {
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
TripQueryVO firstPage = TripQueryVO.forDriver(PHONE);
TripQueryVO firstPage = TripQueryVO.forDriver(DRIVER_ID);
firstPage.setCurrent(1);
firstPage.setSize(2);
assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数");
assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList());
TripQueryVO secondPage = TripQueryVO.forDriver(PHONE);
TripQueryVO secondPage = TripQueryVO.forDriver(DRIVER_ID);
secondPage.setCurrent(2);
secondPage.setSize(2);
assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(),
"第 2 页接着第 1 页,不重复不遗漏");
TripQueryVO bySeven = TripQueryVO.forDriver(PHONE);
TripQueryVO bySeven = TripQueryVO.forDriver(DRIVER_ID);
bySeven.setCurrent(1);
bySeven.setSize(7);
assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变");
@@ -440,7 +443,7 @@ class TripQueryServiceImplTest {
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO));
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE));
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(DRIVER_ID));
assertEquals(1, counts.getPending());
assertEquals(2, counts.getDoing());
@@ -476,7 +479,7 @@ class TripQueryServiceImplTest {
stubRows(
waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO),
waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripQueryVO query = TripQueryVO.forAdmin();
query.setException("exception");
query.setExceptionWaybillIds(java.util.Set.of(2L));
@@ -572,7 +575,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("详情与列表同口径:子单快照没有接单节点、项目生效配置要求接单时,详情也算待接单")
void detailUsesLiveProcessConfig() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID));
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
at(2000L), ACCEPT_NO);
child.setProjectId(7001L);
@@ -590,7 +593,7 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("详情仍是只读取数:生效配置只写回内存里的行,不落库")
void detailStaysReadOnly() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID));
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
at(2000L), ACCEPT_NO);
child.setProjectId(7001L);
@@ -611,7 +614,7 @@ class TripQueryServiceImplTest {
when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null);
when(waybillService.getById(WAYBILL_ID))
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID));
stubChildren();
TripVO lone = service.detail(tripQuery(String.valueOf(WAYBILL_ID)));
@@ -627,13 +630,13 @@ class TripQueryServiceImplTest {
@Test
@DisplayName("详情的收口与列表一致:别人的运次、子单全被拒单的配载运次都不可达")
void detailRefusesForeignTrips() {
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading("13800000001"));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(OTHER_DRIVER_ID));
when(waybillService.getById(WAYBILL_ID))
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID));
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
@@ -647,14 +650,119 @@ class TripQueryServiceImplTest {
void rejectedChildrenLeaveTheTrip() {
// 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在
stubRows();
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(DRIVER_ID));
assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次");
assertEquals(0, page.getTotal());
}
// ------------------------------------------------------------ 收口判据:司机档案 ID
@Test
@DisplayName("司机改号后运次仍可见:判据是档案 ID,派单快照号不再参与")
void driverScopeSurvivesPhoneChange() {
Waybill lone = waybill(7001L, "YD-LONE-0001", null, "running", null, at(1000L), ACCEPT_YES);
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
lone);
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID));
when(waybillService.getById(7001L)).thenReturn(lone);
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setCurrent(1);
query.setSize(10);
IPage<TripVO> page = service.page(query);
assertEquals(2, page.getTotal(), "档案换号不影响归属:已配载与未配载两段都要按档案 ID 命中");
assertEquals(Set.of(LOADING_NO, "YD-LONE-0001"),
page.getRecords().stream().map(TripVO::getTripNo).collect(java.util.stream.Collectors.toSet()));
assertTrue(page.getRecords().stream().allMatch(trip -> PHONE.equals(trip.getDriverPhone())),
"响应里的司机手机号仍是派单快照,改号后不变");
assertEquals(LOADING_NO, service.detail(tripQuery(String.valueOf(LOADING_ID))).getTripNo());
assertEquals(PHONE, service.detail(tripQuery(String.valueOf(LOADING_ID))).getDriverPhone(),
"已配载运次详情仍回派单快照号");
assertEquals(PHONE, service.detail(tripQuery(String.valueOf(7001L))).getDriverPhone(),
"未配载运次详情仍回派单快照号");
}
@Test
@DisplayName("同号换人不放行:手机号相同、档案 ID 不同的运次仍然看不到")
void samePhoneDoesNotGrantAccess() {
// 派单快照号一模一样,档案 ID 却是别人
Waybill foreignLone = waybill(7002L, "YD-LONE-0002", null, "running", null, at(1000L), ACCEPT_YES);
foreignLone.setDriverId(OTHER_DRIVER_ID);
when(waybillService.getById(7002L)).thenReturn(foreignLone);
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(7002L))),
"快照号相同不代表同一个司机");
// 已配载段同样只认配载单上的档案 ID
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(OTHER_DRIVER_ID));
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))),
"配载单归别人时详情同样拒绝");
}
@Test
@DisplayName("两段各按自己的 driver_id 判:已配载看配载单,未配载看运单")
void driverScopeReadsDriverIdOnBothSegments() {
Waybill loadedRow = waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null,
at(2000L), ACCEPT_YES);
// 运单行归别人、配载单归本人:已配载段只认配载单
loadedRow.setDriverId(OTHER_DRIVER_ID);
stubRows(loadedRow);
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setCurrent(1);
query.setSize(10);
assertEquals(1, service.page(query).getTotal(), "已配载段按配载单的 driver_id 收口,不看运单行的");
Waybill lone = waybill(7003L, "YD-LONE-0003", null, "running", null, at(1000L), ACCEPT_YES);
// 运单归本人、手机号却完全对不上:未配载段只认运单的 driver_id
lone.setDriverPhone("13800000003");
stubRows(lone);
IPage<TripVO> page = service.page(query);
assertEquals(1, page.getTotal(), "未配载段按运单的 driver_id 收口,不看手机号");
assertEquals("13800000003", page.getRecords().get(0).getDriverPhone(), "展示的仍是运单上的派单快照号");
}
@Test
@DisplayName("管理端不按司机收口:带上任何 driverId 都照样全出")
void adminScopeIgnoresDriverId() {
stubRows(
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
for (Long driverId : java.util.Arrays.asList(null, 0L, -1L, OTHER_DRIVER_ID)) {
TripQueryVO admin = TripQueryVO.forAdmin();
admin.setDriverId(driverId);
assertEquals(2, service.page(admin).getTotal(), "管理端收口与司机无关:" + driverId);
}
}
@Test
@DisplayName("收口缺省(空、0、负数)一律空集:不放行也不抛异常")
void missingDriverIdIsRefused() {
stubRows(waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
for (Long driverId : java.util.Arrays.asList(null, 0L, -1L, -9001L)) {
TripQueryVO query = TripQueryVO.forDriver(driverId);
IPage<TripVO> page = service.page(query);
assertEquals(0, page.getTotal(), "收口缺失不能退回「不按司机收口」:" + driverId);
assertTrue(page.getRecords().isEmpty());
assertEquals(0, service.tabCounts(query).getAll(), "统计同样为零:" + driverId);
assertThrows(ServiceException.class, () -> service.detail(query), "详情直接拒绝:" + driverId);
}
verify(waybillService, never()).list(anyWaybillWrapper());
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")
@@ -712,7 +820,7 @@ class TripQueryServiceImplTest {
}
private List<TripVO> orgTypeTrips(String orgType) {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setTransportOrgType(orgType);
query.setCurrent(1);
query.setSize(50);
@@ -737,7 +845,7 @@ class TripQueryServiceImplTest {
stubRows(
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID)));
}
private void stubChildren() {
@@ -756,23 +864,25 @@ class TripQueryServiceImplTest {
}
private TripQueryVO tripQuery(String tripId) {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setTripId(tripId);
return query;
}
private LoadingManage loading(String loadingNo, Long loadingId) {
LoadingManage loading = loading(PHONE);
LoadingManage loading = loading(DRIVER_ID);
loading.setId(loadingId);
loading.setLoadingNo(loadingNo);
return loading;
}
private LoadingManage loading(String driverPhone) {
private LoadingManage loading(Long driverId) {
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setDriverPhone(driverPhone);
loading.setDriverId(driverId);
// 配载单上留的是派单时的手机号快照,档案改号后它不变
loading.setDriverPhone(PHONE);
loading.setDriverName("黄宇富");
loading.setVehicleNo("桂A32523");
loading.setProjectName("武宣建筑原料运输项目");
@@ -791,6 +901,8 @@ class TripQueryServiceImplTest {
waybill.setLoadingNo(loadingNo);
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setDriverId(DRIVER_ID);
// 运单上留的是派单时的手机号快照,档案改号后它不变
waybill.setDriverPhone(PHONE);
waybill.setProcessJson(processJson);
waybill.setCreateTime(createTime);
@@ -45,6 +45,7 @@ import static org.mockito.Mockito.when;
class TripReadServiceBoundaryTest {
private static final String PHONE = "17587421208";
private static final Long DRIVER_ID = 9001L;
private static final String LOADING_NO = "PZ-20261017-001";
/** 19 位雪花号,按数字下发会被双精度取整 */
private static final Long LOADING_ID = 2111350784070275074L;
@@ -100,7 +101,7 @@ class TripReadServiceBoundaryTest {
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
// 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setStatus(3);
TripVO trip = onlyTrip(query);
@@ -205,7 +206,7 @@ class TripReadServiceBoundaryTest {
void emptyChildrenDoNotThrow() {
stubLoadedTrip();
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
IPage<TripVO> page = service.page(TripQueryVO.forDriver(DRIVER_ID));
assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次");
@@ -239,6 +240,8 @@ class TripReadServiceBoundaryTest {
bare.setBusinessStatus("pending");
bare.setDriverAcceptStatus("pending");
bare.setProcessJson(ACCEPT_YES);
bare.setDriverId(DRIVER_ID);
// 运单上留的是派单时的手机号快照,档案改号后它不变
bare.setDriverPhone(PHONE);
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare));
@@ -368,7 +371,7 @@ class TripReadServiceBoundaryTest {
}
private TripVO onlyTrip() {
return onlyTrip(TripQueryVO.forDriver(PHONE));
return onlyTrip(TripQueryVO.forDriver(DRIVER_ID));
}
private TripVO onlyTrip(TripQueryVO query) {
@@ -378,7 +381,7 @@ class TripReadServiceBoundaryTest {
}
private TripQueryVO detailQuery() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID);
query.setTripId(String.valueOf(LOADING_ID));
return query;
}
@@ -387,6 +390,8 @@ class TripReadServiceBoundaryTest {
LoadingManage loading = new LoadingManage();
loading.setId(LOADING_ID);
loading.setLoadingNo(LOADING_NO);
loading.setDriverId(DRIVER_ID);
// 配载单上留的是派单时的手机号快照
loading.setDriverPhone(PHONE);
loading.setDriverName("黄宇富");
loading.setVehicleNo("桂A32523");
@@ -401,6 +406,8 @@ class TripReadServiceBoundaryTest {
waybill.setLoadingNo(LOADING_NO);
waybill.setBusinessStatus(businessStatus);
waybill.setDriverAcceptStatus(acceptStatus);
waybill.setDriverId(DRIVER_ID);
// 运单上留的是派单时的手机号快照
waybill.setDriverPhone(PHONE);
waybill.setProcessJson(processJson);
waybill.setCreateTime(createTime);
@@ -0,0 +1,110 @@
package org.springblade.transport.service.impl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.ISysClient;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillService;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 批量导入运单时写司机档案 ID 的测试。
* <p>
* 导入模板可能带档案 ID(老系统导出),也可能只有司机姓名手机号。
* 解析不到档案时只留行级 WARN,该行照常导入(承运商司机本来就没有小程序档案)。
*/
class WaybillImportDriverIdTest {
private static final String DRIVER_PHONE = "13800138000";
private static final Long RESOLVED_DRIVER_ID = 9321L;
private IDriverService driverService;
private WaybillImportBatchServiceImpl service;
@BeforeEach
void setUp() {
driverService = mock(IDriverService.class);
service = new WaybillImportBatchServiceImpl(
mock(IWaybillService.class),
mock(ICustomerArchiveService.class),
mock(IProjectApplyService.class),
mock(ITransportPlanService.class),
mock(IReceivablePayableDetailService.class),
mock(ILoadingManageService.class),
mock(ISysClient.class),
driverService);
}
@Test
@DisplayName("行内带了档案 ID:直接用,不再按手机号解析")
void keepsDriverIdFromRow() throws Exception {
Waybill waybill = importedWaybill(7001L);
fillDriverOwnership(waybill, 3);
assertEquals(7001L, waybill.getDriverId());
verify(driverService, never()).resolveDriverIdByMobile(any(), any());
}
@Test
@DisplayName("行内只有手机号:按手机号解析出档案 ID 写入")
void resolvesDriverIdFromRowPhone() throws Exception {
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID);
Waybill waybill = importedWaybill(null);
fillDriverOwnership(waybill, 3);
assertEquals(RESOLVED_DRIVER_ID, waybill.getDriverId());
}
@Test
@DisplayName("解析不到档案:该行照常导入,归属留空(只留行级 WARN)")
void keepsRowWithoutResolvedProfile() throws Exception {
when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null);
Waybill waybill = importedWaybill(null);
assertDoesNotThrow(() -> fillDriverOwnership(waybill, 3));
assertNull(waybill.getDriverId());
}
// ------------------------------------------------------------ 夹具
private void fillDriverOwnership(Waybill waybill, int rowNo) throws Exception {
Method method = WaybillImportBatchServiceImpl.class.getDeclaredMethod("fillDriverOwnership", Waybill.class, int.class);
method.setAccessible(true);
try {
method.invoke(service, waybill, rowNo);
} catch (java.lang.reflect.InvocationTargetException exception) {
throw (Exception) exception.getCause();
}
}
private Waybill importedWaybill(Long driverId) {
Waybill waybill = new Waybill();
waybill.setWaybillNo("YD-IMPORT-1");
waybill.setDriverId(driverId);
waybill.setDriverName("张三");
waybill.setDriverPhone(DRIVER_PHONE);
return waybill;
}
}
@@ -0,0 +1,197 @@
package org.springblade.transport.service.impl;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.support.LogCapture;
import java.lang.reflect.Field;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 重新派单写司机档案 ID 的测试。
* <p>
* 派单是司机归属的唯一入口:调用方带了档案 ID(>0)就按它写,
* 只带姓名手机号(承运商司机、手工派单)就按手机号解析;解析不到也不能挡住派单。
*/
class WaybillReassignDriverIdTest {
private static final Long WAYBILL_ID = 1900000000000000001L;
private static final String TENANT_ID = "000000";
private static final String DRIVER_PHONE = "13800138000";
private static final Long RESOLVED_DRIVER_ID = 9321L;
private WaybillMapper waybillMapper;
private IDriverService driverService;
private WaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
driverService = mock(IDriverService.class);
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
inject("driverService", driverService);
inject("processConfigService", processConfigService());
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(pendingWaybill());
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
}
@Test
@DisplayName("调用方带了档案 ID:按它写,不再按手机号解析")
void keepsExplicitDriverId() {
Waybill request = reassignRequest(7721L);
assertTrue(service.reassignWithoutDeptCheck(request));
assertEquals(7721L, capturedWaybill().getDriverId());
verify(driverService, never()).resolveDriverIdByMobile(any(), anyString());
}
@Test
@DisplayName("调用方只带手机号:按手机号解析出档案 ID 写入")
void resolvesDriverIdFromPhone() {
when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(RESOLVED_DRIVER_ID);
assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null)));
assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId());
}
@Test
@DisplayName("档案 ID 为 0 或 -1(无主哨兵):按手机号重新解析")
void resolvesWhenDriverIdIsSentinel() throws Exception {
when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(RESOLVED_DRIVER_ID);
assertTrue(service.reassignWithoutDeptCheck(reassignRequest(0L)));
assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId());
setUpAgain();
assertTrue(service.reassignWithoutDeptCheck(reassignRequest(-1L)));
assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId());
}
@Test
@DisplayName("解析不到档案(承运商司机):派单照常成功,归属留空")
void allowsReassignWithoutResolvedProfile() {
when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(null);
assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null)));
assertNull(capturedWaybill().getDriverId());
}
@Test
@DisplayName("解析不到档案:落空时留一条脱敏 WARN,派单照常成功")
void unresolvedProfileLogsMaskedWarning() {
when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(null);
try (LogCapture logs = LogCapture.open(WaybillServiceImpl.class)) {
assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null)));
String warning = logs.events().stream()
.filter(event -> event.getLevel() == Level.WARN)
.map(ILoggingEvent::getFormattedMessage)
.filter(message -> message.contains("司机档案"))
.findFirst()
.orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages()));
assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning);
assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning);
assertTrue(warning.contains("YD-20261027-001"), "WARN 要带业务标识(运单号),实际:" + warning);
}
}
// ------------------------------------------------------------ 夹具
private void setUpAgain() throws Exception {
waybillMapper = mock(WaybillMapper.class);
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
inject("driverService", driverService);
inject("processConfigService", processConfigService());
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(pendingWaybill());
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
}
private IProcessConfigService processConfigService() {
IProcessConfigService processConfigService = mock(IProcessConfigService.class);
when(processConfigService.listByIds(any())).thenReturn(Collections.emptyList());
return processConfigService;
}
private Waybill pendingWaybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setIsDeleted(0);
waybill.setTenantId(TENANT_ID);
waybill.setWaybillNo("YD-20261027-001");
waybill.setBusinessStatus("pending");
waybill.setTransportType("road");
waybill.setDriverName("张三");
waybill.setDriverPhone("13900139000");
waybill.setVehicleNo("桂A12345");
return waybill;
}
private Waybill reassignRequest(Long driverId) {
Waybill request = new Waybill();
request.setId(WAYBILL_ID);
request.setDriverId(driverId);
request.setDriverName("李四");
request.setDriverPhone(DRIVER_PHONE);
request.setVehicleNo("桂A88888");
return request;
}
private Waybill capturedWaybill() {
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
verify(waybillMapper, org.mockito.Mockito.atLeastOnce()).updateById(captor.capture());
return captor.getValue();
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}
@@ -0,0 +1,289 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* 存量司机档案 ID 回填脚本的文本护栏。
* <p>
* 仓库里没有连库的测试基座,回填脚本的正确性只能靠「读脚本、断言不变量」来守。
* 这里守的是「改脚本时最容易写坏、写坏了又不会报错」的几条:
* <ul>
* <li>只填 {@code driver_id IS NULL} 的行 —— 手滑把条件放宽成 {@code <= 0} 就会覆盖
* {@code -1}(无主哨兵)和已经派给别人的单。</li>
* <li>{@code driver_id = -1} 必须有一道显式排除 —— 只靠 {@code IS NULL} 挡住是隐式的,
* 后人看不出「-1 不能动」是需求,容易在重构时丢掉。</li>
* <li>跨表比较必须显式 {@code COLLATE} —— 运单/配载单侧是 {@code utf8mb4_0900_ai_ci},
* 司机档案侧是 {@code utf8mb4_general_ci},漏了就在执行时抛
* {@code ERROR 1267 Illegal mix of collations},而这只有真跑库才会暴露。</li>
* <li>不许 {@code SET driver_phone} / {@code SET update_time} —— 手机号是派单时的号码快照,
* 回填是「补归属」不是「改快照」;{@code update_time} 一改,业务上就分不清这张单是不是
* 真的被人动过。</li>
* <li>唯一命中({@code COUNT(*) = 1})与可重复执行({@code IF NOT EXISTS} / {@code INSERT IGNORE}
* / {@code IS NULL} 兜底)—— 同号多档案时填空会把单挂错人,重复跑必须零变更。</li>
* </ul>
* 这些断言只在脚本文本层面成立,不证明数据结果正确;数据结果由脚本第 2 节校验负责。
*/
class DriverIdBackfillSqlTest {
/**
* 每个用例跑两张表:运单一份、配载单一份,规则完全相同。
*/
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("只更新 driver_id 为 NULL 的行")
void onlyFillsNullDriverId(String fileName, String table) {
List<String> updates = updateStatements(script(fileName));
assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句:回填脚本必须真的写 driver_id");
for (String update : updates) {
String where = whereClause(update).toUpperCase();
assertTrue(where.contains("DRIVER_ID IS NULL"),
table + " 的 UPDATE 缺少 driver_id IS NULL:"
+ "条件一放宽就会覆盖 -1(无主哨兵)与已派单的行。原文:" + squeeze(update));
assertTrue(setClause(update).toUpperCase().contains("DRIVER_ID"),
table + " 的 UPDATE 没有写 driver_id:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("driver_id = -1 的哨兵行有一道显式排除")
void keepsSentinelRowsUntouched(String fileName, String table) {
List<String> updates = updateStatements(script(fileName));
assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句");
for (String update : updates) {
String where = whereClause(update);
assertTrue(where.contains("-1"),
table + " 的 UPDATE 没有出现 -1 保护:哨兵行会被回填覆盖。原文:" + squeeze(update));
assertTrue(where.toUpperCase().contains("IS NOT TRUE") || where.toUpperCase().contains("NOT ("),
table + " 的 UPDATE 里 -1 只是一笔带过(例如 driver_id <> -1),"
+ "对 NULL 行的比较结果是 NULL、条件不成立,要填的行会被整批漏掉。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("跨表比较手机号与租户时显式 COLLATE")
void comparesAcrossTablesWithExplicitCollate(String fileName, String table) {
for (String line : body(fileName).split("\\R")) {
String trimmed = line.trim();
if (trimmed.isEmpty() || !trimmed.contains("=")) {
continue;
}
boolean tenantComparison = countOf(trimmed, ".tenant_id") >= 2;
boolean mobileComparison = trimmed.contains(".mobile") && trimmed.contains("driver_phone");
if (!tenantComparison && !mobileComparison) {
continue;
}
assertTrue(trimmed.toUpperCase().contains("COLLATE"),
table + " 的跨表比较没有显式 COLLATE(会抛 ERROR 1267 Illegal mix of collations):" + trimmed);
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("不改写 driver_phone 快照")
void neverRewritesPhoneSnapshot(String fileName, String table) {
for (String update : updateStatements(script(fileName))) {
assertFalse(setClause(update).toUpperCase().contains("DRIVER_PHONE"),
table + " 的 UPDATE 改写了 driver_phone:手机号是派单时的号码快照,回填只补归属。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("不改写 update_time")
void neverRewritesUpdateTime(String fileName, String table) {
for (String update : updateStatements(script(fileName))) {
assertFalse(setClause(update).toUpperCase().contains("UPDATE_TIME"),
table + " 的 UPDATE 改写了 update_time:回填不是业务动作,改了就看不出单据是否真被人动过。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("同号多档案不填:命中映射要求唯一")
void requiresUniqueMobileMatch(String fileName, String table) {
String body = body(fileName);
assertTrue(Pattern.compile("(?i)HAVING\\s+COUNT\\(\\*\\)\\s*=\\s*1").matcher(body).find(),
table + " 的回填映射没有 HAVING COUNT(*) = 1:同号多档案时会把单挂到其中一条档案上");
assertTrue(Pattern.compile("(?i)COUNT\\(DISTINCT\\s+d\\.id\\)\\s*>\\s*1").matcher(body).find(),
table + " 没有同号多档案的冲突清单:跳过多少、为什么跳过必须可复核");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("可重复执行:存档表与命中映射都带重复执行保护")
void isRerunnable(String fileName, String table) {
String body = body(fileName);
assertTrue(Pattern.compile("(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS").matcher(body).find(),
table + " 的存档表没有 IF NOT EXISTS:第二次执行会报 1050 Table already exists,跑不完");
assertTrue(Pattern.compile("(?i)INSERT\\s+IGNORE").matcher(body).find(),
table + " 的命中映射没有 INSERT IGNORE:第二次执行会撞主键,跑不完");
String insert = statement(body, "INSERT IGNORE");
assertFalse(insert.isEmpty(), table + " 的 INSERT IGNORE 不是一条完整语句");
assertTrue(insert.toUpperCase().contains("DRIVER_ID IS NULL"),
table + " 的命中映射没有 driver_id IS NULL 兜底:重复执行会给已经填过的行反复建映射");
assertTrue(body.contains("ROW_COUNT()"),
table + " 的分批循环没有 ROW_COUNT() 退出条件:会死循环或只跑第一批");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("分批执行:循环里带 LIMIT 的批次大小")
void batchesInBoundedChunks(String fileName, String table) {
String body = body(fileName);
assertTrue(body.contains("REPEAT") || body.contains("WHILE"),
table + " 的回填没有分批循环:一次性 UPDATE 全表会长时间锁行");
assertTrue(Pattern.compile("(?i)LIMIT\\s+(\\d+|v_\\w+)").matcher(body).find(),
table + " 的 LIMIT 后面不是批次大小的数字或变量:不是真正的分批");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("脚本按目录惯例分了 0 存档 / 1 订正 / 2 校验 / 3 回退")
void hasRequiredSections(String fileName, String table) {
String script = script(fileName);
assertTrue(script.contains("-- 0. 存档"), table + " 缺 -- 0. 存档 段");
assertTrue(script.contains("-- 1. 订正"), table + " 缺 -- 1. 订正 段");
assertTrue(script.contains("-- 2. 校验"), table + " 缺 -- 2. 校验 段");
assertTrue(script.contains("-- 3. 回退"), table + " 缺 -- 3. 回退 段");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("取数与写入全在 SQL 内:不经过 JSON 中转")
void keepsSnowflakeIdsInsideSql(String fileName, String table) {
String body = body(fileName).toUpperCase();
assertFalse(body.contains("JSON_"),
table + " 的脚本用了 JSON 函数中转:19 位雪花 ID 过 JSON 会丢精度,取数与写入必须在 SQL 内完成");
}
private static String script(String fileName) {
Path path = locate(fileName);
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new UncheckedIOException("读不了回填脚本:" + path, exception);
}
}
private static Path locate(String fileName) {
List<Path> candidates = List.of(
Path.of("..", "..", "doc", "sql", "transport", fileName),
Path.of("doc", "sql", "transport", fileName));
List<String> tried = new ArrayList<>();
for (Path candidate : candidates) {
if (Files.isRegularFile(candidate)) {
return candidate;
}
tried.add(candidate.toAbsolutePath().normalize().toString());
}
fail("找不到回填脚本 " + fileName + "。surefire 的工作目录是模块目录(blade-service/blade-transport),"
+ "脚本应在 ../../doc/sql/transport/ 下。已尝试:" + String.join(";", tried));
return null;
}
private static List<String> updateStatements(String script) {
List<String> updates = new ArrayList<>();
Matcher matcher = Pattern.compile("(?is)\\bUPDATE\\b.*?;").matcher(stripComments(script));
while (matcher.find()) {
updates.add(matcher.group());
}
return updates;
}
private static String statement(String body, String prefix) {
Matcher matcher = Pattern.compile("(?is)\\b" + Pattern.quote(prefix) + "\\b.*?;").matcher(body);
return matcher.find() ? matcher.group() : "";
}
private static String setClause(String update) {
String upper = update.toUpperCase();
int set = upper.indexOf(" SET ");
if (set < 0) {
fail("UPDATE 里没有 SET:" + squeeze(update));
}
int where = upper.indexOf(" WHERE ", set);
return where < 0 ? update.substring(set) : update.substring(set, where);
}
private static String whereClause(String update) {
int where = update.toUpperCase().indexOf(" WHERE ");
if (where < 0) {
fail("UPDATE 里没有 WHERE:" + squeeze(update));
}
return update.substring(where);
}
private static String body(String fileName) {
return stripComments(script(fileName));
}
private static String stripComments(String script) {
return script.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("--[^\\r\\n]*", " ");
}
private static int countOf(String text, String needle) {
int count = 0;
int index = text.indexOf(needle);
while (index >= 0) {
count++;
index = text.indexOf(needle, index + needle.length());
}
return count;
}
private static String squeeze(String sql) {
return sql.replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,48 @@
package org.springblade.transport.support;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.slf4j.LoggerFactory;
import java.util.List;
/**
* 抓取某个类的日志输出(logback ListAppender)。
* <p>
* 「解析失败时留一条脱敏 WARN」这类要求只看返回值验不出来,必须断言真实日志正文。
*/
public final class LogCapture implements AutoCloseable {
private final Logger logger;
private final ListAppender<ILoggingEvent> appender;
private LogCapture(Logger logger, ListAppender<ILoggingEvent> appender) {
this.logger = logger;
this.appender = appender;
}
public static LogCapture open(Class<?> loggerType) {
Logger logger = (Logger) LoggerFactory.getLogger(loggerType);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
return new LogCapture(logger, appender);
}
/** 已按占位符填充好的日志正文,按发生顺序 */
public List<String> messages() {
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
}
public List<ILoggingEvent> events() {
return List.copyOf(appender.list);
}
@Override
public void close() {
logger.detachAppender(appender);
appender.stop();
}
}
@@ -0,0 +1,86 @@
/**
* 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.wrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springblade.system.cache.UserCache;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
/**
* 认证记录视图对象的修订版标记与字段可见性
* <p>
* 两件事必须同时成立:司机端要能从 {@code revised} 看出「这条记录经过审核员修订」,
* 而又不能顺着 VO 把整份修订内容(含身份证号、证件照地址)再下发一遍。
*
* @author Chill
*/
class TransportCertRecordWrapperRevisionTest {
@Test
@DisplayName("修订版非空才算修订,空串与空白不算")
void marksRevisedOnlyWhenRevisionPresent() {
try (MockedStatic<UserDisplayCache> ignoredDisplay = mockStatic(UserDisplayCache.class);
MockedStatic<UserCache> ignoredUser = mockStatic(UserCache.class)) {
assertTrue(wrapper().fillDisplayFields(record("{\"realName\":\"张三\"}")).getRevised(), "有修订版应标记为已修订");
assertFalse(wrapper().fillDisplayFields(record(null)).getRevised(), "无修订版不应标记");
assertFalse(wrapper().fillDisplayFields(record("")).getRevised(), "空串不应标记");
assertFalse(wrapper().fillDisplayFields(record(" ")).getRevised(), "空白不应标记");
}
}
@Test
@DisplayName("视图对象序列化不包含修订内容,只保留 revised 标记")
void voJsonOmitsRevisionContent() throws Exception {
TransportCertRecordVO vo = record("{\"realName\":\"张三\"}");
vo.setRevised(Boolean.TRUE);
String json = new ObjectMapper().writeValueAsString(vo);
assertFalse(json.contains("auditedSnapshotJson"), "修订内容不得随 VO 下发:" + json);
assertTrue(json.contains("\"revised\":true"), "必须下发 revised 标记:" + json);
}
private static TransportCertRecordWrapper wrapper() {
return TransportCertRecordWrapper.build();
}
private static TransportCertRecordVO record(String auditedSnapshotJson) {
TransportCertRecordVO vo = new TransportCertRecordVO();
vo.setSnapshotJson("{\"realName\":\"李四\"}");
vo.setAuditedSnapshotJson(auditedSnapshotJson);
return vo;
}
}
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `blade_loading_manage` (
`trailer_vehicle_no` varchar(100) DEFAULT NULL COMMENT '挂车车牌号',
`driver_name` varchar(100) DEFAULT NULL COMMENT '司机',
`driver_phone` varchar(50) DEFAULT NULL COMMENT '联系电话',
`driver_id` bigint(20) DEFAULT NULL COMMENT '司机ID',
`escort_name` varchar(100) DEFAULT NULL COMMENT '押运人',
`escort_phone` varchar(50) DEFAULT NULL COMMENT '押运人手机号',
`carrier_type` varchar(50) DEFAULT NULL COMMENT '承运类型',
@@ -50,7 +51,8 @@ CREATE TABLE IF NOT EXISTS `blade_loading_manage` (
UNIQUE KEY `uk_loading_manage_no` (`loading_no`) USING BTREE,
KEY `idx_loading_manage_carrier_contract_id` (`carrier_contract_id`) USING BTREE,
KEY `idx_loading_manage_dept` (`dept_id`) USING BTREE,
KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE
KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE,
KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配载管理';
INSERT IGNORE INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES
@@ -0,0 +1,80 @@
-- ============================================================
-- blade_loading_manage_driver_id_20261028.sql
-- ============================================================
-- 运单/配载单写入司机档案 ID,把「归属」与「手机号快照」解耦:
-- blade_loading_manage.driver_id 配载单当前司机的司机档案 ID(blade_transport_driver.id)
-- blade_waybill.driver_id 已有列(本脚本只补索引),派单/配载/导入时写入
-- 归属判据:driver_id > 0 才算有主;0、-1 与 NULL 一律等同「无主」(承运商司机、未登录过小程序的司机)。
-- 历史数据不回填:手机号二次放号后按号反查会张冠李戴,旧单统一按无主处理,等下一次派单/配载再写入。
-- 本脚本只做结构升级,不改任何业务数据;以下结构变更可重复执行。
-- ------------------------------------------------------------
-- 0. 存档
-- ------------------------------------------------------------
-- 本脚本无数据订正,无需备份表。
-- ------------------------------------------------------------
-- 1. 结构变更
-- ------------------------------------------------------------
-- ① blade_loading_manage.driver_id
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_loading_manage` ADD COLUMN `driver_id` bigint(20) DEFAULT NULL COMMENT ''司机ID'' AFTER `driver_phone`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_loading_manage'
AND COLUMN_NAME = 'driver_id'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- ② blade_loading_manage 按租户 + 司机归属检索(司机端/归属巡检)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_loading_manage` ADD KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_loading_manage'
AND INDEX_NAME = 'idx_loading_manage_tenant_driver_id'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- ③ blade_waybill 按租户 + 司机归属检索(列已存在,只补索引)
SET @ddl_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_waybill` ADD KEY `idx_waybill_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'blade_waybill'
AND INDEX_NAME = 'idx_waybill_tenant_driver_id'
);
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- ------------------------------------------------------------
-- 2. 校验
-- ------------------------------------------------------------
-- 列存在(期望 1 行)
-- SELECT COUNT(*) FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_loading_manage' AND COLUMN_NAME = 'driver_id';
-- 索引存在(期望 2 行)
-- SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND INDEX_NAME IN ('idx_loading_manage_tenant_driver_id', 'idx_waybill_tenant_driver_id')
-- ORDER BY TABLE_NAME, SEQ_IN_INDEX;
-- 归属分布(历史单 driver_id 应为空)
-- SELECT COUNT(*) AS total, SUM(driver_id IS NOT NULL AND driver_id > 0) AS owned FROM blade_waybill;
-- ------------------------------------------------------------
-- 3. 回退
-- ------------------------------------------------------------
-- ALTER TABLE `blade_waybill` DROP INDEX `idx_waybill_tenant_driver_id`;
-- ALTER TABLE `blade_loading_manage` DROP INDEX `idx_loading_manage_tenant_driver_id`;
-- ALTER TABLE `blade_loading_manage` DROP COLUMN `driver_id`;
@@ -0,0 +1,239 @@
-- ============================================================
-- blade_loading_manage_driver_id_backfill_20261028.sql
-- ============================================================
-- 存量配载单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id
-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。
--
-- 为什么要订正:
-- 票 01 之后配载单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是配载时的号码快照。
-- 存量配载单全部只有号码、没有 driver_id(票 01 才加的列),司机端按档案 ID 查配载单会一张都查不到;
-- 按号码反查又会踩二次放号与改号,所以才需要先按「唯一命中」把能确定的补齐。
-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的配载单,其余留在原样、进清单等人工处置。
--
-- 判定与边界(严格按票面口径,不扩大):
-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动;
-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE),
-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。
-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。
-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。
-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。
-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。
-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。
-- ⑤ 逻辑删除的配载单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围;
-- 这些单不会回到列表,回填对它们无副作用。
-- ⑥ 跨表比较显式 COLLATE:blade_loading_manage.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci,
-- blade_transport_driver.mobile / tenant_id 与存档表都是 utf8mb4_general_ci;
-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。
-- 第 2.4 节拿存档表比 driver_phone 同样要带 COLLATE utf8mb4_0900_ai_ci。
-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。
-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。
--
-- 范围说明:与运单脚本同一口径,只做「手机号 → 档案唯一命中」这一条规则;其余留给人工处置。
-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。
-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错):
-- mysql -h127.0.0.1 -uroot -p transport < blade_loading_manage_driver_id_backfill_20261028.sql
-- ------------------------------------------------------------
-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档)
-- ------------------------------------------------------------
-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的配载单 + 它唯一命中的档案 ID。
-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。
CREATE TABLE IF NOT EXISTS blade_loading_manage_driver_id_bak_20261028 (
loading_id bigint NOT NULL COMMENT '配载单ID',
driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)',
driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)',
driver_phone varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写',
update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写',
PRIMARY KEY (loading_id),
KEY idx_driver_id_after (driver_id_after)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='20261028 配载单司机档案ID回填存档';
-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。
-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。
INSERT IGNORE INTO blade_loading_manage_driver_id_bak_20261028 (loading_id, driver_id_before, driver_id_after, driver_phone, update_time_before)
SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time
FROM (
SELECT w.id AS biz_id,
MIN(d.id) AS driver_id,
w.driver_phone AS driver_phone,
w.update_time AS update_time
FROM blade_loading_manage w
JOIN blade_transport_driver d
ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
GROUP BY w.id, w.driver_phone, w.update_time
HAVING COUNT(*) = 1
) matched;
-- ------------------------------------------------------------
-- 1. 订正(分批回填,默认每批 500 行)
-- ------------------------------------------------------------
-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。
-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。
DELIMITER $$
DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028$$
CREATE PROCEDURE backfill_blade_loading_manage_driver_id_20261028(IN p_batch_size INT)
BEGIN
DECLARE v_batch_size INT DEFAULT 500;
DECLARE v_affected INT DEFAULT 0;
IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN
SET v_batch_size = p_batch_size;
END IF;
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_loading_manage_driver_id_20261028 (
loading_id bigint NOT NULL,
PRIMARY KEY (loading_id)
) ENGINE = InnoDB;
REPEAT
-- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转
DELETE FROM tmp_loading_manage_driver_id_20261028;
INSERT INTO tmp_loading_manage_driver_id_20261028 (loading_id)
SELECT b.loading_id
FROM blade_loading_manage_driver_id_bak_20261028 b
JOIN blade_loading_manage w ON w.id = b.loading_id
WHERE w.driver_id IS NULL
ORDER BY b.loading_id
LIMIT v_batch_size;
UPDATE blade_loading_manage w
JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id
JOIN tmp_loading_manage_driver_id_20261028 t ON t.loading_id = w.id
SET w.driver_id = b.driver_id_after
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND b.driver_id_after IS NOT NULL;
SET v_affected = ROW_COUNT();
UNTIL v_affected = 0 END REPEAT;
DROP TEMPORARY TABLE IF EXISTS tmp_loading_manage_driver_id_20261028;
END$$
DELIMITER ;
CALL backfill_blade_loading_manage_driver_id_20261028(500);
DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028;
-- ------------------------------------------------------------
-- 2. 校验(执行后必看;本节只有 SELECT,不动数据)
-- ------------------------------------------------------------
-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次)
-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。
SELECT COUNT(*) AS archived_rows FROM blade_loading_manage_driver_id_bak_20261028;
SELECT COUNT(*) AS remaining_null_rows FROM blade_loading_manage WHERE driver_id IS NULL;
SELECT SUM(driver_id > 0) AS owned_rows,
SUM(driver_id = -1) AS sentinel_rows,
SUM(driver_id IS NULL) AS null_rows
FROM blade_loading_manage;
-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动)
SELECT COUNT(*) AS sentinel_rows,
SUM(id) AS sentinel_id_sum,
MIN(id) AS min_id,
MAX(id) AS max_id
FROM blade_loading_manage
WHERE driver_id = -1;
-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行)
SELECT COUNT(*) AS wrong_driver_id
FROM blade_loading_manage_driver_id_bak_20261028 b
JOIN blade_loading_manage w ON w.id = b.loading_id
WHERE NOT (w.driver_id <=> b.driver_id_after);
-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行)
SELECT COUNT(*) AS snapshot_rewritten
FROM blade_loading_manage_driver_id_bak_20261028 b
JOIN blade_loading_manage w ON w.id = b.loading_id
WHERE NOT (w.driver_phone <=> b.driver_phone COLLATE utf8mb4_0900_ai_ci)
OR NOT (w.update_time <=> b.update_time_before);
-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案)
SELECT w.driver_phone AS conflicting_phone,
COUNT(DISTINCT w.id) AS pending_rows,
COUNT(DISTINCT d.id) AS driver_profiles,
GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids
FROM blade_loading_manage w
JOIN blade_transport_driver d
ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
GROUP BY w.driver_phone
HAVING COUNT(DISTINCT d.id) > 1;
-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜)
SELECT w.driver_phone AS unmatched_phone,
COUNT(*) AS pending_rows
FROM blade_loading_manage w
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
AND NOT EXISTS (
SELECT 1
FROM blade_transport_driver d
WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0)
GROUP BY w.driver_phone
ORDER BY pending_rows DESC, unmatched_phone;
-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量
-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串)
-- by_driver_id = 档案 ID 等于该档案的单量(新口径)
-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分)
-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认)
-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用)
-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照)
SELECT d.id AS driver_id,
d.driver_name,
d.mobile,
(SELECT COUNT(*) FROM blade_loading_manage w WHERE w.driver_id = d.id) AS by_driver_id,
(SELECT COUNT(*) FROM blade_loading_manage w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone,
(SELECT COUNT(*) FROM blade_loading_manage w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id IS NULL) AS phone_only_null,
(SELECT COUNT(*) FROM blade_loading_manage w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id = -1) AS phone_only_sentinel,
(SELECT COUNT(*) FROM blade_loading_manage w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id > 0
AND w.driver_id <> d.id) AS phone_only_other_owner,
(SELECT COUNT(*) FROM blade_loading_manage w
WHERE w.driver_id = d.id
AND (w.driver_phone IS NULL
OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs
FROM blade_transport_driver d
WHERE d.is_deleted = 0
ORDER BY by_phone DESC, by_driver_id DESC, driver_id;
-- ------------------------------------------------------------
-- 3. 回退(仅在需要撤销本次回填时手工执行)
-- ------------------------------------------------------------
-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行:
-- 回填之后又被人改派过的单不会被冲掉。
-- UPDATE blade_loading_manage w
-- JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id
-- SET w.driver_id = b.driver_id_before
-- WHERE w.driver_id <=> b.driver_id_after;
-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删:
-- DROP TABLE blade_loading_manage_driver_id_bak_20261028;
+5 -2
View File
@@ -300,7 +300,8 @@ CREATE TABLE `blade_waybill` (
KEY `idx_waybill_dept` (`dept_id`) USING BTREE,
KEY `idx_waybill_plan_id` (`plan_id`) USING BTREE,
KEY `idx_waybill_carrier_contract_id` (`carrier_contract_id`) USING BTREE,
KEY `idx_waybill_create_time` (`create_time`) USING BTREE
KEY `idx_waybill_create_time` (`create_time`) USING BTREE,
KEY `idx_waybill_tenant_driver_id` (`tenant_id`,`driver_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单管理';
-- ----------------------------
@@ -330,6 +331,7 @@ CREATE TABLE `blade_loading_manage` (
`trailer_vehicle_no` varchar(100) DEFAULT NULL COMMENT '挂车车牌号',
`driver_name` varchar(100) DEFAULT NULL COMMENT '司机',
`driver_phone` varchar(50) DEFAULT NULL COMMENT '联系电话',
`driver_id` bigint(20) DEFAULT NULL COMMENT '司机ID',
`escort_name` varchar(100) DEFAULT NULL COMMENT '押运人',
`escort_phone` varchar(50) DEFAULT NULL COMMENT '押运人手机号',
`carrier_type` varchar(50) DEFAULT NULL COMMENT '承运类型',
@@ -359,7 +361,8 @@ CREATE TABLE `blade_loading_manage` (
UNIQUE KEY `uk_loading_manage_no` (`loading_no`) USING BTREE,
KEY `idx_loading_manage_carrier_contract_id` (`carrier_contract_id`) USING BTREE,
KEY `idx_loading_manage_dept` (`dept_id`) USING BTREE,
KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE
KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE,
KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`,`driver_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配载管理';
-- ----------------------------
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` (
`biz_key` varchar(100) DEFAULT NULL COMMENT '展示键:司机姓名/车牌号',
`status` int(11) NOT NULL COMMENT '审核状态:0审核中 1已通过 2已驳回 3已作废',
`snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '该次提交的全量认证内容JSON,一经创建不可修改',
`audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核通过时确定的修订版内容;NULL=未修订',
`reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值',
`submit_time` datetime DEFAULT NULL COMMENT '提交时间',
`submit_user` bigint(20) DEFAULT NULL COMMENT '提交人',
@@ -0,0 +1,68 @@
-- ----------------------------
-- 认证记录:新增修订版快照列 audited_snapshot_json(2026-10-28)
-- ----------------------------
-- 背景:审核员可以在审核页面对提交内容做修订(全字段可改,身份字段除外)。
-- 修订结果必须留痕:snapshot_json 是「司机当时提交了什么」,一经创建逐字节不可改;
-- 修订版另存一列,「审核通过时到底认定了什么内容」才查得清。
--
-- 语义:
-- NULL —— 未修订,生效内容即 snapshot_json;
-- 非 NULL —— 审核通过时认定的修订版全量 JSON,生效内容以它为准。
-- 只在「通过」时同事务写入;驳回不写(驳回不产生生效内容,留修订版只会让人误读)。
-- 修订人/修订时间复用 audit_user / audit_time,不新增列。
--
-- 只做结构变更,不回填数据:存量记录一律保持 NULL(历史审核确实没有修订动作)。
--
-- 不加索引:本列只在审核详情与生效内容读取时按主键取用,从不作为查询条件;
-- longtext 建索引会超出索引键长度并拖慢写入。
--
-- 幂等:先查 information_schema 判列,列已存在(或类型/可空性已符合)即跳过;
-- 只用 PREPARE 动态语句、不用存储过程,因此不需要 DELIMITER——按分号逐句切分的
-- 客户端(含数据库驱动)也能直接执行。重复执行零变更。
SET @cert_record_schema = DATABASE();
-- ----------------------------
-- 0. 表存在性守卫
-- ----------------------------
-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」;
-- 否则下面因查不到列而静默跳过,脚本会假绿。
SET @cert_record_guard_sql = (
SELECT IF(COUNT(*) > 0,
'SELECT 1',
'SELECT 1 FROM `blade_transport_cert_record` LIMIT 0')
FROM information_schema.tables
WHERE table_schema = @cert_record_schema
AND table_name = 'blade_transport_cert_record'
);
PREPARE cert_record_guard_stmt FROM @cert_record_guard_sql;
EXECUTE cert_record_guard_stmt;
DEALLOCATE PREPARE cert_record_guard_stmt;
-- ----------------------------
-- 1. 加列 audited_snapshot_json
-- ----------------------------
-- 位置紧接 snapshot_json:两列是同一份内容的前后两态,放一起便于对照。
-- 条件分三种:列不存在则 ADD;列存在但类型不是 longtext 或可空性不是 YES 则 MODIFY
-- (防止历史上的手工变更把列建成 varchar 而截断 JSON);其余情况跳过。
SET @cert_record_audited_snapshot_sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `blade_transport_cert_record` ADD COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订'' AFTER `snapshot_json`',
IF(SUM(data_type <> 'longtext' OR is_nullable <> 'YES') > 0,
'ALTER TABLE `blade_transport_cert_record` MODIFY COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订''',
'SELECT 1'))
FROM information_schema.columns
WHERE table_schema = @cert_record_schema
AND table_name = 'blade_transport_cert_record'
AND column_name = 'audited_snapshot_json'
);
PREPARE cert_record_audited_snapshot_stmt FROM @cert_record_audited_snapshot_sql;
EXECUTE cert_record_audited_snapshot_stmt;
DEALLOCATE PREPARE cert_record_audited_snapshot_stmt;
-- ----------------------------
-- 2. 回滚
-- ----------------------------
-- 只新增了一列、未进任何索引,回滚即删该列;
-- snapshot_json 的 updateStrategy=NEVER 与生成列 pending_biz_id、唯一键 uk_cert_record_pending 均不受影响:
-- ALTER TABLE `blade_transport_cert_record` DROP COLUMN `audited_snapshot_json`;
@@ -7,6 +7,7 @@ CREATE TABLE `blade_vehicle_maintenance_record` (
`tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '000000' COMMENT '租户ID',
`vehicle_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车船类型',
`vehicle_no` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车牌号/船号',
`exception_id` bigint NULL DEFAULT NULL COMMENT '关联异常处置ID(小程序录入时写入)',
`maintainer` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '维修人',
`maintenance_time` datetime NOT NULL COMMENT '维修时间',
`location` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '维修位置',
@@ -29,5 +30,6 @@ CREATE TABLE `blade_vehicle_maintenance_record` (
`is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_vehicle_maintenance_record_no`(`vehicle_no`) USING BTREE,
INDEX `idx_vehicle_maintenance_record_time`(`maintenance_time`) USING BTREE
INDEX `idx_vehicle_maintenance_record_time`(`maintenance_time`) USING BTREE,
UNIQUE INDEX `uk_vehicle_maintenance_record_exception`(`exception_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '车辆维修记录';
@@ -0,0 +1,35 @@
-- 维修记录关联异常处置
-- 日期:2026-10-29
-- 小程序调度端在「车辆故障维修」类异常下录入维修记录,记录通过 exception_id 关联该条异常。
-- 一条异常至多一条维修记录,故建唯一索引;唯一索引允许多个 NULL,PC 手工录入/导入的记录不受影响。
DELIMITER $$
DROP PROCEDURE IF EXISTS `upgrade_blade_vehicle_maintenance_record_exception_20261029`$$
CREATE PROCEDURE `upgrade_blade_vehicle_maintenance_record_exception_20261029`()
BEGIN
DECLARE db_name varchar(128);
SET db_name = DATABASE();
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = db_name
AND table_name = 'blade_vehicle_maintenance_record'
AND column_name = 'exception_id') THEN
ALTER TABLE `blade_vehicle_maintenance_record`
ADD COLUMN `exception_id` bigint NULL DEFAULT NULL COMMENT '关联异常处置ID(小程序录入时写入)'
AFTER `vehicle_no`;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema = db_name
AND table_name = 'blade_vehicle_maintenance_record'
AND index_name = 'uk_vehicle_maintenance_record_exception') THEN
ALTER TABLE `blade_vehicle_maintenance_record`
ADD UNIQUE INDEX `uk_vehicle_maintenance_record_exception`(`exception_id`) USING BTREE;
END IF;
END$$
CALL `upgrade_blade_vehicle_maintenance_record_exception_20261029`()$$
DROP PROCEDURE `upgrade_blade_vehicle_maintenance_record_exception_20261029`$$
DELIMITER ;
@@ -0,0 +1,242 @@
-- ============================================================
-- blade_waybill_driver_id_backfill_20261028.sql
-- ============================================================
-- 存量运单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id
-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。
--
-- 为什么要订正:
-- 票 01 之后运单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是派单时的号码快照。
-- 存量运单大多只有号码、没有 driver_id,司机端按档案 ID 查单会整批漏掉它们;
-- 按号码反查又会踩二次放号与改号(本机库实测:黄宇富档案已改号 15577122721,
-- 名下 92 张单仍写着旧号 17587421208,其中 20 张 driver_id 为空、5 张能从接单司机推出来,
-- 剩下 15 张号码已经对不上任何档案——本脚本不猜,它们会出现在第 2.6 节清单里)。
-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的运单,其余留在原样、进清单等人工处置。
--
-- 判定与边界(严格按票面口径,不扩大):
-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动;
-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE),
-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。
-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。
-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。
-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。
-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。
-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。
-- ⑤ 逻辑删除的运单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围;
-- 这些单不会回到列表,回填对它们无副作用。
-- ⑥ 跨表比较显式 COLLATE:blade_waybill.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci,
-- blade_transport_driver.mobile / tenant_id 与存档表都是 utf8mb4_general_ci;
-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。
-- 第 2.4 节拿存档表比 driver_phone 同样要带 COLLATE utf8mb4_0900_ai_ci。
-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。
-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。
--
-- 范围说明:本脚本只做「手机号 → 档案唯一命中」这一条规则。
-- 不引入 driver_accept_driver_id 推断,也不按姓名/历史号码猜归属:这些属于人工处置,在票面范围外。
-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。
-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错):
-- mysql -h127.0.0.1 -uroot -p transport < blade_waybill_driver_id_backfill_20261028.sql
-- ------------------------------------------------------------
-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档)
-- ------------------------------------------------------------
-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的运单 + 它唯一命中的档案 ID。
-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。
CREATE TABLE IF NOT EXISTS blade_waybill_driver_id_bak_20261028 (
waybill_id bigint NOT NULL COMMENT '运单ID',
driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)',
driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)',
driver_phone varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写',
update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写',
PRIMARY KEY (waybill_id),
KEY idx_driver_id_after (driver_id_after)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='20261028 运单司机档案ID回填存档';
-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。
-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。
INSERT IGNORE INTO blade_waybill_driver_id_bak_20261028 (waybill_id, driver_id_before, driver_id_after, driver_phone, update_time_before)
SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time
FROM (
SELECT w.id AS biz_id,
MIN(d.id) AS driver_id,
w.driver_phone AS driver_phone,
w.update_time AS update_time
FROM blade_waybill w
JOIN blade_transport_driver d
ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
GROUP BY w.id, w.driver_phone, w.update_time
HAVING COUNT(*) = 1
) matched;
-- ------------------------------------------------------------
-- 1. 订正(分批回填,默认每批 500 行)
-- ------------------------------------------------------------
-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。
-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。
DELIMITER $$
DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028$$
CREATE PROCEDURE backfill_blade_waybill_driver_id_20261028(IN p_batch_size INT)
BEGIN
DECLARE v_batch_size INT DEFAULT 500;
DECLARE v_affected INT DEFAULT 0;
IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN
SET v_batch_size = p_batch_size;
END IF;
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_waybill_driver_id_20261028 (
waybill_id bigint NOT NULL,
PRIMARY KEY (waybill_id)
) ENGINE = InnoDB;
REPEAT
-- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转
DELETE FROM tmp_waybill_driver_id_20261028;
INSERT INTO tmp_waybill_driver_id_20261028 (waybill_id)
SELECT b.waybill_id
FROM blade_waybill_driver_id_bak_20261028 b
JOIN blade_waybill w ON w.id = b.waybill_id
WHERE w.driver_id IS NULL
ORDER BY b.waybill_id
LIMIT v_batch_size;
UPDATE blade_waybill w
JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id
JOIN tmp_waybill_driver_id_20261028 t ON t.waybill_id = w.id
SET w.driver_id = b.driver_id_after
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND b.driver_id_after IS NOT NULL;
SET v_affected = ROW_COUNT();
UNTIL v_affected = 0 END REPEAT;
DROP TEMPORARY TABLE IF EXISTS tmp_waybill_driver_id_20261028;
END$$
DELIMITER ;
CALL backfill_blade_waybill_driver_id_20261028(500);
DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028;
-- ------------------------------------------------------------
-- 2. 校验(执行后必看;本节只有 SELECT,不动数据)
-- ------------------------------------------------------------
-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次)
-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。
SELECT COUNT(*) AS archived_rows FROM blade_waybill_driver_id_bak_20261028;
SELECT COUNT(*) AS remaining_null_rows FROM blade_waybill WHERE driver_id IS NULL;
SELECT SUM(driver_id > 0) AS owned_rows,
SUM(driver_id = -1) AS sentinel_rows,
SUM(driver_id IS NULL) AS null_rows
FROM blade_waybill;
-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动)
SELECT COUNT(*) AS sentinel_rows,
SUM(id) AS sentinel_id_sum,
MIN(id) AS min_id,
MAX(id) AS max_id
FROM blade_waybill
WHERE driver_id = -1;
-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行)
SELECT COUNT(*) AS wrong_driver_id
FROM blade_waybill_driver_id_bak_20261028 b
JOIN blade_waybill w ON w.id = b.waybill_id
WHERE NOT (w.driver_id <=> b.driver_id_after);
-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行)
SELECT COUNT(*) AS snapshot_rewritten
FROM blade_waybill_driver_id_bak_20261028 b
JOIN blade_waybill w ON w.id = b.waybill_id
WHERE NOT (w.driver_phone <=> b.driver_phone COLLATE utf8mb4_0900_ai_ci)
OR NOT (w.update_time <=> b.update_time_before);
-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案)
SELECT w.driver_phone AS conflicting_phone,
COUNT(DISTINCT w.id) AS pending_rows,
COUNT(DISTINCT d.id) AS driver_profiles,
GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids
FROM blade_waybill w
JOIN blade_transport_driver d
ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
GROUP BY w.driver_phone
HAVING COUNT(DISTINCT d.id) > 1;
-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜)
SELECT w.driver_phone AS unmatched_phone,
COUNT(*) AS pending_rows
FROM blade_waybill w
WHERE w.driver_id IS NULL
AND (w.driver_id = -1) IS NOT TRUE
AND w.driver_phone IS NOT NULL
AND TRIM(w.driver_phone) <> ''
AND w.driver_phone NOT LIKE '%*%'
AND NOT EXISTS (
SELECT 1
FROM blade_transport_driver d
WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id
AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone
AND d.is_deleted = 0)
GROUP BY w.driver_phone
ORDER BY pending_rows DESC, unmatched_phone;
-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量
-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串)
-- by_driver_id = 档案 ID 等于该档案的单量(新口径)
-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分)
-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认)
-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用)
-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照)
SELECT d.id AS driver_id,
d.driver_name,
d.mobile,
(SELECT COUNT(*) FROM blade_waybill w WHERE w.driver_id = d.id) AS by_driver_id,
(SELECT COUNT(*) FROM blade_waybill w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone,
(SELECT COUNT(*) FROM blade_waybill w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id IS NULL) AS phone_only_null,
(SELECT COUNT(*) FROM blade_waybill w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id = -1) AS phone_only_sentinel,
(SELECT COUNT(*) FROM blade_waybill w
WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id
AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile
AND w.driver_id > 0
AND w.driver_id <> d.id) AS phone_only_other_owner,
(SELECT COUNT(*) FROM blade_waybill w
WHERE w.driver_id = d.id
AND (w.driver_phone IS NULL
OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs
FROM blade_transport_driver d
WHERE d.is_deleted = 0
ORDER BY by_phone DESC, by_driver_id DESC, driver_id;
-- ------------------------------------------------------------
-- 3. 回退(仅在需要撤销本次回填时手工执行)
-- ------------------------------------------------------------
-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行:
-- 回填之后又被人改派过的单不会被冲掉。
-- UPDATE blade_waybill w
-- JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id
-- SET w.driver_id = b.driver_id_before
-- WHERE w.driver_id <=> b.driver_id_after;
-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删:
-- DROP TABLE blade_waybill_driver_id_bak_20261028;
+3 -1
View File
@@ -5620,6 +5620,7 @@ CREATE TABLE `blade_vehicle_maintenance_record` (
`tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID',
`vehicle_type` varchar(20) NOT NULL COMMENT '车船类型',
`vehicle_no` varchar(30) NOT NULL COMMENT '车牌号/船号',
`exception_id` bigint(20) DEFAULT NULL COMMENT '关联异常处置ID(小程序录入时写入)',
`maintainer` varchar(20) DEFAULT NULL COMMENT '维修人',
`maintenance_time` datetime NOT NULL COMMENT '维修时间',
`location` varchar(100) DEFAULT NULL COMMENT '维修位置',
@@ -5642,7 +5643,8 @@ CREATE TABLE `blade_vehicle_maintenance_record` (
`is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_vehicle_maintenance_record_no` (`vehicle_no`) USING BTREE,
KEY `idx_vehicle_maintenance_record_time` (`maintenance_time`) USING BTREE
KEY `idx_vehicle_maintenance_record_time` (`maintenance_time`) USING BTREE,
UNIQUE KEY `uk_vehicle_maintenance_record_exception` (`exception_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆维修记录';
-- ----------------------------