Merge branch 'master' into feature/payment-apply-bfi-sync
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.config;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
|
||||
import java.beans.PropertyEditorSupport;
|
||||
|
||||
/**
|
||||
* 列表查询 deptId 绑定:用户可能绑定多个组织,前端会传入逗号分隔字符串。
|
||||
* 组织筛选项为单选,这里取第一个有效 ID,避免 Long 类型转换失败。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@ControllerAdvice(basePackages = "org.springblade.transport.controller")
|
||||
public class DeptIdBindingAdvice {
|
||||
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(Long.class, "deptId", new PropertyEditorSupport() {
|
||||
@Override
|
||||
public void setAsText(String text) {
|
||||
if (Func.isBlank(text)) {
|
||||
setValue(null);
|
||||
return;
|
||||
}
|
||||
setValue(Func.firstLong(text));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
+85
-4
@@ -5,28 +5,109 @@
|
||||
package org.springblade.transport.config;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 凭证图片 MinIO 客户端配置。
|
||||
* <p>
|
||||
* 连接参数由 Nacos 的 file.storage.minio 配置提供。
|
||||
* 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端,
|
||||
* 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动;
|
||||
* 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。
|
||||
* <ul>
|
||||
* <li>{@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回</li>
|
||||
* <li>{@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint")
|
||||
public class VoucherMinioConfig {
|
||||
|
||||
private static final String DEFAULT_REGION = "us-east-1";
|
||||
|
||||
@Bean
|
||||
public MinioClient voucherMinioClient(
|
||||
@Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint,
|
||||
@Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey,
|
||||
@Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) {
|
||||
return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build();
|
||||
String origin = resolveClientEndpoint(endpoint);
|
||||
String pathPrefix = resolvePathPrefix(endpoint);
|
||||
if (StringUtil.isNotBlank(pathPrefix)) {
|
||||
log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址",
|
||||
origin, pathPrefix);
|
||||
}
|
||||
return create(endpoint, accessKey, secretKey);
|
||||
}
|
||||
|
||||
public static MinioClient create(String endpoint, String accessKey, String secretKey) {
|
||||
String origin = resolveClientEndpoint(endpoint);
|
||||
String pathPrefix = resolvePathPrefix(endpoint);
|
||||
MinioClient.Builder builder = MinioClient.builder()
|
||||
.endpoint(origin)
|
||||
.credentials(accessKey, secretKey)
|
||||
.region(DEFAULT_REGION);
|
||||
if (StringUtil.isNotBlank(pathPrefix)) {
|
||||
builder.httpClient(buildHttpClient(pathPrefix), true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* MinIO SDK 不允许 endpoint 带 path(例如 https://host/oss)。
|
||||
*/
|
||||
public static String resolveClientEndpoint(String endpoint) {
|
||||
if (StringUtil.isBlank(endpoint)) {
|
||||
throw new ServiceException("MinIO endpoint 不能为空");
|
||||
}
|
||||
URI uri = URI.create(endpoint.trim());
|
||||
if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) {
|
||||
throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint);
|
||||
}
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(uri.getScheme()).append("://").append(uri.getHost());
|
||||
if (uri.getPort() > 0) {
|
||||
origin.append(':').append(uri.getPort());
|
||||
}
|
||||
return origin.toString();
|
||||
}
|
||||
|
||||
private static String resolvePathPrefix(String endpoint) {
|
||||
URI uri = URI.create(endpoint.trim());
|
||||
String path = uri.getPath();
|
||||
if (StringUtil.isBlank(path) || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
|
||||
return normalized.startsWith("/") ? normalized : "/" + normalized;
|
||||
}
|
||||
|
||||
private static OkHttpClient buildHttpClient(String pathPrefix) {
|
||||
return new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(chain -> {
|
||||
Request original = chain.request();
|
||||
HttpUrl originalUrl = original.url();
|
||||
String encodedPath = originalUrl.encodedPath();
|
||||
if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) {
|
||||
return chain.proceed(original);
|
||||
}
|
||||
HttpUrl newUrl = originalUrl.newBuilder()
|
||||
.encodedPath(pathPrefix + encodedPath)
|
||||
.build();
|
||||
return chain.proceed(original.newBuilder().url(newUrl).build());
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.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.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 认证审核(管理端)
|
||||
* <p>
|
||||
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
|
||||
* 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖,
|
||||
* 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping({"/certification-audit", "/blade-transport/certification-audit"})
|
||||
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
|
||||
public class CertificationAuditController extends BladeController {
|
||||
|
||||
private final ICertAuditService certAuditService;
|
||||
|
||||
@GetMapping("/pending-page")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)")
|
||||
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||
public R<IPage<TransportCertRecordVO>> pendingPage(
|
||||
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
|
||||
@RequestParam String bizType,
|
||||
@Parameter(description = "展示键模糊匹配:提交时的司机姓名 / 车牌号")
|
||||
@RequestParam(required = false) String bizKey,
|
||||
@Parameter(description = "司机姓名模糊匹配:当前姓名,同时兼顾提交时的旧名")
|
||||
@RequestParam(required = false) String driverName,
|
||||
@Parameter(description = "司机手机号模糊匹配:支持只输后四位")
|
||||
@RequestParam(required = false) String driverMobile,
|
||||
@Parameter(description = "审核状态:0审核中 1已通过 2已驳回 3已作废;不传按0(审核中)处理")
|
||||
@RequestParam(required = false) Integer status,
|
||||
Query query) {
|
||||
return R.data(certAuditService.pendingPage(bizType, bizKey, driverName, driverMobile, status, Condition.getPage(query)));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
|
||||
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||
public R<CertAuditDetailVO> detail(
|
||||
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
|
||||
return R.data(certAuditService.detail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/audit")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@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 = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
|
||||
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
|
||||
}
|
||||
|
||||
}
|
||||
+9
-1
@@ -177,8 +177,16 @@ public class ContractManageController extends BladeController {
|
||||
return R.data(contractManageService.expireStats(contractManage));
|
||||
}
|
||||
|
||||
@GetMapping("/export-contract-manage")
|
||||
@GetMapping("/next-contract-no")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览")
|
||||
public R<String> nextContractNo(
|
||||
@Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) {
|
||||
return R.data(contractManageService.nextContractNo(organizationId));
|
||||
}
|
||||
|
||||
@GetMapping("/export-contract-manage")
|
||||
@ApiOperationSupport(order = 17)
|
||||
@Operation(summary = "导出合同管理")
|
||||
public void exportContractManage(ContractManageVO contractManage, @RequestParam(required = false) String ids, HttpServletResponse response) {
|
||||
List<ContractManageExcel> list = contractManageService.exportContractManage(contractManage, ids);
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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 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.ocr.constant.BaiduOcrType;
|
||||
import org.springblade.transport.ocr.service.IBaiduOcrService;
|
||||
import org.springblade.transport.pojo.dto.OcrRecognizeRequest;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.IDriverCertService;
|
||||
import org.springblade.transport.support.OcrTypeMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 司机端认证(小程序)
|
||||
* <p>
|
||||
* 对外路径:{@code /api/blade-transport/driver/auth/**}
|
||||
* 同时兼容未去前缀直连 {@code /blade-transport/driver/auth/**}。
|
||||
* <p>
|
||||
* 提交审核**不影响**该对象当前的可用性:待审内容只落认证记录,
|
||||
* 审核通过前对外呈现的仍是旧的生效值。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping({"/driver/auth", "/blade-transport/driver/auth"})
|
||||
@Tag(name = "司机端认证", description = "小程序司机个人认证与车辆认证")
|
||||
public class DriverAuthController extends BladeController {
|
||||
|
||||
private final IDriverCertService driverCertService;
|
||||
private final IBaiduOcrService baiduOcrService;
|
||||
|
||||
// ------------------------------------------------------------ 个人认证
|
||||
|
||||
@PostMapping("/person")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "提交个人认证", description = "三证(身份证+驾驶证+从业资格证)一起提交,生成待审记录")
|
||||
public R<Long> submitPerson(@RequestBody PersonCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitPersonCert(request));
|
||||
}
|
||||
|
||||
@GetMapping("/person")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "个人认证生效值", description = "返回当前生效快照的内容;从未认证通过时 data 为 null")
|
||||
public R<PersonCertSubmitRequest> person() {
|
||||
return R.data(driverCertService.getEffectivePersonCert());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 车辆认证
|
||||
|
||||
@PostMapping("/vehicle")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "提交车辆认证", description = "生成待审记录;不改变该车已生效的认证内容")
|
||||
public R<Long> submitVehicle(@RequestBody VehicleCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@PutMapping("/vehicle")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "重新认证车辆", description = "与提交同一语义,语义上用于已有车辆重新认证")
|
||||
public R<Long> updateVehicle(@RequestBody VehicleCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/vehicle/{id}")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "删除车辆认证", description = "对车辆做逻辑删除;认证记录保留,不物理删除")
|
||||
public R<Boolean> deleteVehicle(@PathVariable Long id) {
|
||||
return R.status(driverCertService.deleteVehicleCert(id));
|
||||
}
|
||||
|
||||
@GetMapping("/vehicle/effective")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "车辆认证生效值", description = "返回该车当前生效快照的内容")
|
||||
public R<VehicleCertSubmitRequest> vehicleEffective(@RequestParam Long vehicleId) {
|
||||
return R.data(driverCertService.getEffectiveVehicleCert(vehicleId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 认证记录
|
||||
|
||||
@GetMapping("/record/list")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "认证记录列表", description = "按提交时间倒序;只能查本人或本人绑定车辆的记录")
|
||||
public R<List<TransportCertRecordVO>> recordList(@RequestParam String bizType,
|
||||
@RequestParam Long bizId) {
|
||||
return R.data(driverCertService.listRecords(bizType, bizId));
|
||||
}
|
||||
|
||||
@GetMapping("/record/detail")
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "认证记录详情", description = "内容取自该次提交的完整快照")
|
||||
public R<TransportCertRecordVO> recordDetail(@RequestParam Long id) {
|
||||
return R.data(driverCertService.getRecord(id));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ OCR
|
||||
|
||||
@PostMapping("/ocr")
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "证件OCR识别",
|
||||
description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。"
|
||||
+ "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证")
|
||||
public R<BaiduOcrResultVO> ocr(@RequestBody OcrRecognizeRequest request) {
|
||||
// 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游
|
||||
BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType());
|
||||
// 未指定时传 null,不要在这里补 front:道路运输证没有正副面参数,
|
||||
// 补了会变成「传了 side 但该证件不支持」,让这条链路恒失败。
|
||||
// 「默认取正面」由知晓证件类型的一侧(OCR 服务)决定。
|
||||
String side = OcrTypeMapping.normalizeSide(request.getSide());
|
||||
return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl()));
|
||||
}
|
||||
|
||||
}
|
||||
+12
-4
@@ -38,6 +38,7 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -64,14 +65,14 @@ public class DriverWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/current-task")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单")
|
||||
@Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单")
|
||||
public R<DriverWaybillCardVO> currentTask() {
|
||||
return R.data(driverWaybillService.currentTask());
|
||||
}
|
||||
|
||||
@GetMapping("/pending-preview")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数")
|
||||
@Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数")
|
||||
public R<DriverWaybillPreviewVO> pendingPreview(
|
||||
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
|
||||
return R.data(driverWaybillService.pendingPreview(size));
|
||||
@@ -79,14 +80,14 @@ public class DriverWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/counts")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成")
|
||||
@Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成")
|
||||
public R<DriverWaybillTabCountsVO> counts() {
|
||||
return R.data(driverWaybillService.tabCounts());
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成")
|
||||
@Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成")
|
||||
public R<IPage<DriverWaybillCardVO>> page(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
|
||||
@@ -104,6 +105,13 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
|
||||
public R<WaybillTrackVO> track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
return R.data(driverWaybillService.track(id));
|
||||
}
|
||||
|
||||
@PostMapping("/accept")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中")
|
||||
|
||||
+12
@@ -30,11 +30,14 @@ import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.support.ExceptionDisposalPermissions;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController {
|
||||
return R.success("批量完成成功");
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
@PreAuth(ExceptionDisposalPermissions.EDIT)
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态")
|
||||
public R update(@RequestBody ExceptionDisposalUpdateRequest request) {
|
||||
exceptionDisposalService.update(request);
|
||||
return R.success("编辑成功");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
-1
@@ -30,6 +30,7 @@ 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.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
|
||||
@@ -37,7 +38,9 @@ import org.springblade.transport.pojo.vo.AdminHomeVO;
|
||||
import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -62,6 +65,7 @@ import java.util.List;
|
||||
public class ManageWaybillController extends BladeController {
|
||||
|
||||
private final IManageWaybillService manageWaybillService;
|
||||
private final IWaybillService waybillService;
|
||||
|
||||
@GetMapping("/stats")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@@ -72,7 +76,7 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/home")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名")
|
||||
@Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名")
|
||||
public R<AdminHomeVO> home() {
|
||||
return R.data(manageWaybillService.home());
|
||||
}
|
||||
@@ -140,4 +144,19 @@ public class ManageWaybillController extends BladeController {
|
||||
return R.data(manageWaybillService.searchVehicles(keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/punch-records")
|
||||
@ApiOperationSupport(order = 9)
|
||||
@Operation(summary = "打卡记录", description = "小程序调度端:仅需登录态;按过程配置返回节点/在途打卡流水")
|
||||
public R<WaybillPunchRecordsVO> punchRecords(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long waybillId) {
|
||||
return R.data(waybillService.listPunchRecords(waybillId));
|
||||
}
|
||||
|
||||
@PostMapping("/punch-record/save")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "编辑打卡记录", description = "小程序调度端:补录/订正/取消打卡,仅需登录态")
|
||||
public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) {
|
||||
return R.status(waybillService.savePunchRecord(request));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+19
-1
@@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.excel.util.ExcelUtil;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
@@ -67,6 +68,7 @@ import java.util.List;
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@PreAuth(menu = "transport_vehicle")
|
||||
@RequestMapping("/transport-vehicle")
|
||||
@Tag(name = "车辆管理", description = "车辆管理")
|
||||
@@ -132,11 +134,27 @@ public class TransportVehicleController extends BladeController {
|
||||
return R.status(transportVehicleService.changeStatus(id, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核车辆认证(已弃用)
|
||||
*
|
||||
* @deprecated 本接口作用于**车辆行**并原地覆盖认证状态,在「生效值 / 待审值分离」
|
||||
* 之后语义已不成立:一次审核应对应**一条认证记录**,且通过才建立生效快照、
|
||||
* 驳回不得影响既得认证。请改用
|
||||
* {@code POST /blade-transport/certification-audit/audit}。
|
||||
* <p>
|
||||
* 保留而非删除,是因为它可能仍被小程序历史版本或其它系统调用;
|
||||
* 调用时记录告警日志。它只写展示缓存,**不再影响可用性判定**
|
||||
* (可用性只看 {@code effective_cert_record_id})。
|
||||
*/
|
||||
@Deprecated
|
||||
@PostMapping("/audit-certification")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "审核车辆认证")
|
||||
@Operation(summary = "审核车辆认证(已弃用)",
|
||||
description = "已弃用:作用于车辆行且原地覆盖。请改用 /blade-transport/certification-audit/audit")
|
||||
public R auditCertification(@RequestParam Long id, @RequestParam Integer certificationStatus,
|
||||
@RequestParam(required = false) String rejectReason) {
|
||||
log.warn("[认证审核] 调用了已弃用接口 audit-certification,vehicleId={}, status={};"
|
||||
+ "请改用 /blade-transport/certification-audit/audit", id, certificationStatus);
|
||||
return R.status(transportVehicleService.auditCertification(id, certificationStatus, rejectReason));
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -29,10 +29,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 对外路径:{@code /blade-transport/voucher-manage/**}
|
||||
* <p>
|
||||
* 网关 StripPrefix 后落入 {@code /voucher-manage/**};
|
||||
* 同时兼容未去前缀直连 {@code /blade-transport/voucher-manage/**},避免 404 落到静态资源。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@PreAuth(menu = "voucher_manage")
|
||||
@RequestMapping("/voucher-manage")
|
||||
@RequestMapping({"/voucher-manage", "/blade-transport/voucher-manage"})
|
||||
@Tag(name = "凭证管理", description = "执行凭证批次管理")
|
||||
public class VoucherManageController extends BladeController {
|
||||
private final IVoucherManageService voucherManageService;
|
||||
|
||||
+34
-2
@@ -23,6 +23,7 @@
|
||||
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 cn.idev.excel.write.handler.SheetWriteHandler;
|
||||
import cn.idev.excel.write.metadata.holder.WriteSheetHolder;
|
||||
@@ -43,6 +44,7 @@ import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.excel.WaybillExcel;
|
||||
import org.springblade.transport.excel.WaybillImportBatchExcel;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
@@ -50,6 +52,7 @@ import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.pojo.entity.TransportPlan;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillImportBatch;
|
||||
import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
|
||||
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
@@ -60,6 +63,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillVO;
|
||||
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
|
||||
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
@@ -76,8 +80,11 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运单管理 控制器
|
||||
@@ -113,6 +120,13 @@ public class WaybillController extends BladeController {
|
||||
return R.data(waybillService.listPunchRecords(waybillId));
|
||||
}
|
||||
|
||||
@PostMapping("/punch-record/save")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "编辑打卡记录", description = "按过程配置补录/修正节点或在途打卡;punched=false 表示取消打卡,清除该节点打卡流水")
|
||||
public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) {
|
||||
return R.status(waybillService.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@PostMapping("/locate")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "车辆实时定位", description = "按运单绑定车牌调用 LBS_LOCATE")
|
||||
@@ -151,10 +165,22 @@ public class WaybillController extends BladeController {
|
||||
.map(contract -> option(contract.getId(), "contractName", contract.getContractName(), "projectId", contract.getProjectId()))
|
||||
.toList());
|
||||
options.put("carriers", customerArchiveService.list().stream()
|
||||
.filter(customer -> "承运商".equals(customer.getCustomerType()) && Integer.valueOf(1).equals(customer.getStatus()))
|
||||
.filter(customer -> isCarrierCustomer(customer) && Integer.valueOf(1).equals(customer.getStatus()))
|
||||
.map(customer -> option(customer.getId(), "name", customer.getFullName(), null, null))
|
||||
.toList());
|
||||
options.put("creators", new ArrayList<>());
|
||||
// 创建人选项取已有导入批次的创建人,保证筛选项与列表数据可对应。
|
||||
Set<Long> creatorIds = new LinkedHashSet<>();
|
||||
waybillImportBatchService.list(Wrappers.<WaybillImportBatch>lambdaQuery()
|
||||
.select(WaybillImportBatch::getCreateUser)
|
||||
.eq(WaybillImportBatch::getIsDeleted, 0)
|
||||
.isNotNull(WaybillImportBatch::getCreateUser))
|
||||
.stream()
|
||||
.map(WaybillImportBatch::getCreateUser)
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(creatorIds::add);
|
||||
options.put("creators", creatorIds.stream()
|
||||
.map(userId -> option(userId, "name", UserCache.getUserRealName(userId), null, null))
|
||||
.toList());
|
||||
options.put("plans", transportPlanService.list().stream()
|
||||
.map(plan -> option(plan.getId(), "planName", plan.getPlanName(), "projectId", plan.getProjectId()))
|
||||
.toList());
|
||||
@@ -364,6 +390,12 @@ public class WaybillController extends BladeController {
|
||||
return option;
|
||||
}
|
||||
|
||||
/** 客商类型支持多选(逗号分隔),包含「承运商」即视为承运商。 */
|
||||
private boolean isCarrierCustomer(CustomerArchive customer) {
|
||||
String type = Func.toStr(customer.getCustomerType()).replace(",", ",");
|
||||
return Func.toStrList(type).stream().anyMatch("承运商"::equals);
|
||||
}
|
||||
|
||||
private static final class TextColumnStyleHandler implements SheetWriteHandler {
|
||||
|
||||
private final int[] columnIndexes;
|
||||
|
||||
+8
-2
@@ -56,7 +56,7 @@ public class ContractManageExcel implements Serializable {
|
||||
private String contractName;
|
||||
@ExcelProperty("所属项目")
|
||||
private String projectName;
|
||||
@ExcelProperty("所属组织")
|
||||
@ExcelProperty("合同负责部门")
|
||||
private String organizationName;
|
||||
@ExcelProperty("合同类别")
|
||||
private String contractCategory;
|
||||
@@ -76,6 +76,10 @@ public class ContractManageExcel implements Serializable {
|
||||
private LocalDate temporaryEndDate;
|
||||
@ExcelProperty("经办人")
|
||||
private String handlerUserName;
|
||||
@ExcelProperty("签订日期")
|
||||
private LocalDate signDate;
|
||||
@ExcelProperty("申请日期")
|
||||
private LocalDate applyDate;
|
||||
@ExcelProperty("合同阶段")
|
||||
private String contractStage;
|
||||
@ExcelProperty("审核状态")
|
||||
@@ -105,7 +109,9 @@ public class ContractManageExcel implements Serializable {
|
||||
private String currentNode;
|
||||
@ExcelProperty("当前处理人")
|
||||
private String currentProcessor;
|
||||
@ExcelProperty("备注")
|
||||
@ExcelProperty("合同主要内容")
|
||||
private String mainContent;
|
||||
@ExcelProperty("其他事项说明")
|
||||
private String remark;
|
||||
@ExcelProperty("创建人")
|
||||
private String createUserName;
|
||||
|
||||
+13
-1
@@ -46,6 +46,12 @@
|
||||
<result column="emergency_contact_name" property="emergencyContactName"/>
|
||||
<result column="emergency_contact_mobile" property="emergencyContactMobile"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="id_card_valid_start" property="idCardValidStart"/>
|
||||
<result column="id_card_valid_end" property="idCardValidEnd"/>
|
||||
<result column="id_card_long_term" property="idCardLongTerm"/>
|
||||
<result column="certification_status" property="certificationStatus"/>
|
||||
<result column="certification_reject_reason" property="certificationRejectReason"/>
|
||||
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="BaseColumn">
|
||||
@@ -70,6 +76,9 @@
|
||||
posts,
|
||||
id_card_front,
|
||||
id_card_back,
|
||||
id_card_valid_start,
|
||||
id_card_valid_end,
|
||||
id_card_long_term,
|
||||
head_photo,
|
||||
driving_type,
|
||||
driving_license_no,
|
||||
@@ -91,7 +100,10 @@
|
||||
organization_name,
|
||||
emergency_contact_name,
|
||||
emergency_contact_mobile,
|
||||
remark
|
||||
remark,
|
||||
certification_status,
|
||||
certification_reject_reason,
|
||||
effective_cert_record_id
|
||||
</sql>
|
||||
|
||||
<select id="selectByIdCardNoIncludingDeleted" resultType="org.springblade.transport.pojo.entity.Driver">
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证记录 Mapper 接口
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface TransportCertRecordMapper extends BaseMapper<TransportCertRecord> {
|
||||
|
||||
/**
|
||||
* 审核列表分页:认证记录 + 该记录的司机姓名与手机号
|
||||
* <p>
|
||||
* 必须走自定义 SQL 而不是单表 Wrapper:司机姓名与手机号取的是司机档案的**当前**值,
|
||||
* 且「按司机姓名/手机号筛选」必须发生在**分页之前**——若在 Java 层补字段再过滤,
|
||||
* 就变成「先分页再过滤」,每页条数与总数都不准。
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废
|
||||
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
|
||||
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
|
||||
* @param driverMobile 司机手机号模糊匹配,可为空
|
||||
* @return 认证记录列表
|
||||
*/
|
||||
List<TransportCertRecordVO> selectAuditPage(IPage<TransportCertRecordVO> page,
|
||||
@Param("bizType") String bizType,
|
||||
@Param("status") Integer status,
|
||||
@Param("bizKey") String bizKey,
|
||||
@Param("driverName") String driverName,
|
||||
@Param("driverMobile") String driverMobile);
|
||||
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.springblade.transport.mapper.TransportCertRecordMapper">
|
||||
|
||||
<resultMap id="certRecordAuditMap" type="org.springblade.transport.pojo.vo.TransportCertRecordVO">
|
||||
<result column="id" property="id"/>
|
||||
<result column="tenant_id" property="tenantId"/>
|
||||
<result column="biz_type" property="bizType"/>
|
||||
<result column="biz_id" property="bizId"/>
|
||||
<result column="biz_key" property="bizKey"/>
|
||||
<result column="status" property="status"/>
|
||||
<result column="reject_reason" property="rejectReason"/>
|
||||
<result column="submit_time" property="submitTime"/>
|
||||
<result column="submit_user" property="submitUser"/>
|
||||
<result column="audit_time" property="auditTime"/>
|
||||
<result column="audit_user" property="auditUser"/>
|
||||
<result column="create_user" property="createUser"/>
|
||||
<result column="create_dept" property="createDept"/>
|
||||
<result column="create_time" property="createTime"/>
|
||||
<result column="update_user" property="updateUser"/>
|
||||
<result column="update_time" property="updateTime"/>
|
||||
<result column="is_deleted" property="isDeleted"/>
|
||||
<result column="driver_name" property="driverName"/>
|
||||
<result column="driver_mobile" property="driverMobile"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
审核列表:认证记录 + 该记录的司机姓名与手机号(司机档案的当前值)。
|
||||
|
||||
司机是谁:司机侧就是认证对象本身(biz_id);车辆侧是这条记录的**提交司机**
|
||||
(车辆认证强制「只能操作本人绑定车辆」,故提交司机必然是这辆车的绑定司机)。
|
||||
提交人存的是用户 ID,而司机档案的 user_id 是同一个值,故按 user_id 定位。
|
||||
|
||||
子查询带 ORDER BY update_time DESC LIMIT 1 有实际作用,不是保险起见:
|
||||
user_id 上没有唯一索引,脏数据里同一用户可能对应多条司机档案,
|
||||
不收敛就会让一条记录在列表里出现多行(并让总数虚高)。
|
||||
按 update_time 最新取,与司机端解析当前司机档案的既有口径一致。
|
||||
-->
|
||||
<select id="selectAuditPage" resultMap="certRecordAuditMap">
|
||||
SELECT
|
||||
r.id,
|
||||
r.tenant_id,
|
||||
r.biz_type,
|
||||
r.biz_id,
|
||||
r.biz_key,
|
||||
r.status,
|
||||
r.reject_reason,
|
||||
r.submit_time,
|
||||
r.submit_user,
|
||||
r.audit_time,
|
||||
r.audit_user,
|
||||
r.create_user,
|
||||
r.create_dept,
|
||||
r.create_time,
|
||||
r.update_user,
|
||||
r.update_time,
|
||||
r.is_deleted,
|
||||
d.driver_name AS driver_name,
|
||||
d.mobile AS driver_mobile
|
||||
FROM
|
||||
blade_transport_cert_record r
|
||||
LEFT JOIN blade_transport_driver d
|
||||
ON d.is_deleted = 0
|
||||
AND d.id = CASE
|
||||
WHEN r.biz_type = 'driver' THEN r.biz_id
|
||||
ELSE (
|
||||
SELECT dr.id
|
||||
FROM blade_transport_driver dr
|
||||
WHERE dr.is_deleted = 0
|
||||
AND dr.user_id = r.submit_user
|
||||
ORDER BY dr.update_time DESC, dr.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
END
|
||||
WHERE
|
||||
r.is_deleted = 0
|
||||
AND r.biz_type = #{bizType}
|
||||
AND r.status = #{status}
|
||||
<if test="bizKey != null and bizKey != ''">
|
||||
AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%')
|
||||
</if>
|
||||
<!--
|
||||
姓名同时匹配「档案当前姓名」与「提交时的姓名」:
|
||||
司机改名后,按新名与按旧名都应该搜得到他的记录,
|
||||
只认一个会让审核员两头试。手机号则只有档案当前值一个来源。
|
||||
-->
|
||||
<if test="driverName != null and driverName != ''">
|
||||
AND (
|
||||
d.driver_name LIKE CONCAT('%', #{driverName}, '%')
|
||||
OR r.biz_key LIKE CONCAT('%', #{driverName}, '%')
|
||||
)
|
||||
</if>
|
||||
<if test="driverMobile != null and driverMobile != ''">
|
||||
AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%')
|
||||
</if>
|
||||
ORDER BY
|
||||
r.submit_time DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+2
@@ -48,6 +48,7 @@
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="certification_status" property="certificationStatus"/>
|
||||
<result column="certification_reject_reason" property="certificationRejectReason"/>
|
||||
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="BaseColumn">
|
||||
@@ -96,6 +97,7 @@
|
||||
remark
|
||||
, certification_status
|
||||
, certification_reject_reason
|
||||
, effective_cert_record_id
|
||||
</sql>
|
||||
|
||||
<sql id="ExpiryWithin30Condition">
|
||||
|
||||
+1
-14
@@ -105,7 +105,7 @@ public class MkProcessMessageService {
|
||||
bizType, processId, loginName, JSON.toJSONString(processInfo));
|
||||
if (isProcessFinished(processInfo)) {
|
||||
applyProcessResult(handler, bizType, bizIdText, processId, formValues, true);
|
||||
} else if (isProcessRejected(processInfo)) {
|
||||
} else if (MkProcessNodeHelper.isProcessRejected(processInfo)) {
|
||||
applyProcessResult(handler, bizType, bizIdText, processId, formValues, false);
|
||||
} else if (StringUtil.isNotBlank(bizIdText)) {
|
||||
try {
|
||||
@@ -123,19 +123,6 @@ public class MkProcessMessageService {
|
||||
return "30".equals(firstText(asMap(processInfo), "fdProcessStatus"));
|
||||
}
|
||||
|
||||
private static boolean isProcessRejected(Object processInfo) {
|
||||
Map<String, Object> info = asMap(processInfo);
|
||||
if (!"20".equals(firstText(info, "fdProcessStatus"))) {
|
||||
return false;
|
||||
}
|
||||
Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos");
|
||||
if (!(historyHandlerInfos instanceof List<?> historyList) || historyList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Object lastHandler = historyList.get(historyList.size() - 1);
|
||||
return "N6".equals(firstText(asMap(lastHandler), "nodeNumber"));
|
||||
}
|
||||
|
||||
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText,
|
||||
String processId, Map<String, Object> formValues, boolean approved) {
|
||||
String action = approved ? "审核通过" : "审核驳回";
|
||||
|
||||
+82
-2
@@ -11,20 +11,47 @@ import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 解析 MK 流程实例详情中的当前节点、当前处理人
|
||||
* 解析 MK 流程实例详情中的当前节点、当前处理人,以及流程是否驳回到起草节点。
|
||||
*/
|
||||
public final class MkProcessNodeHelper {
|
||||
|
||||
/**
|
||||
* 起草节点编号。审批节点编号随流程模板变化,不能用来判断驳回。
|
||||
*/
|
||||
private static final String DRAFT_NODE_NUMBER = "N2";
|
||||
|
||||
private static final List<String> CURRENT_NODE_KEYS = List.of("handlerInfos", "drafterInfos", "adminInfos");
|
||||
|
||||
private MkProcessNodeHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程未结束,且当前待办已经回到起草节点时,才视为审核驳回。
|
||||
* 中间审批节点通过后流程状态仍是 20,不能把历史节点编号当成驳回。
|
||||
*/
|
||||
public static boolean isProcessRejected(Object processInfo) {
|
||||
Map<String, Object> info = asMap(processInfo);
|
||||
String processStatus = text(info.get("fdProcessStatus"));
|
||||
if (!"11".equals(processStatus) && !"20".equals(processStatus)) {
|
||||
return false;
|
||||
}
|
||||
return hasActiveDraftNode(asMap(info.get("fdTaskInfo")));
|
||||
}
|
||||
|
||||
public static String currentProcessor(Object processInfo) {
|
||||
return joinDistinct(extractTexts(asMap(processInfo).get("currentHandlers"), "fdName", "name"), "、");
|
||||
}
|
||||
|
||||
public static String currentNode(Object processInfo) {
|
||||
Map<String, Object> taskInfo = asMap(asMap(processInfo).get("fdTaskInfo"));
|
||||
return joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、");
|
||||
List<String> nodeNames = new ArrayList<>();
|
||||
for (String key : CURRENT_NODE_KEYS) {
|
||||
nodeNames.addAll(extractActiveNodeNames(taskInfo.get(key)));
|
||||
}
|
||||
if (nodeNames.isEmpty()) {
|
||||
nodeNames.addAll(extractTexts(taskInfo.get("handlerInfos"), "nodeName"));
|
||||
}
|
||||
return joinDistinct(nodeNames, "、");
|
||||
}
|
||||
|
||||
public static String text(String value, String fallback) {
|
||||
@@ -52,6 +79,59 @@ public final class MkProcessNodeHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasActiveDraftNode(Map<String, Object> taskInfo) {
|
||||
for (String key : CURRENT_NODE_KEYS) {
|
||||
Object nodes = taskInfo.get(key);
|
||||
if (!(nodes instanceof Collection<?> collection)) {
|
||||
continue;
|
||||
}
|
||||
for (Object item : collection) {
|
||||
if (isActiveDraftNode(asMap(item))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isActiveDraftNode(Map<String, Object> node) {
|
||||
if (!"20".equals(text(node.get("status")))) {
|
||||
return false;
|
||||
}
|
||||
String nodeNumber = text(node.get("nodeNumber"));
|
||||
if (Func.isEmpty(nodeNumber)) {
|
||||
nodeNumber = text(node.get("nodeId"));
|
||||
}
|
||||
if (DRAFT_NODE_NUMBER.equals(nodeNumber)) {
|
||||
return true;
|
||||
}
|
||||
String activityType = text(node.get("activityType"));
|
||||
return "draft".equalsIgnoreCase(activityType) || "draftTask".equalsIgnoreCase(activityType);
|
||||
}
|
||||
|
||||
private static List<String> extractActiveNodeNames(Object listObj) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (!(listObj instanceof Collection<?> collection)) {
|
||||
return result;
|
||||
}
|
||||
for (Object item : collection) {
|
||||
Map<String, Object> node = asMap(item);
|
||||
if (!"20".equals(text(node.get("status")))) {
|
||||
continue;
|
||||
}
|
||||
String nodeName = text(node.get("nodeName"));
|
||||
if (Func.isNotEmpty(nodeName)) {
|
||||
result.add(nodeName);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
String text = Func.toStr(value, "").trim();
|
||||
return "null".equalsIgnoreCase(text) ? "" : text;
|
||||
}
|
||||
|
||||
private static List<String> extractTexts(Object listObj, String... keys) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (!(listObj instanceof Collection<?> collection) || keys == null) {
|
||||
|
||||
+4
@@ -36,6 +36,7 @@ import org.springblade.transport.ocr.config.BaiduOcrProperties;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
import org.springblade.transport.ocr.service.IBaiduOcrService;
|
||||
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
|
||||
import org.springblade.transport.support.OcrFieldSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
@@ -143,6 +144,9 @@ public class BaiduOcrServiceImpl implements IBaiduOcrService {
|
||||
response.setType(type.name());
|
||||
response.setSide(normalizedSide);
|
||||
response.setResult(objectMapper.convertValue(result, RESULT_TYPE));
|
||||
// 同时下发归一化字段:调用方不该去认识百度那套中文键名,
|
||||
// 且键名对不上时前端只会「静默什么都不填」,是最难查的一类问题
|
||||
response.setFields(OcrFieldSupport.extract(type, normalizedSide, result));
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
/**
|
||||
* 认证审核服务(管理端)
|
||||
* <p>
|
||||
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
|
||||
* 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ICertAuditService {
|
||||
|
||||
/**
|
||||
* 认证记录分页(按认证对象类型与审核状态)
|
||||
* <p>
|
||||
* 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传;
|
||||
* 不传时按待审核处理,兼容只想要待审队列的调用方。
|
||||
* <p>
|
||||
* 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值
|
||||
* (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。
|
||||
* 解析不到司机档案的记录,两个字段为空,不回落成提交时的值——
|
||||
* 那会让「同一个人的多条记录显示成不同的人」。
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
|
||||
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
|
||||
* @param driverMobile 司机手机号模糊匹配,可为空
|
||||
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中)
|
||||
* @param page 分页参数(由 Controller 用 Condition.getPage 构造)
|
||||
* @return 认证记录分页
|
||||
*/
|
||||
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
|
||||
Integer status, IPage<TransportCertRecordVO> page);
|
||||
|
||||
/**
|
||||
* 审核详情:同时返回待审快照与当前生效值,供前端做 diff
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @return 审核详情
|
||||
*/
|
||||
CertAuditDetailVO detail(Long recordId);
|
||||
|
||||
/**
|
||||
* 审核一条认证记录
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @param pass true 通过,false 驳回
|
||||
* @param rejectReason 驳回原因;驳回时必填,≤200 字
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean audit(Long recordId, boolean pass, String rejectReason);
|
||||
|
||||
}
|
||||
+8
@@ -64,4 +64,12 @@ public interface IContractManageService extends BaseService<ContractManage> {
|
||||
Map<String, Long> expireStats(ContractManageVO contractManage);
|
||||
List<ContractManageExcel> exportContractManage(ContractManageVO contractManage, String ids);
|
||||
|
||||
/**
|
||||
* 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水)
|
||||
*
|
||||
* @param organizationId 合同负责部门ID
|
||||
* @return 预览编号
|
||||
*/
|
||||
String nextContractNo(Long organizationId);
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
|
||||
@@ -44,4 +45,15 @@ public interface IDriverAppService {
|
||||
*/
|
||||
List<DriverVehicleCardVO> myVehicles();
|
||||
|
||||
/**
|
||||
* 当前登录用户对应的司机**实体**
|
||||
* <p>
|
||||
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
|
||||
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
|
||||
* 匹配不到再按 {@code user_id} 兜底。
|
||||
*
|
||||
* @return 司机实体;未登录或查不到时返回 null
|
||||
*/
|
||||
Driver currentDriverEntity();
|
||||
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 司机端认证服务(小程序)
|
||||
* <p>
|
||||
* 认证对象是「一事一议」的:司机的个人认证与每一辆车的车辆认证各自独立成立、
|
||||
* 独立审核、独立留痕。本服务只负责司机端能做什么,管理端审核在审核服务里。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IDriverCertService {
|
||||
|
||||
/**
|
||||
* 提交个人认证(全量:身份证 + 驾驶证 + 从业资格证,三证一起审)
|
||||
* <p>
|
||||
* 生成一条待审记录,并把本人前一条待审记录置为已作废。
|
||||
* **不修改生效快照**:已认证的司机在本次审核通过前仍按旧生效值认定。
|
||||
*
|
||||
* @param request 个人认证内容
|
||||
* @return 新生成的认证记录主键
|
||||
*/
|
||||
Long submitPersonCert(PersonCertSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 读取本人当前生效的个人认证内容(供个人认证卡片展示)
|
||||
*
|
||||
* @return 生效快照内容;从未认证通过时返回 null
|
||||
*/
|
||||
PersonCertSubmitRequest getEffectivePersonCert();
|
||||
|
||||
/**
|
||||
* 提交车辆认证(新增或重新认证)
|
||||
* <p>
|
||||
* 生成一条待审记录,并把该车前一条待审记录置为已作废。
|
||||
* **不修改生效快照**:已认证的车辆在本次审核通过前仍按旧生效值认定,
|
||||
* 因此提交审核不会影响车辆正常接单。
|
||||
*
|
||||
* @param request 车辆认证内容
|
||||
* @return 新生成的认证记录主键
|
||||
*/
|
||||
Long submitVehicleCert(VehicleCertSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 删除车辆认证(按逻辑删除处理)
|
||||
* <p>
|
||||
* 只把车辆标记为已删除,**认证记录一律保留**——记录是不可变快照,
|
||||
* 也是「曾通过即永久可用」这一判定的依据,物理删除会让历史无从追溯。
|
||||
* 只能删除本人绑定的车辆,越权拒绝。
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean deleteVehicleCert(Long vehicleId);
|
||||
|
||||
/**
|
||||
* 读取某辆车当前生效的认证内容
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
* @return 生效快照内容;从未认证通过时返回 null
|
||||
*/
|
||||
VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId);
|
||||
|
||||
/**
|
||||
* 查询某认证对象的认证记录列表(按提交时间倒序)
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 认证记录列表
|
||||
*/
|
||||
List<TransportCertRecordVO> listRecords(String bizType, Long bizId);
|
||||
|
||||
/**
|
||||
* 查询单条认证记录详情
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @return 认证记录;不存在或无权查看时抛业务异常
|
||||
*/
|
||||
TransportCertRecordVO getRecord(Long recordId);
|
||||
|
||||
}
|
||||
+38
@@ -30,6 +30,9 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 司机端运单服务(小程序首页 / 列表)
|
||||
@@ -68,6 +71,19 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
DriverWaybillCardVO detail(Long id);
|
||||
|
||||
/**
|
||||
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
|
||||
* <p>
|
||||
* 起止日期优先级:
|
||||
* <ol>
|
||||
* <li>有打卡且多于 1 条:首条打卡日 → 末条打卡日</li>
|
||||
* <li>有打卡且仅 1 条:首条打卡日 → 今天</li>
|
||||
* <li>无打卡有计划开始日:计划开始日 → 今天</li>
|
||||
* <li>无打卡无计划:3 天前 → 今天</li>
|
||||
* </ol>
|
||||
*/
|
||||
WaybillTrackVO track(Long id);
|
||||
|
||||
/**
|
||||
* 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。
|
||||
* 供调度端 manage/detail 复用。
|
||||
@@ -99,4 +115,26 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
boolean complete(Long id);
|
||||
|
||||
/**
|
||||
* 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。
|
||||
* <p>
|
||||
* 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。
|
||||
*/
|
||||
Map<String, Object> findPunchNode(Long waybillId, String nodeCode);
|
||||
|
||||
/**
|
||||
* 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。
|
||||
* <p>
|
||||
* 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。
|
||||
*/
|
||||
boolean isNodePunched(Long waybillId, String nodeCode);
|
||||
|
||||
/**
|
||||
* 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。
|
||||
* <p>
|
||||
* 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案,
|
||||
* 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。
|
||||
*/
|
||||
void assertCurrentDriverOwnsWaybill(Long waybillId);
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -25,6 +25,7 @@ package org.springblade.transport.service;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
|
||||
@@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
|
||||
*/
|
||||
ExceptionDisposalVO submitReport(ExceptionDisposal request);
|
||||
|
||||
/**
|
||||
* 编辑异常处置的上报内容。不改动处置状态,并按字段变更追加一条跟进记录
|
||||
*/
|
||||
void update(ExceptionDisposalUpdateRequest request);
|
||||
|
||||
void follow(ExceptionDisposalFollowRequest request);
|
||||
|
||||
void complete(Long id);
|
||||
|
||||
+62
@@ -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.service;
|
||||
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证记录 服务类
|
||||
* <p>
|
||||
* 只承载认证记录本身的读写与数据模型约束的守护;提交、审核、可用性门槛等业务链路
|
||||
* 由各自的业务服务实现。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ITransportCertRecordService extends BaseService<TransportCertRecord> {
|
||||
|
||||
/**
|
||||
* 某认证对象的认证记录列表,按提交时间倒序
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 认证记录列表
|
||||
*/
|
||||
List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId);
|
||||
|
||||
/**
|
||||
* 取某认证对象当前的待审快照(至多一条)
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 待审快照,无则返回 null
|
||||
*/
|
||||
TransportCertRecord selectPendingRecord(String bizType, Long bizId);
|
||||
|
||||
}
|
||||
+11
@@ -33,6 +33,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillVO;
|
||||
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
|
||||
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -59,6 +60,16 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
*/
|
||||
WaybillPunchRecordsVO listPunchRecords(Long waybillId);
|
||||
|
||||
/**
|
||||
* 管理端:编辑时间轴上的单条打卡记录(含打卡状态)
|
||||
* <p>
|
||||
* punched=false 时清除该节点(在途节点为该运单全部在途流水)的打卡流水。
|
||||
*
|
||||
* @param request 打卡记录编辑请求
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean savePunchRecord(WaybillPunchRecordSaveRequest request);
|
||||
|
||||
/**
|
||||
* 运单车辆实时定位(按运单绑定车牌调用 LBS)
|
||||
*
|
||||
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* 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.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.TransportCertRecordMapper;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.ICertAuditService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationRuleSupport;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 认证审核服务实现
|
||||
* <p>
|
||||
* 审核作用于一条记录,两条路径的差别在于是否切换生效快照:
|
||||
* <ul>
|
||||
* <li><b>通过</b>:记录转已通过,并把该对象的生效快照指针切到它;</li>
|
||||
* <li><b>驳回</b>:记录转已驳回并留原因,生效快照指针一个字都不动。</li>
|
||||
* </ul>
|
||||
* 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CertAuditServiceImpl implements ICertAuditService {
|
||||
|
||||
private final ITransportCertRecordService certRecordService;
|
||||
private final TransportCertRecordMapper certRecordMapper;
|
||||
private final ITransportVehicleService transportVehicleService;
|
||||
private final IDriverService driverService;
|
||||
|
||||
@Override
|
||||
public IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
|
||||
Integer status, IPage<TransportCertRecordVO> page) {
|
||||
if (!CertificationRuleSupport.isValidBizType(bizType)) {
|
||||
throw new ServiceException("认证对象类型不正确");
|
||||
}
|
||||
// 未传状态时按「审核中」处理:审核页默认停在待审页签,
|
||||
// 只关心待审队列的调用方也不必额外传参
|
||||
Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status;
|
||||
|
||||
// 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成:
|
||||
// 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。
|
||||
List<TransportCertRecordVO> records = certRecordMapper.selectAuditPage(page, bizType, queryStatus,
|
||||
blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile));
|
||||
TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build();
|
||||
records.forEach(wrapper::fillDisplayFields);
|
||||
return page.setRecords(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索项的空串与纯空白一律当作「没填」
|
||||
* <p>
|
||||
* 否则 {@code " "} 会变成 {@code LIKE '% %'},把「没输入」当成一个真实筛选条件。
|
||||
*
|
||||
* @param value 原始搜索值
|
||||
* @return 去空白后的值;为空时返回 null
|
||||
*/
|
||||
private String blankToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CertAuditDetailVO detail(Long recordId) {
|
||||
TransportCertRecord record = requireRecord(recordId);
|
||||
Long effectiveId = effectiveRecordIdOf(record.getBizType(), record.getBizId());
|
||||
|
||||
CertAuditDetailVO detail = new CertAuditDetailVO();
|
||||
detail.setId(record.getId());
|
||||
detail.setBizType(record.getBizType());
|
||||
detail.setBizId(record.getBizId());
|
||||
detail.setBizKey(record.getBizKey());
|
||||
detail.setStatus(record.getStatus());
|
||||
detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus()));
|
||||
detail.setSubmitTime(record.getSubmitTime());
|
||||
detail.setSubmitUser(record.getSubmitUser());
|
||||
// 提交人来自小程序用户;审核人来自后台系统用户
|
||||
detail.setSubmitUserName(UserDisplayCache.getUserRealName(record.getSubmitUser()));
|
||||
detail.setAuditUser(record.getAuditUser());
|
||||
detail.setAuditTime(record.getAuditTime());
|
||||
detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser()));
|
||||
detail.setPendingSnapshotJson(record.getSnapshotJson());
|
||||
|
||||
// 生效值一侧:从未认证通过时为 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());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean audit(Long recordId, boolean pass, String rejectReason) {
|
||||
TransportCertRecord record = requireRecord(recordId);
|
||||
|
||||
// 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」,
|
||||
// 生效值就凭空消失了——这正是必须挡住的原因。
|
||||
if (!CertificationRuleSupport.isAuditable(record)) {
|
||||
throw new ServiceException("该认证记录已是"
|
||||
+ CertificationRuleSupport.statusText(record.getStatus()) + "状态,不可再次审核");
|
||||
}
|
||||
if (!CertificationRuleSupport.isRejectReasonValid(pass, rejectReason)) {
|
||||
throw new ServiceException("请输入认证驳回原因(不超过"
|
||||
+ TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)");
|
||||
}
|
||||
|
||||
Long auditor = AuthUtil.getUserId();
|
||||
LocalDateTime auditTime = LocalDateTime.now();
|
||||
|
||||
// 1) 记录本身:状态 + 审核留痕(审核人、审核时间写在这条记录上,不依赖会被覆盖的审计字段)
|
||||
TransportCertRecord patch = new TransportCertRecord();
|
||||
patch.setId(record.getId());
|
||||
patch.setStatus(pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED);
|
||||
patch.setRejectReason(pass
|
||||
? CertificationRuleSupport.reasonForApprove()
|
||||
: CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
patch.setAuditUser(auditor);
|
||||
patch.setAuditTime(auditTime);
|
||||
certRecordService.updateById(patch);
|
||||
|
||||
// 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE,
|
||||
// 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。
|
||||
updateCertTarget(record, pass, rejectReason);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录
|
||||
* <p>
|
||||
* 只发**一次** UPDATE:
|
||||
* <ul>
|
||||
* <li>通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);</li>
|
||||
* <li>驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。</li>
|
||||
* </ul>
|
||||
* 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
|
||||
*
|
||||
* @param record 被审核的记录
|
||||
* @param pass 是否通过
|
||||
* @param rejectReason 驳回原因
|
||||
*/
|
||||
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
|
||||
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
|
||||
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
|
||||
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
|
||||
Long newEffectiveId = pass ? record.getId() : null;
|
||||
|
||||
if (vehicleTarget) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(record.getBizId());
|
||||
vehicle.setCertificationStatus(status);
|
||||
if (pass) {
|
||||
vehicle.setEffectiveCertRecordId(newEffectiveId);
|
||||
// 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。
|
||||
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
|
||||
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
|
||||
// 与指针合并成同一次 UPDATE。
|
||||
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
|
||||
} else {
|
||||
vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
}
|
||||
requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId());
|
||||
if (pass) {
|
||||
clearVehicleRejectReason(record.getBizId());
|
||||
}
|
||||
return;
|
||||
}
|
||||
Driver driver = new Driver();
|
||||
driver.setId(record.getBizId());
|
||||
driver.setCertificationStatus(status);
|
||||
// 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS,
|
||||
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
|
||||
// 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。
|
||||
copyDrivingVehicle(record.getBizId(), driver);
|
||||
if (pass) {
|
||||
driver.setEffectiveCertRecordId(newEffectiveId);
|
||||
// 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。
|
||||
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
|
||||
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
|
||||
// 与指针合并成同一次 UPDATE。
|
||||
applySnapshotToDriver(driver, record.getSnapshotJson());
|
||||
} else {
|
||||
driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
}
|
||||
requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId());
|
||||
if (pass) {
|
||||
clearDriverRejectReason(record.getBizId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空车辆档案上的驳回原因展示缓存
|
||||
* <p>
|
||||
* 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略
|
||||
* 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL,
|
||||
* 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。
|
||||
* 它不碰生效快照指针。
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
*/
|
||||
private void clearVehicleRejectReason(Long vehicleId) {
|
||||
transportVehicleService.update(Wrappers.<TransportVehicle>lambdaUpdate()
|
||||
.eq(TransportVehicle::getId, vehicleId)
|
||||
.set(TransportVehicle::getCertificationRejectReason, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空司机档案上的驳回原因展示缓存
|
||||
* <p>
|
||||
* 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。
|
||||
* 它不碰生效快照指针,也不碰车辆绑定。
|
||||
*
|
||||
* @param driverId 司机主键
|
||||
*/
|
||||
private void clearDriverRejectReason(Long driverId) {
|
||||
driverService.update(Wrappers.<Driver>lambdaUpdate()
|
||||
.eq(Driver::getId, driverId)
|
||||
.set(Driver::getCertificationRejectReason, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标对象的写入必须命中一行,否则整体失败
|
||||
* <p>
|
||||
* {@code updateById} 命中 0 行时**不抛异常、只返回 false**。原先丢弃这个返回值,
|
||||
* 于是「审核记录已通过、司机/车辆档案一个字没变」能静默成立:生效快照指针没写进去,
|
||||
* 对象下次接单仍被判未认证,而审核页显示的是已通过。此处让事务整体回滚,
|
||||
* 宁可报错也不留下状态与档案互相矛盾的数据。
|
||||
*
|
||||
* @param updated 更新结果
|
||||
* @param target 对象名称,用于提示
|
||||
* @param id 对象主键
|
||||
*/
|
||||
private void requireTargetUpdated(boolean updated, String target, Long id) {
|
||||
if (!updated) {
|
||||
throw new ServiceException("未能更新" + target + "档案(ID:" + id
|
||||
+ "),本次审核已回滚、未生效;请核对该对象是否存在、未被删除,且与认证记录属于同一租户");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空
|
||||
* <p>
|
||||
* {@code driving_vehicle} 上标了 {@code FieldStrategy.ALWAYS},任何一次
|
||||
* {@code updateById} 都会把该列写成 patch 里的值(null 也照写)。
|
||||
* 而司机端的车辆列表是按这一列的车牌匹配出来的,一旦被清空,
|
||||
* 页面就变成「暂无绑定车辆」,且无法从界面上恢复。
|
||||
*
|
||||
* @param driverId 司机主键
|
||||
* @param patch 待更新的司机 patch(就地写入原值)
|
||||
*/
|
||||
private void copyDrivingVehicle(Long driverId, Driver patch) {
|
||||
Driver current = driverService.getById(driverId);
|
||||
if (current != null) {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
|
||||
* <p>
|
||||
* 搬**司机自己申报的全部实名内容**,姓名与身份证号也在其中:审核通过即认可这次申报,
|
||||
* 档案必须与生效值一致;否则司机端卡片显示档案里的名字、「重新认证」表单回填快照里的名字,
|
||||
* 同一个页面上出现两个名字。
|
||||
* <p>
|
||||
* 仍不碰 {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}:
|
||||
* 内部管理归属与派工关系由管理端维护,不该被司机的提交内容覆盖。
|
||||
* <p>
|
||||
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
|
||||
* 因此不会把管理端填过、司机端没填的字段清空。
|
||||
* <p>
|
||||
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
|
||||
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
|
||||
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
|
||||
* 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号,
|
||||
* 必须阻断。
|
||||
*
|
||||
* @param driver 待更新的司机 patch
|
||||
* @param snapshotJson 该条记录的全量提交内容
|
||||
*/
|
||||
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
|
||||
if (Func.isEmpty(snapshotJson)) {
|
||||
return;
|
||||
}
|
||||
PersonCertSubmitRequest snapshot;
|
||||
try {
|
||||
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
|
||||
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
|
||||
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
|
||||
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
|
||||
return;
|
||||
}
|
||||
// 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
|
||||
// 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
driver.setIdCardValidEnd(snapshot.getIdCardValidEnd());
|
||||
driver.setIdCardLongTerm(snapshot.getIdCardLongTerm());
|
||||
driver.setHeadPhoto(snapshot.getHeadPhoto());
|
||||
driver.setDrivingType(snapshot.getDriverClass());
|
||||
driver.setDrivingLicenseNo(snapshot.getDriverFileNo());
|
||||
driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart());
|
||||
driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd());
|
||||
driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm());
|
||||
driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl());
|
||||
driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl());
|
||||
driver.setQualificationType(snapshot.getQualificationType());
|
||||
driver.setQualificationNo(snapshot.getQualificationNo());
|
||||
driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson));
|
||||
driver.setQualificationBack(snapshot.getQualificationBack());
|
||||
driver.setQualificationEndDate(snapshot.getQualificationValidEnd());
|
||||
driver.setQualificationLongTerm(snapshot.getQualificationLongTerm());
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份证号不得落在另一名司机名下
|
||||
* <p>
|
||||
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
|
||||
* 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
|
||||
* 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
|
||||
* 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
|
||||
* <p>
|
||||
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
|
||||
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
|
||||
*
|
||||
* @param idCardNo 快照里的身份证号
|
||||
* @param driverId 本次要写入的司机主键
|
||||
*/
|
||||
private void requireIdCardNotTaken(String idCardNo, Long driverId) {
|
||||
if (Func.isEmpty(idCardNo)) {
|
||||
return;
|
||||
}
|
||||
Driver owner = driverService.getOne(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getIdCardNo, idCardNo)
|
||||
.last("LIMIT 1"));
|
||||
if (owner != null && !Objects.equals(owner.getId(), driverId)) {
|
||||
throw new ServiceException("身份证号 " + idCardNo + " 已属于另一名司机档案(ID:" + owner.getId()
|
||||
+ "),本次审核已回滚、未生效;请先处理档案归属");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取从业资格证封面页地址,兼容改名前的旧键
|
||||
* <p>
|
||||
* 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致,
|
||||
* 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键,
|
||||
* 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。
|
||||
*
|
||||
* @param snapshot 已解析的快照(可能因旧键而为 null)
|
||||
* @param snapshotJson 原始快照 JSON,用于回退读取旧键
|
||||
* @return 封面页地址;新旧键都没有时为 null
|
||||
*/
|
||||
private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) {
|
||||
if (Func.isNotEmpty(snapshot.getQualificationFront())) {
|
||||
return snapshot.getQualificationFront();
|
||||
}
|
||||
try {
|
||||
return JSON.parseObject(snapshotJson).getString("qualificationUrl");
|
||||
} catch (Exception exception) {
|
||||
// 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象)
|
||||
* <p>
|
||||
* 只搬**证件内容**,不碰两类字段:
|
||||
* <ul>
|
||||
* <li>{@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;</li>
|
||||
* <li>{@code organizationName} / {@code useDepartment}:内部管理归属,
|
||||
* 由管理端维护,不该被司机的提交内容覆盖。</li>
|
||||
* </ul>
|
||||
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
|
||||
* 因此不会把管理端填过、司机端没填的字段清空。
|
||||
*
|
||||
* @param vehicle 待更新的车辆 patch
|
||||
* @param snapshotJson 该条记录的全量提交内容
|
||||
*/
|
||||
private void applySnapshotToVehicle(TransportVehicle vehicle, String snapshotJson) {
|
||||
if (Func.isEmpty(snapshotJson)) {
|
||||
throw new ServiceException("该认证记录没有提交内容,无法写入车辆档案");
|
||||
}
|
||||
VehicleCertSubmitRequest snapshot;
|
||||
try {
|
||||
snapshot = JSON.parseObject(snapshotJson, VehicleCertSubmitRequest.class);
|
||||
} catch (Exception exception) {
|
||||
throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案");
|
||||
}
|
||||
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(早期格式的历史快照就是这种情况),
|
||||
// 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。
|
||||
if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) {
|
||||
throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照),"
|
||||
+ "未写入车辆档案;请重新提交一次认证");
|
||||
}
|
||||
vehicle.setOwnerName(snapshot.getOwnerName());
|
||||
vehicle.setPlateColor(snapshot.getPlateColor());
|
||||
vehicle.setVehicleType(snapshot.getVehicleType());
|
||||
vehicle.setVin(snapshot.getVin());
|
||||
vehicle.setOuterLength(snapshot.getOuterLength());
|
||||
vehicle.setOuterWidth(snapshot.getOuterWidth());
|
||||
vehicle.setOuterHeight(snapshot.getOuterHeight());
|
||||
vehicle.setApprovedLoadKg(snapshot.getApprovedLoadKg());
|
||||
vehicle.setTractionMassKg(snapshot.getTractionMassKg());
|
||||
vehicle.setEnergyType(snapshot.getEnergyType());
|
||||
vehicle.setBusinessRelation(snapshot.getBusinessRelation());
|
||||
vehicle.setCompulsoryScrapDate(snapshot.getCompulsoryScrapDate());
|
||||
vehicle.setCompulsoryScrapLongTerm(snapshot.getCompulsoryScrapLongTerm());
|
||||
vehicle.setDrivingLicenseNo(snapshot.getDrivingLicenseNo());
|
||||
vehicle.setDrivingLicenseImage(snapshot.getDrivingLicenseImage());
|
||||
vehicle.setDrivingLicenseMainBack(snapshot.getDrivingLicenseMainBack());
|
||||
vehicle.setDrivingLicenseViceFront(snapshot.getDrivingLicenseViceFront());
|
||||
vehicle.setDrivingLicenseViceBack(snapshot.getDrivingLicenseViceBack());
|
||||
vehicle.setDrivingLicenseStartDate(snapshot.getDrivingLicenseStartDate());
|
||||
vehicle.setDrivingLicenseEndDate(snapshot.getDrivingLicenseEndDate());
|
||||
vehicle.setDrivingLicenseLongTerm(snapshot.getDrivingLicenseLongTerm());
|
||||
vehicle.setRoadTransportCertNo(snapshot.getRoadTransportCertNo());
|
||||
vehicle.setRoadTransportCertImage(snapshot.getRoadTransportCertImage());
|
||||
vehicle.setRoadTransportCertStartDate(snapshot.getRoadTransportCertStartDate());
|
||||
vehicle.setRoadTransportCertEndDate(snapshot.getRoadTransportCertEndDate());
|
||||
vehicle.setRoadTransportCertLongTerm(snapshot.getRoadTransportCertLongTerm());
|
||||
vehicle.setRegistrationNo(snapshot.getRegistrationNo());
|
||||
vehicle.setRegistrationImage(snapshot.getRegistrationImage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该对象当前的生效快照指针
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
* @return 指针值;对象不存在或从未认证通过时返回 null
|
||||
*/
|
||||
private Long effectiveRecordIdOf(String bizType, Long bizId) {
|
||||
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType)) {
|
||||
TransportVehicle vehicle = transportVehicleService.getById(bizId);
|
||||
return vehicle == null ? null : vehicle.getEffectiveCertRecordId();
|
||||
}
|
||||
Driver driver = driverService.getById(bizId);
|
||||
return driver == null ? null : driver.getEffectiveCertRecordId();
|
||||
}
|
||||
|
||||
private TransportCertRecord requireRecord(Long recordId) {
|
||||
if (Func.isEmpty(recordId)) {
|
||||
throw new ServiceException("认证记录主键不能为空");
|
||||
}
|
||||
TransportCertRecord record = certRecordService.getById(recordId);
|
||||
if (record == null) {
|
||||
throw new ServiceException("认证记录不存在");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+69
-19
@@ -32,7 +32,9 @@ import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.transport.excel.ContractManageExcel;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
@@ -55,6 +57,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 合同管理 服务实现类
|
||||
@@ -190,6 +194,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setApprovalStatus(STATUS_REVIEWING);
|
||||
contractManage.setCurrentNode("正式合同审批");
|
||||
contractManage.setCurrentProcessor("待处理");
|
||||
// 正式合同提交时刷新申请日期为当天
|
||||
contractManage.setApplyDate(LocalDate.now());
|
||||
return updateById(contractManage);
|
||||
}
|
||||
|
||||
@@ -301,6 +307,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
source.setOriginalContractNo(TransportBusinessSupport.trimToNull(request.getOriginalContractNo()));
|
||||
source.setElectronicSealFlag(request.getElectronicSealFlag());
|
||||
normalizeOptionalIntegerFields(source);
|
||||
validateContractAmountRequired(source);
|
||||
validateAdditionalFields(source);
|
||||
source.setBillingPlanJson(request.getBillingPlanJson()); source.setSettlementRuleJson(request.getSettlementRuleJson()); source.setPreSettlementConfigJson(request.getPreSettlementConfigJson()); source.setFormalSettlementConfigJson(request.getFormalSettlementConfigJson()); source.setPaymentRatioJson(request.getPaymentRatioJson());
|
||||
source.setContractFileJson(request.getContractFileJson()); source.setAttachmentsJson(request.getAttachmentsJson());
|
||||
@@ -375,6 +382,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
target.setCurrentNode(null);
|
||||
target.setCurrentProcessor(null);
|
||||
target.setApprovedTime(null);
|
||||
target.setApplyDate(null);
|
||||
target.setChangeContent(null);
|
||||
target.setChangeReason(null);
|
||||
target.setChangeAttachmentsJson(null);
|
||||
@@ -493,13 +501,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|
||||
private void prepareCreateOrUpdate(ContractManage contractManage) {
|
||||
if (Func.isEmpty(contractManage.getId())) {
|
||||
if (Func.isEmpty(contractManage.getContractNo())) {
|
||||
contractManage.setContractNo("HT" + LocalDate.now().toString().replace("-", "") + String.format("%06d", count() + 1));
|
||||
}
|
||||
if (Func.isEmpty(contractManage.getOrganizationId())) {
|
||||
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
|
||||
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
|
||||
}
|
||||
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
|
||||
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
|
||||
if (Func.isEmpty(contractManage.getHandlerUserId())) {
|
||||
contractManage.setHandlerUserId(AuthUtil.getUserId());
|
||||
contractManage.setHandlerUserName(AuthUtil.getUserName());
|
||||
@@ -526,6 +533,48 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String nextContractNo(Long organizationId) {
|
||||
if (Func.isEmpty(organizationId)) {
|
||||
throw new ServiceException("请选择合同负责部门");
|
||||
}
|
||||
Dept dept = SysCache.getDept(organizationId);
|
||||
if (dept == null) {
|
||||
throw new ServiceException("合同负责部门不存在");
|
||||
}
|
||||
String prefix = TransportBusinessSupport.trimToNull(dept.getContractCodePrefix());
|
||||
if (Func.isEmpty(prefix)) {
|
||||
throw new ServiceException("请先在组织机构中配置该部门的合同编码前缀");
|
||||
}
|
||||
int year = LocalDate.now().getYear();
|
||||
String head = prefix + "(" + year + ")";
|
||||
List<ContractManage> latestList = list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.select(ContractManage::getContractNo)
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getOrganizationId, organizationId)
|
||||
.likeRight(ContractManage::getContractNo, head)
|
||||
.orderByDesc(ContractManage::getContractNo)
|
||||
.last("LIMIT 50"));
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
|
||||
int next = 1;
|
||||
for (ContractManage item : latestList) {
|
||||
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
|
||||
if (Func.isEmpty(contractNo)) {
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = pattern.matcher(contractNo);
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
next = Integer.parseInt(matcher.group(1)) + 1;
|
||||
break;
|
||||
}
|
||||
if (next > 999) {
|
||||
throw new ServiceException("当前部门本年合同流水号已达上限999");
|
||||
}
|
||||
return head + String.format("%03d", next) + "号";
|
||||
}
|
||||
|
||||
private void refreshArchiveStatusOnApprove(ContractManage contractManage) {
|
||||
if (hasSealArchiveFile(contractManage.getContractFileJson())) {
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED);
|
||||
@@ -595,7 +644,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
for (Map.Entry<String, Map<String, Object>> entry : originalApproved.entrySet()) {
|
||||
if (!requestKeys.contains(entry.getKey())) {
|
||||
throw new ServiceException("已审核通过的合同文件不允许删除");
|
||||
throw new ServiceException("已审核通过的用印材料不允许删除");
|
||||
}
|
||||
}
|
||||
return merged.isEmpty() ? null : JsonUtil.toJson(merged);
|
||||
@@ -670,8 +719,9 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
private void validateDraft(ContractManage contractManage) {
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractName(), "请输入合同名称");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类别");
|
||||
validateContractNameUnique(contractManage);
|
||||
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "备注不能超过2000字");
|
||||
// 合同名称允许重复,唯一性由合同编号保证。
|
||||
TransportBusinessSupport.validateLength(contractManage.getMainContent(), 1000, "合同主要内容不能超过1000字");
|
||||
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "其他事项说明不能超过2000字");
|
||||
}
|
||||
|
||||
private void validateBillingPlans(String billingPlanJson) {
|
||||
@@ -787,16 +837,6 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
private void validateContractNameUnique(ContractManage contractManage) {
|
||||
Long count = count(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getContractName, contractManage.getContractName())
|
||||
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
|
||||
if (count > 0) {
|
||||
throw new ServiceException("合同名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateContractPartyCombinationUnique(ContractManage contractManage) {
|
||||
if (Func.isEmpty(contractManage.getContractCategory())
|
||||
|| Func.isEmpty(contractManage.getPartyA())
|
||||
@@ -822,9 +862,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractName(), "请输入合同名称");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择所属组织");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请选择甲方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请选择乙方");
|
||||
validateContractAmountRequired(contractManage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,18 +896,26 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractName(), "请输入合同名称");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择所属组织");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getSettlementCurrency(), "请选择结算币种");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请输入甲方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请输入乙方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getStartDate() == null ? null : contractManage.getStartDate().toString(), "请选择合同期限");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getSignDate() == null ? null : contractManage.getSignDate().toString(), "请选择签订日期");
|
||||
TransportBusinessSupport.validateDateRange(contractManage.getStartDate(), contractManage.getEndDate(), "合同期限开始日期不能晚于结束日期");
|
||||
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "备注不能超过2000字");
|
||||
validateContractAmountRequired(contractManage);
|
||||
TransportBusinessSupport.validateLength(contractManage.getMainContent(), 1000, "合同主要内容不能超过1000字");
|
||||
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "其他事项说明不能超过2000字");
|
||||
validateBillingPlans(contractManage.getBillingPlanJson());
|
||||
validatePaymentRatios(contractManage.getPaymentRatioJson());
|
||||
}
|
||||
|
||||
private void validateContractAmountRequired(ContractManage contractManage) {
|
||||
if (contractManage.getContractAmount() == null) {
|
||||
throw new ServiceException("请输入合同金额");
|
||||
}
|
||||
}
|
||||
|
||||
private ContractManage loadExists(Long id) {
|
||||
ContractManage contractManage = getById(id);
|
||||
if (Func.isEmpty(contractManage) || Objects.equals(contractManage.getIsDeleted(), 1)) {
|
||||
@@ -979,6 +1028,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
data.put("templateFlag", contractManage.getTemplateFlag());
|
||||
data.put("originalContractNo", contractManage.getOriginalContractNo());
|
||||
data.put("electronicSealFlag", contractManage.getElectronicSealFlag());
|
||||
data.put("mainContent", contractManage.getMainContent());
|
||||
data.put("remark", contractManage.getRemark());
|
||||
data.put("feeGenerationMode", contractManage.getFeeGenerationMode());
|
||||
data.put("billingPlanJson", contractManage.getBillingPlanJson());
|
||||
|
||||
+2
-15
@@ -12,12 +12,12 @@ import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.FR;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.process.feign.IBusinessProcessClient;
|
||||
import org.springblade.transport.mk.MkProcessNodeHelper;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -97,7 +97,7 @@ public class CustomerArchivePublicProcessService {
|
||||
processId, loginName, JSON.toJSONString(processInfo));
|
||||
if (isProcessFinished(processInfo)) {
|
||||
applyProcessResult(customerIdText, processId, formValues, true);
|
||||
} else if (isProcessRejected(processInfo)) {
|
||||
} else if (MkProcessNodeHelper.isProcessRejected(processInfo)) {
|
||||
applyProcessResult(customerIdText, processId, formValues, false);
|
||||
} else if (StringUtil.isNotBlank(customerIdText)) {
|
||||
try {
|
||||
@@ -115,19 +115,6 @@ public class CustomerArchivePublicProcessService {
|
||||
return "30".equals(firstText(asMap(processInfo), "fdProcessStatus"));
|
||||
}
|
||||
|
||||
private static boolean isProcessRejected(Object processInfo) {
|
||||
Map<String, Object> info = asMap(processInfo);
|
||||
if (!"20".equals(firstText(info, "fdProcessStatus"))) {
|
||||
return false;
|
||||
}
|
||||
Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos");
|
||||
if (!(historyHandlerInfos instanceof List<?> historyList) || historyList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Object lastHandler = historyList.get(historyList.size() - 1);
|
||||
return "N6".equals(firstText(asMap(lastHandler), "nodeNumber"));
|
||||
}
|
||||
|
||||
private void applyProcessResult(String customerIdText, String processId, Map<String, Object> formValues,
|
||||
boolean approved) {
|
||||
String action = approved ? "审核通过" : "审核驳回";
|
||||
|
||||
+26
-12
@@ -34,6 +34,7 @@ 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.transport.mk.MkProcessNodeHelper;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
@@ -50,6 +51,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.excel.CustomerArchiveExcel;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
@@ -163,6 +165,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
* 审批状态:审批驳回
|
||||
*/
|
||||
private static final String APPROVAL_REJECTED = "rejected";
|
||||
/** 客商所属组织固定为顶层组织 */
|
||||
private static final String FIXED_OWNER_DEPT_NAME = "桂物物流集团";
|
||||
|
||||
/**
|
||||
* 金蝶协同状态:未推送(新增客商默认值,审批通过后参与推送)
|
||||
@@ -381,9 +385,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
log.warn("同步客商当前节点跳过,流程实例详情为空 id={}", id);
|
||||
return false;
|
||||
}
|
||||
String currentProcessor = joinDistinct(extractTexts(info.get("currentHandlers"), "fdName", "name"), "、");
|
||||
Map<String, Object> taskInfo = asStringObjectMap(info.get("fdTaskInfo"));
|
||||
String currentNode = joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、");
|
||||
String currentProcessor = MkProcessNodeHelper.currentProcessor(processInfo);
|
||||
String currentNode = MkProcessNodeHelper.currentNode(processInfo);
|
||||
if (Func.isEmpty(currentNode) && Func.isEmpty(currentProcessor)) {
|
||||
log.warn("同步客商当前节点跳过,未解析到节点名称或处理人 id={}", id);
|
||||
return false;
|
||||
@@ -919,15 +922,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
customer.setRegisteredAddress(buildRegisteredAddress(customer));
|
||||
customer.setLegalPerson(trimToEmpty(customer.getLegalPerson()));
|
||||
customer.setContactPhone(trimToEmpty(customer.getContactPhone()));
|
||||
customer.setDeptIds(trimToNull(customer.getDeptIds()));
|
||||
if (Func.isNotEmpty(customer.getDeptIds())) {
|
||||
List<Long> deptIdList = Func.toLongList(customer.getDeptIds());
|
||||
if (Func.isEmpty(deptIdList)) {
|
||||
throw new ServiceException("所属组织不正确");
|
||||
}
|
||||
customer.setDeptId(deptIdList.get(0));
|
||||
}
|
||||
customer.setDeptName(trimToEmpty(customer.getDeptName()));
|
||||
applyFixedOwnerDept(customer);
|
||||
customer.setAccessType(Func.isEmpty(customer.getAccessType()) ? ACCESS_TEMPORARY : customer.getAccessType());
|
||||
customer.setApprovalStatus(Func.isEmpty(customer.getApprovalStatus()) ? APPROVAL_DRAFT : customer.getApprovalStatus());
|
||||
customer.setKingdeeStatus(Func.isEmpty(customer.getKingdeeStatus()) ? KINGDEE_STATUS_NONE : customer.getKingdeeStatus());
|
||||
@@ -2166,6 +2161,25 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
return trimToNull(fallbackAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商所属组织固定为顶层「桂物物流集团」,忽略前端传入的其他组织。
|
||||
*/
|
||||
private void applyFixedOwnerDept(CustomerArchiveVO customer) {
|
||||
customer.setDeptName(FIXED_OWNER_DEPT_NAME);
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
String deptIds = SysCache.getDeptIds(tenantId, FIXED_OWNER_DEPT_NAME);
|
||||
if (Func.isEmpty(deptIds)) {
|
||||
throw new ServiceException("未找到所属组织「" + FIXED_OWNER_DEPT_NAME + "」,请先维护组织数据");
|
||||
}
|
||||
List<Long> deptIdList = Func.toLongList(deptIds);
|
||||
if (Func.isEmpty(deptIdList) || deptIdList.get(0) == null) {
|
||||
throw new ServiceException("所属组织「" + FIXED_OWNER_DEPT_NAME + "」不正确");
|
||||
}
|
||||
Long deptId = deptIdList.get(0);
|
||||
customer.setDeptId(deptId);
|
||||
customer.setDeptIds(String.valueOf(deptId));
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
+93
-73
@@ -22,14 +22,15 @@
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.feign.IUserClient;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
import org.springblade.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;
|
||||
@@ -37,16 +38,14 @@ import org.springblade.transport.pojo.vo.DriverVO;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.wrapper.DriverWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -58,7 +57,7 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
|
||||
private final IDriverService driverService;
|
||||
private final ITransportVehicleService transportVehicleService;
|
||||
private final IUserClient userClient;
|
||||
private final IMpUserClient mpUserClient;
|
||||
|
||||
@Override
|
||||
public DriverVO currentByPhone(String mobile) {
|
||||
@@ -72,22 +71,15 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> plates = splitPlates(driver.getDrivingVehicle());
|
||||
List<String> plates = VehiclePlateSupport.splitPlates(driver.getDrivingVehicle());
|
||||
if (plates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
// 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌)
|
||||
List<TransportVehicle> vehicles = transportVehicleService.list(Wrappers.<TransportVehicle>lambdaQuery()
|
||||
.and(w -> {
|
||||
w.in(TransportVehicle::getPlateNo, plates);
|
||||
for (String plate : plates) {
|
||||
w.or().apply(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}",
|
||||
plate
|
||||
);
|
||||
}
|
||||
})
|
||||
.orderByDesc(TransportVehicle::getUpdateTime));
|
||||
// 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异)
|
||||
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(plateQuery, plates);
|
||||
List<TransportVehicle> vehicles = transportVehicleService.list(
|
||||
plateQuery.orderByDesc(TransportVehicle::getUpdateTime));
|
||||
// 去重(精确与规范化可能命中同一条)
|
||||
Map<Long, TransportVehicle> uniq = new LinkedHashMap<>();
|
||||
for (TransportVehicle vehicle : vehicles) {
|
||||
@@ -98,6 +90,11 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
return uniq.values().stream().map(this::toCard).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Driver currentDriverEntity() {
|
||||
return currentDriver(null);
|
||||
}
|
||||
|
||||
private Driver currentDriver(String mobileHint) {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (userId == null || userId <= 0) {
|
||||
@@ -119,57 +116,66 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
return driver;
|
||||
}
|
||||
|
||||
private List<String> splitPlates(String drivingVehicle) {
|
||||
String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",")
|
||||
.replace(";", ",").replace("/", ",").replace("|", ",");
|
||||
Set<String> plates = new LinkedHashSet<>();
|
||||
for (String part : Func.toStrList(",", normalized)) {
|
||||
if (Func.isEmpty(part)) {
|
||||
continue;
|
||||
}
|
||||
String plate = normalizePlate(part);
|
||||
if (Func.isNotEmpty(plate)) {
|
||||
plates.add(plate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(plates);
|
||||
}
|
||||
|
||||
/** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */
|
||||
private String normalizePlate(String plateNo) {
|
||||
if (Func.isEmpty(plateNo)) {
|
||||
return "";
|
||||
}
|
||||
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
|
||||
DriverVehicleCardVO card = new DriverVehicleCardVO();
|
||||
card.setId(vehicle.getId());
|
||||
card.setPlateNo(vehicle.getPlateNo());
|
||||
card.setOwnerName(Func.toStr(vehicle.getOwnerName(), ""));
|
||||
card.setPlateColor(Func.toStr(vehicle.getPlateColor(), ""));
|
||||
card.setVehicleType(vehicle.getVehicleType());
|
||||
card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
|
||||
card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack()));
|
||||
card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
|
||||
card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
|
||||
card.setVin("");
|
||||
card.setEngineNo("");
|
||||
if (vehicle.getDrivingLicenseEndDate() != null) {
|
||||
card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString());
|
||||
} else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) {
|
||||
card.setLicenseValidEnd("长期");
|
||||
} else {
|
||||
card.setLicenseValidEnd("");
|
||||
}
|
||||
card.setVin(Func.toStr(vehicle.getVin(), ""));
|
||||
card.setOuterLength(vehicle.getOuterLength());
|
||||
card.setOuterWidth(vehicle.getOuterWidth());
|
||||
card.setOuterHeight(vehicle.getOuterHeight());
|
||||
card.setApprovedLoadKg(vehicle.getApprovedLoadKg());
|
||||
card.setTractionMassKg(vehicle.getTractionMassKg());
|
||||
card.setEnergyType(Func.toStr(vehicle.getEnergyType(), ""));
|
||||
card.setBusinessRelation(Func.toStr(vehicle.getBusinessRelation(), ""));
|
||||
card.setCompulsoryScrapDate(dateText(vehicle.getCompulsoryScrapDate(), vehicle.getCompulsoryScrapLongTerm()));
|
||||
card.setCompulsoryScrapLongTerm(vehicle.getCompulsoryScrapLongTerm());
|
||||
card.setDrivingLicenseNo(Func.toStr(vehicle.getDrivingLicenseNo(), ""));
|
||||
// 行驶证四张图各自独立,不再把「主页反面」与「副页正面」挤在同一个字段里:
|
||||
// 它们本来就是证件上不同的四面,混在一起会让「重新认证」回填出错的图。
|
||||
card.setDrivingLicenseImage(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
|
||||
card.setDrivingLicenseMainBack(Func.toStr(vehicle.getDrivingLicenseMainBack(), ""));
|
||||
card.setDrivingLicenseViceFront(Func.toStr(vehicle.getDrivingLicenseViceFront(), ""));
|
||||
card.setDrivingLicenseViceBack(Func.toStr(vehicle.getDrivingLicenseViceBack(), ""));
|
||||
card.setDrivingLicenseStartDate(dateText(vehicle.getDrivingLicenseStartDate(), null));
|
||||
card.setDrivingLicenseEndDate(dateText(vehicle.getDrivingLicenseEndDate(), vehicle.getDrivingLicenseLongTerm()));
|
||||
card.setDrivingLicenseLongTerm(vehicle.getDrivingLicenseLongTerm());
|
||||
card.setRoadTransportCertNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
|
||||
card.setRoadTransportCertImage(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
|
||||
card.setRoadTransportCertStartDate(dateText(vehicle.getRoadTransportCertStartDate(), null));
|
||||
card.setRoadTransportCertEndDate(dateText(vehicle.getRoadTransportCertEndDate(), vehicle.getRoadTransportCertLongTerm()));
|
||||
card.setRoadTransportCertLongTerm(vehicle.getRoadTransportCertLongTerm());
|
||||
card.setRegistrationNo(Func.toStr(vehicle.getRegistrationNo(), ""));
|
||||
card.setRegistrationImage(Func.toStr(vehicle.getRegistrationImage(), ""));
|
||||
card.setCertificationStatus(vehicle.getCertificationStatus());
|
||||
return card;
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String first, String second) {
|
||||
if (Func.isNotEmpty(first)) {
|
||||
return first;
|
||||
/**
|
||||
* 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字**
|
||||
* <p>
|
||||
* 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住:
|
||||
* <ul>
|
||||
* <li>长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达,
|
||||
* 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后
|
||||
* 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。</li>
|
||||
* <li>长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它),
|
||||
* 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」
|
||||
* 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param date 日期
|
||||
* @param longTerm 长期有效标志:1 表示长期
|
||||
* @return {@code yyyy-MM-dd} 或空串
|
||||
*/
|
||||
private String dateText(LocalDate date, Integer longTerm) {
|
||||
if (Integer.valueOf(1).equals(longTerm)) {
|
||||
return "";
|
||||
}
|
||||
return Func.toStr(second, "");
|
||||
return date == null ? "" : date.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
String selfPhone = resolveSelfPhone(userId);
|
||||
String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim();
|
||||
if (Func.isNotEmpty(hint)) {
|
||||
if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) {
|
||||
if (Func.isEmpty(selfPhone)) {
|
||||
// 解析不出本人号码时不能拿前端传的 mobile 去查:
|
||||
// currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。
|
||||
// 这里返回空让它退回按 user_id 查,宁可查不到也不越权。
|
||||
return null;
|
||||
}
|
||||
if (!selfPhone.equals(hint)) {
|
||||
throw new ServiceException("只能查询本人司机档案");
|
||||
}
|
||||
return hint;
|
||||
@@ -190,21 +202,29 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
|
||||
private String resolveSelfPhone(Long userId) {
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) {
|
||||
if (isMobileLike(account)) {
|
||||
return account.trim();
|
||||
}
|
||||
R<User> result = userClient.userInfoById(userId);
|
||||
if (result == null || !R.isSuccess(result) || result.getData() == null) {
|
||||
return Func.isEmpty(account) ? null : account.trim();
|
||||
}
|
||||
User user = result.getData();
|
||||
if (Func.isNotEmpty(user.getPhone())) {
|
||||
return user.getPhone().trim();
|
||||
}
|
||||
if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
|
||||
return user.getAccount().trim();
|
||||
// 小程序用户 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}$");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* 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.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverCertService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationRuleSupport;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 司机端认证服务实现
|
||||
* <p>
|
||||
* 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、
|
||||
* 把该对象前一条待审记录置为已作废、刷新展示缓存。
|
||||
* <p>
|
||||
* ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段,
|
||||
* 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里,
|
||||
* 在审核通过之前,该对象对外呈现的仍然是旧的生效值。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DriverCertServiceImpl implements IDriverCertService {
|
||||
|
||||
/**
|
||||
* 证件文本字段的长度上限:与车辆表列宽及管理端校验保持一致
|
||||
*/
|
||||
private static final int SHORT_TEXT_MAX_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* 所有人(行驶证登记的单位名称)长度上限
|
||||
*/
|
||||
private static final int OWNER_NAME_MAX_LENGTH = 100;
|
||||
|
||||
private final IDriverAppService driverAppService;
|
||||
private final IDriverService driverService;
|
||||
private final ITransportVehicleService transportVehicleService;
|
||||
private final ITransportCertRecordService transportCertRecordService;
|
||||
|
||||
/**
|
||||
* 司机端提交的车辆认证内容校验
|
||||
* <p>
|
||||
* 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。
|
||||
* 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条
|
||||
* 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}),
|
||||
* 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。
|
||||
*
|
||||
* @param request 提交内容
|
||||
*/
|
||||
private void validateCertContent(VehicleCertSubmitRequest request) {
|
||||
requireText(request.getPlateColor(), "车牌颜色不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getVehicleType(), "车辆类型不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getOwnerName(), "所有人不能为空", OWNER_NAME_MAX_LENGTH);
|
||||
requireText(request.getEnergyType(), "能源类型不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getBusinessRelation(), "业务关系不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getDrivingLicenseNo(), "行驶证档案编号不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getRoadTransportCertNo(), "道路运输证号不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
Optional.ofNullable(request.getVin())
|
||||
.filter(Func::isNotEmpty)
|
||||
.ifPresent(vin -> requireText(vin, "车架号格式不正确", 17));
|
||||
requireNonNegative(request.getApprovedLoadKg(), "核定载质量不能为空", "核定载质量不能小于0");
|
||||
requireNonNegative(request.getTractionMassKg(), "准牵引总质量不能为空", "准牵引总质量不能小于0");
|
||||
requireNonNegative(request.getOuterLength(), "外廓长度不能为空", "外廓长度不能小于0");
|
||||
requireNonNegative(request.getOuterWidth(), "外廓宽度不能为空", "外廓宽度不能小于0");
|
||||
requireNonNegative(request.getOuterHeight(), "外廓高度不能为空", "外廓高度不能小于0");
|
||||
requireDateRange(request.getDrivingLicenseStartDate(), request.getDrivingLicenseEndDate(),
|
||||
request.getDrivingLicenseLongTerm(), "请选择行驶证有效期", "行驶证有效期止不能早于有效期起");
|
||||
requireDateRange(request.getRoadTransportCertStartDate(), request.getRoadTransportCertEndDate(),
|
||||
request.getRoadTransportCertLongTerm(), "请选择道路运输证有效期", "道路运输证有效期止不能早于有效期起");
|
||||
if (Func.isEmpty(request.getCompulsoryScrapDate())
|
||||
&& !Integer.valueOf(1).equals(request.getCompulsoryScrapLongTerm())) {
|
||||
throw new ServiceException("请选择强制报废日期");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT
|
||||
* 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。
|
||||
* <p>
|
||||
* 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属),
|
||||
* 拿它拦住重新认证只会让司机无从下手。
|
||||
*
|
||||
* @param driver 当前司机
|
||||
*/
|
||||
private void requireOrganizationForNewVehicle(Driver driver) {
|
||||
if (Func.isEmpty(driver.getOrganizationName())) {
|
||||
throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本必填 + 长度校验
|
||||
*
|
||||
* @param value 待校验值
|
||||
* @param emptyText 为空时的提示
|
||||
* @param maxLength 长度上限
|
||||
*/
|
||||
private void requireText(String value, String emptyText, int maxLength) {
|
||||
if (Func.isEmpty(value)) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (value.trim().length() > maxLength) {
|
||||
throw new ServiceException(emptyText.replace("不能为空", "") + "不能超过" + maxLength + "字");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值必填 + 非负校验
|
||||
*
|
||||
* @param value 待校验值
|
||||
* @param emptyText 为空时的提示
|
||||
* @param negativeText 为负数时的提示
|
||||
*/
|
||||
private void requireNonNegative(Integer value, String emptyText, String negativeText) {
|
||||
if (value == null) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (value < 0) {
|
||||
throw new ServiceException(negativeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求
|
||||
* <p>
|
||||
* 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期),
|
||||
* 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。
|
||||
* 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。
|
||||
*
|
||||
* @param start 有效期起,可为空
|
||||
* @param end 有效期止
|
||||
* @param longTerm 长期有效标志:1 表示长期
|
||||
* @param emptyText 缺止期时的提示
|
||||
* @param rangeText 区间颠倒时的提示
|
||||
*/
|
||||
private void requireDateRange(LocalDate start, LocalDate end, Integer longTerm,
|
||||
String emptyText, String rangeText) {
|
||||
if (Integer.valueOf(1).equals(longTerm)) {
|
||||
return;
|
||||
}
|
||||
if (end == null) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (start != null && end.isBefore(start)) {
|
||||
throw new ServiceException(rangeText);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submitPersonCert(PersonCertSubmitRequest request) {
|
||||
if (Func.isEmpty(request)) {
|
||||
throw new ServiceException("认证内容不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
String snapshotJson = JSON.toJSONString(request);
|
||||
|
||||
// 先作废本人上一条待审,再落新记录:任何时刻同一对象至多一条待审。
|
||||
// 作废只动「待审」,已通过/已驳回都是终态,尤其不得碰生效快照指着的那条。
|
||||
supersedePending(TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId());
|
||||
|
||||
TransportCertRecord record = newRecord(
|
||||
TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId(),
|
||||
Func.toStr(driver.getDriverName(), ""), snapshotJson, driver.getUserId());
|
||||
transportCertRecordService.save(record);
|
||||
|
||||
// 展示缓存:让小程序卡片显示「审核中」。可用性判定不看这个字段。
|
||||
updateDriverDisplayCache(driver.getId(), TransportCertRecord.STATUS_PENDING, null);
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PersonCertSubmitRequest getEffectivePersonCert() {
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 当前司机实体已加载过,直接读它身上的指针,不再回查一次
|
||||
TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId());
|
||||
return effective == null
|
||||
? null
|
||||
: JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submitVehicleCert(VehicleCertSubmitRequest request) {
|
||||
if (Func.isEmpty(request)) {
|
||||
throw new ServiceException("认证内容不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」——
|
||||
// 后者原先一点校验都不做,内容全靠审核环节兜底
|
||||
validateCertContent(request);
|
||||
TransportVehicle vehicle = resolveBoundVehicle(driver, request);
|
||||
String snapshotJson = JSON.toJSONString(request);
|
||||
|
||||
supersedePending(TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId());
|
||||
|
||||
TransportCertRecord record = newRecord(
|
||||
TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId(),
|
||||
Func.toStr(vehicle.getPlateNo(), ""), snapshotJson, driver.getUserId());
|
||||
transportCertRecordService.save(record);
|
||||
|
||||
updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null);
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean deleteVehicleCert(Long vehicleId) {
|
||||
if (Func.isEmpty(vehicleId)) {
|
||||
throw new ServiceException("车辆主键不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
// 越权校验:与提交链路同一套绑定判定
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权删除该车辆的认证");
|
||||
}
|
||||
// 逻辑删除车辆;认证记录**不删**——它是不可变快照,且是「曾通过即永久可用」的依据
|
||||
return transportVehicleService.deleteLogic(Collections.singletonList(vehicleId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId) {
|
||||
if (Func.isEmpty(vehicleId)) {
|
||||
throw new ServiceException("车辆主键不能为空");
|
||||
}
|
||||
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权查看该车辆的认证信息");
|
||||
}
|
||||
TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId());
|
||||
return effective == null
|
||||
? null
|
||||
: JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TransportCertRecordVO> listRecords(String bizType, Long bizId) {
|
||||
requireValidBizType(bizType);
|
||||
requireReadableTarget(bizType, bizId);
|
||||
return transportCertRecordService.selectRecordList(bizType, bizId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecordVO getRecord(Long recordId) {
|
||||
TransportCertRecord record = transportCertRecordService.getById(recordId);
|
||||
if (record == null) {
|
||||
throw new ServiceException("认证记录不存在");
|
||||
}
|
||||
requireReadableTarget(record.getBizType(), record.getBizId());
|
||||
return TransportCertRecordWrapper.build().entityVO(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把该认证对象当前那条待审记录置为已作废
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
*/
|
||||
private void supersedePending(String bizType, Long bizId) {
|
||||
TransportCertRecord pending = transportCertRecordService.selectPendingRecord(bizType, bizId);
|
||||
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(pending);
|
||||
if (superseded != null) {
|
||||
transportCertRecordService.updateById(superseded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条待审记录
|
||||
* <p>
|
||||
* 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1,
|
||||
* 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。
|
||||
*
|
||||
* @param submitUser 提交人**用户ID**(blade_mp_user.id)——审核页要靠它翻译成姓名。
|
||||
* 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错,
|
||||
* 只会让「提交人」一栏显示成一串数字。
|
||||
*/
|
||||
private TransportCertRecord newRecord(String bizType, Long bizId, String bizKey,
|
||||
String snapshotJson, Long submitUser) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setBizType(bizType);
|
||||
record.setBizId(bizId);
|
||||
record.setBizKey(bizKey);
|
||||
record.setSnapshotJson(snapshotJson);
|
||||
record.setSubmitTime(LocalDateTime.now());
|
||||
record.setSubmitUser(submitUser);
|
||||
CertificationRuleSupport.prepareForSubmit(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按生效快照指针取那条记录
|
||||
* <p>
|
||||
* 只按指针查,不看记录表里任何一条的「最新状态」——这正是生效值与待审值分离的意义。
|
||||
*
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 生效快照记录;指针为空时返回 null
|
||||
*/
|
||||
private TransportCertRecord findEffectiveRecord(Long effectiveRecordId) {
|
||||
return Func.isEmpty(effectiveRecordId)
|
||||
? null
|
||||
: transportCertRecordService.getById(effectiveRecordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新司机侧的「最新提交状态」展示缓存
|
||||
* <p>
|
||||
* 只写展示缓存,**不碰生效快照指针**。
|
||||
*/
|
||||
private void updateDriverDisplayCache(Long driverId, Integer status, String rejectReason) {
|
||||
Driver patch = new Driver();
|
||||
patch.setId(driverId);
|
||||
patch.setCertificationStatus(status);
|
||||
patch.setCertificationRejectReason(rejectReason);
|
||||
// 必须带上 driving_vehicle 原值:该列标了 FieldStrategy.ALWAYS,
|
||||
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
|
||||
// 而司机端的车辆列表正是按这一列的车牌匹配出来的(清空即「暂无绑定车辆」)。
|
||||
Driver current = driverService.getById(driverId);
|
||||
if (current != null) {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
driverService.updateById(patch);
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
// 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null,
|
||||
// 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次
|
||||
driverService.update(Wrappers.<Driver>lambdaUpdate()
|
||||
.eq(Driver::getId, driverId)
|
||||
.set(Driver::getCertificationRejectReason, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新车辆侧的「最新提交状态」展示缓存
|
||||
* <p>
|
||||
* 只写展示缓存,**不碰生效快照指针**,也不覆盖车辆当前的证件字段。
|
||||
*/
|
||||
private void updateVehicleDisplayCache(Long vehicleId, Integer status, String rejectReason) {
|
||||
TransportVehicle patch = new TransportVehicle();
|
||||
patch.setId(vehicleId);
|
||||
patch.setCertificationStatus(status);
|
||||
patch.setCertificationRejectReason(rejectReason);
|
||||
transportVehicleService.updateById(patch);
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
// 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着
|
||||
transportVehicleService.update(Wrappers.<TransportVehicle>lambdaUpdate()
|
||||
.eq(TransportVehicle::getId, vehicleId)
|
||||
.set(TransportVehicle::getCertificationRejectReason, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本次提交指向的车辆,并校验它确实绑定在当前司机名下
|
||||
*
|
||||
* @param driver 当前司机
|
||||
* @param request 提交内容
|
||||
* @return 目标车辆
|
||||
*/
|
||||
private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) {
|
||||
// 按 id 定位:只能操作本人绑定的车,否则越权
|
||||
if (Func.isNotEmpty(request.getId())) {
|
||||
TransportVehicle vehicle = transportVehicleService.getById(request.getId());
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权操作该车辆的认证");
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
String normalized = VehiclePlateSupport.normalize(request.getPlateNo());
|
||||
if (Func.isEmpty(normalized)) {
|
||||
throw new ServiceException("车牌号不能为空");
|
||||
}
|
||||
|
||||
// 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。
|
||||
// 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定,
|
||||
// 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。
|
||||
TransportVehicle existing = findVehicleByAnyPlateForm(normalized);
|
||||
if (existing != null) {
|
||||
if (existing.getIsDeleted() != null && existing.getIsDeleted() != 0) {
|
||||
throw new ServiceException("该车牌已被删除,请联系管理员");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), existing.getPlateNo())) {
|
||||
// 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属
|
||||
throw new ServiceException("该车牌已归属其他司机,请联系管理员");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return createVehicleForDriver(driver, request, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按车牌(容忍写法差异)查已登记的车辆
|
||||
*
|
||||
* @param normalizedPlate 已规范化的车牌
|
||||
* @return 车辆;未登记返回 null
|
||||
*/
|
||||
private TransportVehicle findVehicleByAnyPlateForm(String normalizedPlate) {
|
||||
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(plateQuery, List.of(normalizedPlate));
|
||||
return transportVehicleService.list(plateQuery).stream()
|
||||
.filter(item -> normalizedPlate.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为司机新增一辆车并绑定到其名下
|
||||
* <p>
|
||||
* 只写入车辆的基本识别信息与本次提交的证件内容;车辆是否「已认证」仍由
|
||||
* 生效快照指针决定,故此处建档**不会**让一辆未审核通过的车变得可用。
|
||||
*
|
||||
* @param driver 当前司机
|
||||
* @param request 提交内容
|
||||
* @param normalizedPlate 已规范化的车牌
|
||||
* @return 新建的车辆
|
||||
*/
|
||||
private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request,
|
||||
String normalizedPlate) {
|
||||
// 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件
|
||||
requireOrganizationForNewVehicle(driver);
|
||||
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setPlateNo(normalizedPlate);
|
||||
// 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报
|
||||
// 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。
|
||||
// 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。
|
||||
vehicle.setOrganizationName(driver.getOrganizationName());
|
||||
vehicle.setOwnerName(request.getOwnerName());
|
||||
vehicle.setPlateColor(request.getPlateColor());
|
||||
vehicle.setVehicleType(request.getVehicleType());
|
||||
vehicle.setVin(request.getVin());
|
||||
vehicle.setOuterLength(request.getOuterLength());
|
||||
vehicle.setOuterWidth(request.getOuterWidth());
|
||||
vehicle.setOuterHeight(request.getOuterHeight());
|
||||
vehicle.setApprovedLoadKg(request.getApprovedLoadKg());
|
||||
vehicle.setTractionMassKg(request.getTractionMassKg());
|
||||
vehicle.setEnergyType(request.getEnergyType());
|
||||
vehicle.setBusinessRelation(request.getBusinessRelation());
|
||||
vehicle.setCompulsoryScrapDate(request.getCompulsoryScrapDate());
|
||||
vehicle.setCompulsoryScrapLongTerm(request.getCompulsoryScrapLongTerm());
|
||||
// 行驶证四张图与车辆表四列一一对应,不再把「主页反面」塞进语义含糊的「副页」字段
|
||||
vehicle.setDrivingLicenseNo(request.getDrivingLicenseNo());
|
||||
vehicle.setDrivingLicenseImage(request.getDrivingLicenseImage());
|
||||
vehicle.setDrivingLicenseMainBack(request.getDrivingLicenseMainBack());
|
||||
vehicle.setDrivingLicenseViceFront(request.getDrivingLicenseViceFront());
|
||||
vehicle.setDrivingLicenseViceBack(request.getDrivingLicenseViceBack());
|
||||
vehicle.setDrivingLicenseStartDate(request.getDrivingLicenseStartDate());
|
||||
vehicle.setDrivingLicenseEndDate(request.getDrivingLicenseEndDate());
|
||||
vehicle.setDrivingLicenseLongTerm(request.getDrivingLicenseLongTerm());
|
||||
vehicle.setRoadTransportCertNo(request.getRoadTransportCertNo());
|
||||
vehicle.setRoadTransportCertStartDate(request.getRoadTransportCertStartDate());
|
||||
vehicle.setRoadTransportCertEndDate(request.getRoadTransportCertEndDate());
|
||||
vehicle.setRoadTransportCertLongTerm(request.getRoadTransportCertLongTerm());
|
||||
vehicle.setRoadTransportCertImage(request.getRoadTransportCertImage());
|
||||
// 机动车登记编号该列同样 NOT NULL:司机端是选填,缺值写空串而不是 null
|
||||
vehicle.setRegistrationNo(Func.toStr(request.getRegistrationNo(), ""));
|
||||
vehicle.setRegistrationImage(request.getRegistrationImage());
|
||||
vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL);
|
||||
transportVehicleService.save(vehicle);
|
||||
|
||||
// 绑定到司机名下,否则本人后续仍无法操作这辆车
|
||||
String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle())
|
||||
? normalizedPlate
|
||||
: driver.getDrivingVehicle() + "," + normalizedPlate;
|
||||
// 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100),
|
||||
// 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车)
|
||||
VehiclePlateSupport.validateBoundVehicles(drivingVehicle);
|
||||
Driver patch = new Driver();
|
||||
patch.setId(driver.getId());
|
||||
patch.setDrivingVehicle(drivingVehicle);
|
||||
driverService.updateById(patch);
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前登录用户能否查看该认证对象的记录
|
||||
* <p>
|
||||
* 司机只能查自己的;车辆必须是本人绑定的。越权一律拒绝,不返回空列表
|
||||
* ——空列表会让调用方以为「没有记录」,而不是「无权查看」。
|
||||
*/
|
||||
private void requireReadableTarget(String bizType, Long bizId) {
|
||||
if (Func.isEmpty(bizId)) {
|
||||
throw new ServiceException("认证对象主键不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
if (TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)) {
|
||||
if (!bizId.equals(driver.getId())) {
|
||||
throw new ServiceException("无权查看其他司机的认证记录");
|
||||
}
|
||||
return;
|
||||
}
|
||||
TransportVehicle vehicle = transportVehicleService.getById(bizId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权查看该车辆的认证记录");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireValidBizType(String bizType) {
|
||||
if (!CertificationRuleSupport.isValidBizType(bizType)) {
|
||||
throw new ServiceException("认证对象类型不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private Driver requireCurrentDriver() {
|
||||
Driver driver = driverAppService.currentDriverEntity();
|
||||
if (driver == null) {
|
||||
throw new ServiceException("未找到当前登录用户对应的司机档案");
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
}
|
||||
+55
-73
@@ -36,20 +36,19 @@ import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.feign.ISysClient;
|
||||
import org.springblade.system.feign.IUserClient;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
import org.springblade.system.pojo.entity.UserInfo;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.system.pojo.enums.UserSex;
|
||||
import org.springblade.system.pojo.enums.UserType;
|
||||
import org.springblade.transport.excel.DriverExcel;
|
||||
import org.springblade.transport.mapper.DriverMapper;
|
||||
import org.springblade.transport.mapper.TransportVehicleMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -69,15 +68,14 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
private static final int NAME_MAX_LENGTH = 10;
|
||||
private static final int ID_CARD_MAX_LENGTH = 18;
|
||||
private static final int MOBILE_MAX_LENGTH = 20;
|
||||
private static final int VEHICLE_NO_MAX_LENGTH = 30;
|
||||
private static final int VEHICLE_NO_MAX_LENGTH = 100;
|
||||
private static final int SHORT_TEXT_MAX_LENGTH = 50;
|
||||
private static final int ADDRESS_MAX_LENGTH = 200;
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
private static final int DEFAULT_ENABLED_STATUS = 1;
|
||||
private static final int DEFAULT_FALSE = 0;
|
||||
|
||||
private final TransportVehicleMapper transportVehicleMapper;
|
||||
private final IUserClient userClient;
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final ISysClient sysClient;
|
||||
|
||||
@Override
|
||||
@@ -255,15 +253,11 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
validateLength(driver.getIdCardNo(), ID_CARD_MAX_LENGTH, "身份证号不能超过18字");
|
||||
validateLength(driver.getMobile(), MOBILE_MAX_LENGTH, "手机号不能超过20字");
|
||||
validateLength(driver.getEmergencyContactMobile(), MOBILE_MAX_LENGTH, "紧急联系人手机号不能超过20字");
|
||||
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过30字");
|
||||
if (Func.isNotEmpty(driver.getDrivingVehicle())) {
|
||||
Long vehicleCount = transportVehicleMapper.selectCount(Wrappers.<TransportVehicle>lambdaQuery()
|
||||
.eq(TransportVehicle::getIsDeleted, 0)
|
||||
.eq(TransportVehicle::getPlateNo, driver.getDrivingVehicle()));
|
||||
if (vehicleCount == null || vehicleCount <= 0) {
|
||||
throw new ServiceException("驾驶车辆不存在,请重新选择");
|
||||
}
|
||||
}
|
||||
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过100字");
|
||||
// 「驾驶车辆」是**逗号并列的多辆车**(小程序每绑一辆就追加一个车牌),
|
||||
// 不是一个车牌。此前这里拿整串去车牌表精确查,绑了两辆车的司机
|
||||
// 在司机管理页一保存就会撞上「驾驶车辆不存在」——校验口径必须与写入方一致。
|
||||
VehiclePlateSupport.validateBoundVehicles(driver.getDrivingVehicle());
|
||||
validateLength(driver.getDrivingLicenseNo(), SHORT_TEXT_MAX_LENGTH, "驾驶证档案编号不能超过50字");
|
||||
validateLength(driver.getQualificationNo(), SHORT_TEXT_MAX_LENGTH, "资格证号不能超过50字");
|
||||
validateLength(driver.getOrganizationName(), SHORT_TEXT_MAX_LENGTH, "所属组织不能超过50字");
|
||||
@@ -315,13 +309,13 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
tenantId = driver.getTenantId();
|
||||
}
|
||||
if (Func.isEmpty(tenantId)) {
|
||||
throw new ServiceException("租户信息为空,无法同步系统用户");
|
||||
throw new ServiceException("租户信息为空,无法同步小程序用户");
|
||||
}
|
||||
String mobile = driver.getMobile();
|
||||
if (Func.isEmpty(mobile) || mobile.length() < 6) {
|
||||
throw new ServiceException("手机号长度不足,无法同步系统用户");
|
||||
throw new ServiceException("手机号长度不足,无法同步小程序用户");
|
||||
}
|
||||
// 编辑时前端可能不传 userId,从库中补齐已关联用户
|
||||
// 编辑时前端可能不传 userId,从库中补齐已关联小程序用户
|
||||
if (Func.isEmpty(driver.getUserId()) && Func.isNotEmpty(driver.getId())) {
|
||||
Driver dbDriver = getById(driver.getId());
|
||||
if (dbDriver != null && Func.isNotEmpty(dbDriver.getUserId())) {
|
||||
@@ -329,95 +323,83 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
}
|
||||
}
|
||||
|
||||
R<String> roleResult = sysClient.getRoleIdByAlias(tenantId, "driver");
|
||||
if (roleResult == null || !R.isSuccess(roleResult) || Func.isEmpty(roleResult.getData())) {
|
||||
throw new ServiceException("未配置角色别名为driver的角色,请先在系统角色中维护");
|
||||
}
|
||||
String roleId = roleResult.getData();
|
||||
|
||||
// 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步
|
||||
String deptId = null;
|
||||
R<String> deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName());
|
||||
if (deptResult == null || !R.isSuccess(deptResult) || Func.isEmpty(deptResult.getData())) {
|
||||
throw new ServiceException("所属组织未匹配到系统部门,无法同步用户");
|
||||
if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) {
|
||||
deptId = deptResult.getData();
|
||||
}
|
||||
String deptId = deptResult.getData();
|
||||
|
||||
User existing = findExistingUser(driver, tenantId, mobile);
|
||||
MpUser existing = findExistingMpUser(driver, tenantId, mobile);
|
||||
if (existing == null || Func.isEmpty(existing.getId())) {
|
||||
// 司机未关联有效用户时新建;初始密码由系统用户保存逻辑按默认参数/强度规则填充
|
||||
createAndLinkDriverUser(driver, tenantId, mobile, roleId, deptId);
|
||||
createAndLinkDriverMpUser(driver, tenantId, mobile, deptId);
|
||||
} else {
|
||||
updateAndLinkDriverUser(driver, existing, tenantId, mobile, roleId, deptId);
|
||||
updateAndLinkDriverMpUser(driver, existing, tenantId, mobile, deptId);
|
||||
}
|
||||
}
|
||||
|
||||
private User findExistingUser(Driver driver, String tenantId, String mobile) {
|
||||
private MpUser findExistingMpUser(Driver driver, String tenantId, String mobile) {
|
||||
if (Func.isNotEmpty(driver.getUserId())) {
|
||||
R<User> byId = userClient.userInfoById(driver.getUserId());
|
||||
R<MpUser> byId = mpUserClient.userById(driver.getUserId());
|
||||
if (byId != null && R.isSuccess(byId) && byId.getData() != null && Func.isNotEmpty(byId.getData().getId())) {
|
||||
return byId.getData();
|
||||
}
|
||||
// 关联的用户已不存在,清空后按手机号重建
|
||||
// 关联的小程序用户已不存在,清空后按手机号重建
|
||||
driver.setUserId(null);
|
||||
}
|
||||
return findExistingUserByAccountOrPhone(tenantId, mobile);
|
||||
return findExistingMpUserByPhone(tenantId, mobile);
|
||||
}
|
||||
|
||||
private User findExistingUserByAccountOrPhone(String tenantId, String mobile) {
|
||||
R<User> byAccount = userClient.userByAccount(tenantId, mobile);
|
||||
if (byAccount != null && R.isSuccess(byAccount) && byAccount.getData() != null
|
||||
&& Func.isNotEmpty(byAccount.getData().getId())) {
|
||||
return byAccount.getData();
|
||||
}
|
||||
R<UserInfo> byPhone = userClient.userInfoByPhone(tenantId, mobile, UserType.WEB.getName());
|
||||
private MpUser findExistingMpUserByPhone(String tenantId, String mobile) {
|
||||
R<MpUser> byPhone = mpUserClient.userByPhone(tenantId, mobile);
|
||||
if (byPhone != null && R.isSuccess(byPhone) && byPhone.getData() != null
|
||||
&& byPhone.getData().getUser() != null && Func.isNotEmpty(byPhone.getData().getUser().getId())) {
|
||||
return byPhone.getData().getUser();
|
||||
&& Func.isNotEmpty(byPhone.getData().getId())) {
|
||||
return byPhone.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createAndLinkDriverUser(Driver driver, String tenantId, String mobile, String roleId, String deptId) {
|
||||
User user = buildSyncUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId);
|
||||
// 不传密码:交由 UserServiceImpl.saveUser 使用系统默认初始密码或自动生成合规密码
|
||||
user.setUserType(UserType.WEB.getCategory());
|
||||
private void createAndLinkDriverMpUser(Driver driver, String tenantId, String mobile, String deptId) {
|
||||
MpUser user = buildSyncMpUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(),
|
||||
deptId, MpUserIdentity.DRIVER.getCode());
|
||||
|
||||
R<Boolean> saveResult = null;
|
||||
R<Boolean> saveResult;
|
||||
try {
|
||||
saveResult = userClient.saveUser(user);
|
||||
saveResult = mpUserClient.saveUser(user);
|
||||
} catch (Exception ex) {
|
||||
User existed = findExistingUserByAccountOrPhone(tenantId, mobile);
|
||||
MpUser existed = findExistingMpUserByPhone(tenantId, mobile);
|
||||
if (existed != null) {
|
||||
updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId);
|
||||
updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId);
|
||||
return;
|
||||
}
|
||||
throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步系统用户失败");
|
||||
throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步小程序用户失败");
|
||||
}
|
||||
if (!isFeignSuccess(saveResult)) {
|
||||
User existed = findExistingUserByAccountOrPhone(tenantId, mobile);
|
||||
MpUser existed = findExistingMpUserByPhone(tenantId, mobile);
|
||||
if (existed != null) {
|
||||
updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId);
|
||||
updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId);
|
||||
return;
|
||||
}
|
||||
throw new ServiceException(resolveFeignError(saveResult, "同步系统用户失败"));
|
||||
throw new ServiceException(resolveFeignError(saveResult, "同步小程序用户失败"));
|
||||
}
|
||||
|
||||
R<User> created = userClient.userByAccount(tenantId, mobile);
|
||||
User createdUser = (created != null && R.isSuccess(created)) ? created.getData() : null;
|
||||
MpUser createdUser = findExistingMpUserByPhone(tenantId, mobile);
|
||||
if (createdUser == null || Func.isEmpty(createdUser.getId())) {
|
||||
createdUser = findExistingUserByAccountOrPhone(tenantId, mobile);
|
||||
}
|
||||
if (createdUser == null || Func.isEmpty(createdUser.getId())) {
|
||||
throw new ServiceException("系统用户创建成功但无法回查用户ID");
|
||||
throw new ServiceException("小程序用户创建成功但无法回查用户ID");
|
||||
}
|
||||
linkDriverUserId(driver, createdUser.getId());
|
||||
}
|
||||
|
||||
private void updateAndLinkDriverUser(Driver driver, User existing, String tenantId, String mobile, String roleId, String deptId) {
|
||||
User user = buildSyncUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId);
|
||||
R<Boolean> updateResult = userClient.updateUser(user);
|
||||
// updateById 无字段变化时可能返回 false,但业务上仍视为成功,以接口 code 为准
|
||||
private void updateAndLinkDriverMpUser(Driver driver, MpUser existing, String tenantId, String mobile, String deptId) {
|
||||
// 保留已有管理员等身份,确保包含司机身份
|
||||
String identities = MpUserIdentity.merge(existing.getIdentities(), MpUserIdentity.DRIVER.getCode());
|
||||
MpUser user = buildSyncMpUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(),
|
||||
deptId, identities);
|
||||
// 更新时不覆盖 openid
|
||||
user.setOpenid(null);
|
||||
R<Boolean> updateResult = mpUserClient.updateUser(user);
|
||||
if (!isFeignSuccess(updateResult)) {
|
||||
throw new ServiceException(resolveFeignError(updateResult, "同步更新系统用户失败"));
|
||||
throw new ServiceException(resolveFeignError(updateResult, "同步更新小程序用户失败"));
|
||||
}
|
||||
linkDriverUserId(driver, existing.getId());
|
||||
}
|
||||
@@ -438,9 +420,9 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
return msg;
|
||||
}
|
||||
|
||||
private User buildSyncUser(Long userId, String tenantId, String mobile, String driverName, String gender,
|
||||
String roleId, String deptId) {
|
||||
User user = new User();
|
||||
private MpUser buildSyncMpUser(Long userId, String tenantId, String mobile, String driverName, String gender,
|
||||
String deptId, String identities) {
|
||||
MpUser user = new MpUser();
|
||||
user.setId(userId);
|
||||
user.setTenantId(tenantId);
|
||||
user.setAccount(mobile);
|
||||
@@ -448,8 +430,8 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
user.setName(driverName);
|
||||
user.setRealName(driverName);
|
||||
user.setSex(UserSex.getCodeByName(gender));
|
||||
user.setRoleId(roleId);
|
||||
user.setDeptId(deptId);
|
||||
user.setIdentities(identities);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
+495
-46
@@ -31,12 +31,23 @@ import org.springblade.core.log.exception.ServiceException;
|
||||
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;
|
||||
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
@@ -48,6 +59,7 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
@@ -67,8 +79,8 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -107,11 +119,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd");
|
||||
private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
/** 被异常阻断时的不可打卡原因(司机端展示) */
|
||||
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverService driverService;
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final IProcessConfigService processConfigService;
|
||||
private final ITransportVehicleService transportVehicleService;
|
||||
private final CertificationGate certificationGate;
|
||||
private final WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private final WaybillNodePunchMapper nodePunchMapper;
|
||||
private final ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO currentTask() {
|
||||
@@ -225,6 +244,52 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return toCard(normalizeAcceptStatus(waybill), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WaybillTrackVO track(Long id) {
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
LocalDate[] range = resolveTrackDateRange(waybill);
|
||||
return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按打卡记录 / 计划时间推导轨迹查询起止日期
|
||||
*/
|
||||
private LocalDate[] resolveTrackDateRange(Waybill waybill) {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<LocalDate> punchDates = new ArrayList<>();
|
||||
nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybill.getId())
|
||||
.isNotNull(WaybillNodePunch::getPunchTime)
|
||||
.orderByAsc(WaybillNodePunch::getPunchTime)).forEach(punch -> {
|
||||
if (punch.getPunchTime() != null) {
|
||||
punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
|
||||
}
|
||||
});
|
||||
enroutePunchMapper.selectList(Wrappers.<WaybillEnroutePunch>lambdaQuery()
|
||||
.eq(WaybillEnroutePunch::getWaybillId, waybill.getId())
|
||||
.isNotNull(WaybillEnroutePunch::getPunchTime)
|
||||
.orderByAsc(WaybillEnroutePunch::getPunchTime)).forEach(punch -> {
|
||||
if (punch.getPunchTime() != null) {
|
||||
punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
|
||||
}
|
||||
});
|
||||
punchDates.sort(LocalDate::compareTo);
|
||||
if (punchDates.size() > 1) {
|
||||
return new LocalDate[]{punchDates.get(0), punchDates.get(punchDates.size() - 1)};
|
||||
}
|
||||
if (punchDates.size() == 1) {
|
||||
return new LocalDate[]{punchDates.get(0), today};
|
||||
}
|
||||
if (waybill.getEstimatedStartTime() != null) {
|
||||
return new LocalDate[]{waybill.getEstimatedStartTime(), today};
|
||||
}
|
||||
if (waybill.getStartDate() != null) {
|
||||
return new LocalDate[]{waybill.getStartDate(), today};
|
||||
}
|
||||
return new LocalDate[]{today.minusDays(3), today};
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO detailPunchSnapshot(Long id) {
|
||||
if (id == null) {
|
||||
@@ -247,16 +312,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId());
|
||||
WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin(
|
||||
resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
if (!decision.punchEnabled()) {
|
||||
throw new ServiceException("该运单未启用在途打卡");
|
||||
}
|
||||
if (decision.doneToday()) {
|
||||
throw new ServiceException("今日已完成在途打卡,不可重复打卡");
|
||||
}
|
||||
// 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束
|
||||
assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson));
|
||||
// 不做频次/时段门禁;定位按前端是否传参落库
|
||||
assertPunchPhoto(dto.getPhoto() == null ? null : List.of(dto.getPhoto()));
|
||||
|
||||
Date now = new Date();
|
||||
WaybillEnroutePunch punch = new WaybillEnroutePunch();
|
||||
@@ -292,7 +361,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
|
||||
Map<String, Object> nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Map<String, Object> nodeCfg = findPunchNodeConfig(processJson, nodeCode);
|
||||
if (nodeCfg == null) {
|
||||
throw new ServiceException("该节点未启用打卡或不存在");
|
||||
}
|
||||
@@ -303,6 +373,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (existed != null) {
|
||||
throw new ServiceException("该节点已打卡,不可重复打卡");
|
||||
}
|
||||
assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg));
|
||||
assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg);
|
||||
// photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表,
|
||||
// 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。
|
||||
boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty();
|
||||
List<Map<String, String>> photoItems = typedPhotoItems
|
||||
? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg))
|
||||
: legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"));
|
||||
assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems);
|
||||
|
||||
Date now = new Date();
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
@@ -321,24 +400,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim());
|
||||
}
|
||||
if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) {
|
||||
List<String> urls = dto.getPhotos().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
if (!urls.isEmpty()) {
|
||||
List<String> types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes");
|
||||
List<Map<String, String>> photoItems = new ArrayList<>();
|
||||
for (int i = 0; i < urls.size(); i++) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1));
|
||||
item.put("type", type);
|
||||
item.put("url", urls.get(i));
|
||||
photoItems.add(item);
|
||||
}
|
||||
punch.setPhotos(JsonUtil.toJson(photoItems));
|
||||
}
|
||||
if (!photoItems.isEmpty()) {
|
||||
punch.setPhotos(JsonUtil.toJson(photoItems));
|
||||
}
|
||||
punch.setWeight(trimOrNull(dto.getWeight()));
|
||||
punch.setVolume(trimOrNull(dto.getVolume()));
|
||||
@@ -355,6 +418,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean accept(Long id) {
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 认证门槛:司机本人须已认证,且承运车辆须已认证。
|
||||
// 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。
|
||||
assertCertified(driver);
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
waybillService.syncDriverAcceptState(waybill);
|
||||
assertAcceptable(waybill);
|
||||
@@ -368,6 +434,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return waybillService.updateById(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接单前的认证门槛校验
|
||||
* <p>
|
||||
* 司机的生效快照由其档案持有;车辆按司机绑定的车牌取。
|
||||
* 任一不满足即拒绝接单,并给出可读原因。
|
||||
*
|
||||
* @param driver 当前司机
|
||||
*/
|
||||
private void assertCertified(Driver driver) {
|
||||
if (!certificationGate.isDriverAvailable(driver.getEffectiveCertRecordId())) {
|
||||
throw new ServiceException(certificationGate.driverUnavailableReason());
|
||||
}
|
||||
if (currentBoundPlates(driver).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 用共享的车牌匹配条件:只做精确 IN 会漏掉库中带间隔符/大小写差异的车牌,
|
||||
// 那会把已认证的司机误判为未认证而拒绝接单
|
||||
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver));
|
||||
List<TransportVehicle> boundVehicles = transportVehicleService.list(plateQuery);
|
||||
// 逐车取不可用原因:停用与未认证要分开报,否则「车已认证但被停用」
|
||||
// 会被报成「认证没通过」,把人引去做无用的重新认证
|
||||
String unavailableReason = boundVehicles.stream()
|
||||
.map(vehicle -> certificationGate.vehicleUnavailableReason(
|
||||
vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL,
|
||||
vehicle.getEffectiveCertRecordId()))
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (unavailableReason != null) {
|
||||
throw new ServiceException(unavailableReason);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean reject(Long id, String reason) {
|
||||
@@ -404,6 +504,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
&& !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) {
|
||||
throw new ServiceException("当前运单状态不允许完成");
|
||||
}
|
||||
// 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁
|
||||
assertNoUnresolvedException(waybill.getId());
|
||||
// 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单
|
||||
return waybillService.completeWithoutDeptCheck(waybill.getId());
|
||||
}
|
||||
@@ -539,7 +641,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录司机:优先 userId,其次 JWT 账号(手机号)匹配 driver.mobile
|
||||
* 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile
|
||||
*/
|
||||
private Driver currentDriver() {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
@@ -552,37 +654,38 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (driver != null) {
|
||||
return driver;
|
||||
}
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) {
|
||||
String phone = resolveSelfPhone(userId);
|
||||
if (Func.isNotEmpty(phone)) {
|
||||
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getMobile, account.trim())
|
||||
.eq(Driver::getMobile, phone)
|
||||
.orderByDesc(Driver::getUpdateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> splitPlates(String drivingVehicle) {
|
||||
String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",")
|
||||
.replace(";", ",").replace("/", ",").replace("|", ",");
|
||||
Set<String> plates = new LinkedHashSet<>();
|
||||
for (String part : Func.toStrList(",", normalized)) {
|
||||
if (Func.isEmpty(part)) {
|
||||
continue;
|
||||
}
|
||||
String plate = normalizePlate(part);
|
||||
if (Func.isNotEmpty(plate)) {
|
||||
plates.add(plate);
|
||||
}
|
||||
/**
|
||||
* 解析当前小程序用户可用于匹配司机档案的手机号
|
||||
*/
|
||||
private String resolveSelfPhone(Long userId) {
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (isMobileLike(account)) {
|
||||
return account.trim();
|
||||
}
|
||||
return new ArrayList<>(plates);
|
||||
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 String normalizePlate(String plateNo) {
|
||||
if (Func.isEmpty(plateNo)) {
|
||||
return "";
|
||||
}
|
||||
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
|
||||
private boolean isMobileLike(String value) {
|
||||
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
|
||||
}
|
||||
|
||||
private List<String> splitPlates(String drivingVehicle) {
|
||||
return VehiclePlateSupport.splitPlates(drivingVehicle);
|
||||
}
|
||||
|
||||
private int normalizePreviewSize(Integer size) {
|
||||
@@ -746,11 +849,266 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单未了结(未达终态)的异常处置所属节点 key。
|
||||
* <p>
|
||||
* 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> findPunchNode(Long waybillId, String nodeCode) {
|
||||
if (waybillId == null || Func.isEmpty(nodeCode)) {
|
||||
return null;
|
||||
}
|
||||
Waybill waybill = waybillService.getById(waybillId);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return null;
|
||||
}
|
||||
return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNodePunched(Long waybillId, String nodeCode) {
|
||||
if (waybillId == null || Func.isEmpty(nodeCode)) {
|
||||
return false;
|
||||
}
|
||||
// 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来
|
||||
return nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId))
|
||||
.stream()
|
||||
.anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
|
||||
// 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定
|
||||
Driver driver = requireCurrentDriver();
|
||||
loadDriverWaybill(waybillId, currentBoundPlates(driver));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。
|
||||
* <p>
|
||||
* 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致:
|
||||
* 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。
|
||||
* <p>
|
||||
* 不含空串——未指明节点的异常没有对应节点,无从挂标签;
|
||||
* 它是否阻断全单由 {@link #blockingException} 另行判定。
|
||||
*/
|
||||
private Set<String> unresolvedExceptionNodeCodes(Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return unresolvedExceptions(waybillId).stream()
|
||||
.map(item -> Func.toStr(item.getNodeCode(), "").trim())
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单未处理完成异常的节点 key 列表,**保留空串**(表示存在未指明节点的异常)。
|
||||
* <p>
|
||||
* 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。
|
||||
*/
|
||||
private List<String> unresolvedExceptionNodeCodeList(Long waybillId) {
|
||||
return unresolvedExceptions(waybillId).stream()
|
||||
.map(item -> Func.toStr(item.getNodeCode(), "").trim())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 该运单未了结(未达终态)的异常处置,按上报时间升序 */
|
||||
private List<ExceptionDisposal> unresolvedExceptions(Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return exceptionDisposalMapper.selectList(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
|
||||
.orderByAsc(ExceptionDisposal::getReportTime)
|
||||
.orderByAsc(ExceptionDisposal::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「正在阻断该运单打卡」的那条异常处置。
|
||||
* <p>
|
||||
* 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先),
|
||||
* 与 {@link WaybillProcessSupport#blockedNodeKeys} 的判定口径一致。
|
||||
*/
|
||||
private ExceptionDisposal blockingException(Waybill waybill, String processJson) {
|
||||
String earliestCode = WaybillProcessSupport.earliestBlockingNodeCode(
|
||||
processJson, unresolvedExceptionNodeCodeList(waybill.getId()));
|
||||
if (earliestCode == null) {
|
||||
return null;
|
||||
}
|
||||
return unresolvedExceptions(waybill.getId()).stream()
|
||||
.filter(item -> earliestCode.equals(Func.toStr(item.getNodeCode(), "").trim()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言指定节点未被异常门禁阻断。
|
||||
* <p>
|
||||
* 普通节点:阻断范围是未了结异常所在节点及其后的节点。
|
||||
* 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。
|
||||
* 前端禁用只是体验层,这里才是强制点。
|
||||
*/
|
||||
private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(
|
||||
processJson, unresolvedExceptionNodeCodeList(waybillId));
|
||||
if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) {
|
||||
throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言该节点的前置打卡节点都已完成(顺序门禁)。
|
||||
* <p>
|
||||
* 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错,
|
||||
* 司机才知道该等一等(顺序)还是该找人处理(异常)。
|
||||
*/
|
||||
private void assertPrerequisitePunched(String processJson, Long waybillId, Map<String, Object> nodeCfg) {
|
||||
// ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来
|
||||
Set<String> punched = nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId))
|
||||
.stream()
|
||||
.map(punch -> Func.toStr(punch.getNodeCode(), "").trim())
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
|
||||
processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
|
||||
if (!unfinished.isEmpty()) {
|
||||
throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言打卡照片已上传(在途链路)。
|
||||
* <p>
|
||||
* 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况
|
||||
* ——前端的必填提示不构成约束。
|
||||
*/
|
||||
private void assertPunchPhoto(List<String> photos) {
|
||||
boolean hasPhoto = photos != null && photos.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.anyMatch(Func::isNotEmpty);
|
||||
if (!hasPhoto) {
|
||||
throw new ServiceException("请上传打卡照片");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。
|
||||
* <p>
|
||||
* 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据,
|
||||
* 放进来路不明的类型会让回显分组错乱。
|
||||
*/
|
||||
private List<Map<String, String>> typedNodePunchPhotos(List<PunchPhotoDTO> items, List<String> allowedTypes) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
for (PunchPhotoDTO item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String url = Func.toStr(item.getUrl(), "").trim();
|
||||
if (Func.isEmpty(url)) {
|
||||
continue;
|
||||
}
|
||||
String type = Func.toStr(item.getType(), "").trim();
|
||||
if (Func.isEmpty(type)) {
|
||||
throw new ServiceException("照片类型不能为空");
|
||||
}
|
||||
if (!allowedTypes.contains(type)) {
|
||||
throw new ServiceException("照片类型「" + type + "」不属于该节点");
|
||||
}
|
||||
out.add(nodePunchPhotoItem(type, url));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。
|
||||
*/
|
||||
private List<Map<String, String>> legacyNodePunchPhotos(List<String> photos, List<String> voucherTypes) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<String> urls = photos.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
for (int i = 0; i < urls.size(); i++) {
|
||||
String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1));
|
||||
out.add(nodePunchPhotoItem(type, urls.get(i)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, String> nodePunchPhotoItem(String type, String url) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
item.put("type", type);
|
||||
item.put("url", url);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言节点打卡照片满足类型口径。
|
||||
* <p>
|
||||
* 每个类型至多 9 张,按类型各自计数,不是节点合计。
|
||||
* 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」;
|
||||
* 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」,
|
||||
* 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。
|
||||
*/
|
||||
private void assertNodePunchPhotos(List<Map<String, String>> photoItems, Map<String, Object> nodeCfg,
|
||||
boolean typedPhotoItems) {
|
||||
Map<String, Integer> countByType = countPunchPhotoByType(photoItems);
|
||||
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
|
||||
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
|
||||
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
|
||||
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
|
||||
}
|
||||
}
|
||||
if (!typedPhotoItems) {
|
||||
if (countByType.isEmpty()) {
|
||||
throw new ServiceException("请上传打卡照片");
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) {
|
||||
if (countByType.getOrDefault(type, 0) < 1) {
|
||||
throw new ServiceException("「" + type + "」至少上传 1 张照片");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Integer> countPunchPhotoByType(List<Map<String, String>> photoItems) {
|
||||
Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||
for (Map<String, String> item : photoItems) {
|
||||
countByType.merge(item.get("type"), 1, Integer::sum);
|
||||
}
|
||||
return countByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言该运单没有未了结的异常。
|
||||
* <p>
|
||||
* 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
|
||||
* 「完成运单」绕过打卡门禁。
|
||||
*/
|
||||
private void assertNoUnresolvedException(Long waybillId) {
|
||||
if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
|
||||
throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装过程配置 punch=是 的打卡节点列表。
|
||||
* <p>
|
||||
* 未打卡节点均可打,不做顺序/时段门禁;
|
||||
* 默认展开:第一个未完成的可见节点。
|
||||
* 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级——
|
||||
* 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。
|
||||
* 默认展开:第一个未完成、且未被异常阻断的可见节点。
|
||||
* 非在途节点「已打卡」以节点打卡表为准。
|
||||
* 节点字段(定位/货量/凭证)取自动态过程配置。
|
||||
*/
|
||||
@@ -837,8 +1195,87 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
nodes.add(vo);
|
||||
}
|
||||
|
||||
// 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡;
|
||||
// 在途另按运单级——该运单只要还有未了结异常,在途就不可打卡。
|
||||
// 已打卡节点不受影响,仍可展开查看回填内容。
|
||||
ExceptionDisposal blocker = blockingException(waybill, processJson);
|
||||
Set<String> blockedKeys = WaybillProcessSupport.blockedNodeKeys(
|
||||
processJson, unresolvedExceptionNodeCodeList(waybill.getId()));
|
||||
if (!blockedKeys.isEmpty()) {
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) {
|
||||
continue;
|
||||
}
|
||||
vo.setBlocked(true);
|
||||
vo.setActionable(false);
|
||||
if (blocker != null) {
|
||||
vo.setBlockedExceptionId(blocker.getId());
|
||||
}
|
||||
vo.setLockReason(LOCK_REASON_EXCEPTION);
|
||||
}
|
||||
}
|
||||
|
||||
// 顺序门禁:前面还有打卡节点没打,本节点就不能打(在途豁免)。
|
||||
// 与异常门禁独立:两者可同时成立,也可只成立一个。
|
||||
Set<String> punchedCodes = new HashSet<>();
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (!Boolean.TRUE.equals(vo.getDone())) {
|
||||
if (Boolean.TRUE.equals(vo.getDone())) {
|
||||
punchedCodes.add(vo.getKey());
|
||||
}
|
||||
}
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (Boolean.TRUE.equals(vo.getDone())) {
|
||||
continue;
|
||||
}
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
|
||||
processJson, punchedCodes, vo.getKey());
|
||||
if (unfinished.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
vo.setBlockedByOrder(true);
|
||||
vo.setActionable(false);
|
||||
vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
|
||||
// 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用);
|
||||
// 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder());
|
||||
vo.setExpandable(!blockedByOrder);
|
||||
}
|
||||
|
||||
// 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。
|
||||
// 被牵连阻断的后续节点没有异常上报,不挂标签——否则司机以为每个节点都出了事。
|
||||
Set<String> exceptionNodeCodes = unresolvedExceptionNodeCodes(waybill.getId());
|
||||
if (!exceptionNodeCodes.isEmpty()) {
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (exceptionNodeCodes.contains(vo.getKey())) {
|
||||
vo.setHasException(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空),
|
||||
// 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。
|
||||
// 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点
|
||||
// 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。
|
||||
// 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64,
|
||||
// 前端直收数字会丢精度。
|
||||
List<ExceptionDisposal> unresolved = unresolvedExceptions(waybill.getId());
|
||||
boolean hasWaybillException = unresolved.stream()
|
||||
.anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode()));
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
vo.setHasWaybillException(hasWaybillException);
|
||||
vo.setRelatedExceptionIds(unresolved.stream()
|
||||
.filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode()))
|
||||
.map(item -> String.valueOf(item.getId()))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (!Boolean.TRUE.equals(vo.getDone())
|
||||
&& !Boolean.TRUE.equals(vo.getBlocked())
|
||||
&& !Boolean.TRUE.equals(vo.getBlockedByOrder())) {
|
||||
vo.setDefaultExpanded(true);
|
||||
break;
|
||||
}
|
||||
@@ -846,6 +1283,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */
|
||||
private String prerequisiteNames(String processJson, Set<String> nodeKeys) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Map<String, Object> node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
|
||||
String key = WaybillProcessSupport.nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
names.add(WaybillProcessSupport.nodeName(node));
|
||||
}
|
||||
}
|
||||
return names.isEmpty() ? "" : String.join("、", names);
|
||||
}
|
||||
|
||||
private Map<String, Object> findPunchNodeConfig(String processJson, String nodeCode) {
|
||||
List<Map<String, Object>> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson);
|
||||
for (Map<String, Object> cfg : punchConfigs) {
|
||||
|
||||
+169
-11
@@ -31,25 +31,30 @@ import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
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.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -68,13 +73,19 @@ public class ExceptionDisposalServiceImpl
|
||||
private static final String STATUS_PROCESSING = "processing";
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
|
||||
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
|
||||
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
|
||||
|
||||
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
|
||||
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
|
||||
IWaybillService waybillService) {
|
||||
IWaybillService waybillService,
|
||||
IDriverWaybillService driverWaybillService) {
|
||||
this.followRecordMapper = followRecordMapper;
|
||||
this.waybillService = waybillService;
|
||||
this.driverWaybillService = driverWaybillService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -120,7 +131,7 @@ public class ExceptionDisposalServiceImpl
|
||||
disposal.setDisposalStatus(STATUS_PENDING);
|
||||
disposal.setReportTime(LocalDateTime.now());
|
||||
disposal.setReporterId(AuthUtil.getUserId());
|
||||
disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId()));
|
||||
disposal.setReporterName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
|
||||
|
||||
fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo());
|
||||
|
||||
@@ -128,12 +139,58 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("请关联运单后再上报");
|
||||
}
|
||||
|
||||
// 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。
|
||||
// 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
String nodeCode = Func.toStr(request.getNodeCode(), "").trim();
|
||||
if (Func.isNotEmpty(nodeCode)) {
|
||||
Map<String, Object> nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode);
|
||||
if (nodeCfg == null) {
|
||||
throw new ServiceException("异常节点不存在");
|
||||
}
|
||||
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
|
||||
// 上报门禁之一:已打卡说明这个环节已经过去,事后才发现的问题走整单异常。
|
||||
// 在途例外——它可重复打卡、没有完成含义,打卡与否不说明环节是否已经过去。
|
||||
if (!WaybillProcessSupport.isTransitNodePublic(nodeCfg)
|
||||
&& driverWaybillService.isNodePunched(disposal.getWaybillId(), nodeKey)) {
|
||||
throw new ServiceException("该节点已打卡,不能上报异常");
|
||||
}
|
||||
// 上报门禁之二:同一节点同时至多一条未了结异常,处理中也占位。
|
||||
// 约束是同时性的:异常了结后该节点重新可以上报。
|
||||
if (hasUnresolvedNodeException(disposal.getWaybillId(), nodeKey)) {
|
||||
throw new ServiceException("该节点已有未处理异常,处理完成后才能再次上报");
|
||||
}
|
||||
disposal.setNodeCode(nodeKey);
|
||||
disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg));
|
||||
}
|
||||
|
||||
if (!save(disposal)) {
|
||||
throw new ServiceException("异常上报失败");
|
||||
}
|
||||
return toVO(disposal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单的指定节点是否已有未了结(未达终态)的异常。
|
||||
* <p>
|
||||
* 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算,
|
||||
* 「处理中」同样占位——它只是有人跟进过,问题可能仍在。
|
||||
* 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。
|
||||
*/
|
||||
private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) {
|
||||
Long count = baseMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)
|
||||
.apply("LOWER(TRIM(node_code)) = {0}", nodeCode.trim().toLowerCase(Locale.ROOT)));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */
|
||||
private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) {
|
||||
Waybill waybill = null;
|
||||
@@ -165,31 +222,129 @@ public class ExceptionDisposalServiceImpl
|
||||
disposal.setLoadingOrMasterNo(loadingOrMaster);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(ExceptionDisposalUpdateRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要编辑的异常");
|
||||
}
|
||||
if (Func.isBlank(request.getExceptionType())) {
|
||||
throw new ServiceException("请选择异常类型");
|
||||
}
|
||||
if (Func.isBlank(request.getReportDescription())) {
|
||||
throw new ServiceException("请填写上报说明");
|
||||
}
|
||||
if (request.getReportDescription().length() > 500) {
|
||||
throw new ServiceException("上报说明不能超过500字");
|
||||
}
|
||||
if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) {
|
||||
throw new ServiceException("异常原因不能超过200字");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
|
||||
String exceptionType = request.getExceptionType().trim();
|
||||
String exceptionReason = Func.isBlank(request.getExceptionReason())
|
||||
? null
|
||||
: request.getExceptionReason().trim();
|
||||
String reportDescription = request.getReportDescription().trim();
|
||||
String scenePhotos = Func.isBlank(request.getScenePhotos())
|
||||
? null
|
||||
: request.getScenePhotos().trim();
|
||||
|
||||
String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos);
|
||||
if (Func.isBlank(changeLog)) {
|
||||
throw new ServiceException("未检测到任何修改");
|
||||
}
|
||||
|
||||
disposal.setExceptionType(exceptionType);
|
||||
disposal.setExceptionReason(exceptionReason);
|
||||
disposal.setReportDescription(reportDescription);
|
||||
disposal.setScenePhotos(scenePhotos);
|
||||
disposal.setLatestFollowContent(changeLog);
|
||||
updateById(disposal);
|
||||
|
||||
ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
|
||||
record.setDisposalId(disposal.getId());
|
||||
record.setFollowContent(changeLog);
|
||||
record.setFollowUser(AuthUtil.getUserId());
|
||||
record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.setFollowTime(LocalDateTime.now());
|
||||
followRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐字段比对生成变更说明,用于写入跟进记录。
|
||||
* 未发生变更时返回 null,调用方据此拒绝这次编辑。
|
||||
* <p>
|
||||
* 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制,
|
||||
* 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。
|
||||
*/
|
||||
private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason,
|
||||
String reportDescription, String scenePhotos) {
|
||||
List<String> changes = new ArrayList<>();
|
||||
appendChange(changes, "异常类型", before.getExceptionType(), exceptionType);
|
||||
appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason);
|
||||
appendChange(changes, "上报说明", before.getReportDescription(), reportDescription);
|
||||
if (!Objects.equals(before.getScenePhotos(), scenePhotos)) {
|
||||
changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张");
|
||||
}
|
||||
if (changes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return truncate("编辑:" + String.join(";", changes));
|
||||
}
|
||||
|
||||
private void appendChange(List<String> changes, String label, String before, String after) {
|
||||
String oldValue = Func.isBlank(before) ? "空" : before;
|
||||
String newValue = Func.isBlank(after) ? "空" : after;
|
||||
if (!Objects.equals(before, after)) {
|
||||
changes.add(label + "由 " + oldValue + " 改为 " + newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 截断到字段上限内,留出省略号位置 */
|
||||
private String truncate(String text) {
|
||||
return text.length() <= FOLLOW_CONTENT_MAX_LENGTH
|
||||
? text
|
||||
: text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
private int photoCount(String scenePhotos) {
|
||||
return splitPhotos(scenePhotos).size();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void follow(ExceptionDisposalFollowRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要跟进的异常");
|
||||
}
|
||||
if (Func.isBlank(request.getFollowContent())) {
|
||||
throw new ServiceException("请填写跟进说明");
|
||||
boolean hasAttachments = Func.isNotBlank(request.getAttachmentsJson());
|
||||
if (Func.isBlank(request.getFollowContent()) && !hasAttachments) {
|
||||
throw new ServiceException("请填写跟进说明或上传附件");
|
||||
}
|
||||
if (request.getFollowContent().length() > 500) {
|
||||
if (Func.isNotBlank(request.getFollowContent()) && request.getFollowContent().length() > 500) {
|
||||
throw new ServiceException("跟进说明不能超过500字");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("已完成的异常不允许继续跟进");
|
||||
}
|
||||
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
|
||||
String followContent = Func.isBlank(request.getFollowContent())
|
||||
? null
|
||||
: request.getFollowContent();
|
||||
disposal.setDisposalStatus(STATUS_PROCESSING);
|
||||
disposal.setLatestFollowContent(request.getFollowContent());
|
||||
disposal.setLatestFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent);
|
||||
updateById(disposal);
|
||||
|
||||
ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
|
||||
record.setDisposalId(disposal.getId());
|
||||
record.setFollowContent(request.getFollowContent());
|
||||
// follow_content 列为 NOT NULL
|
||||
record.setFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent);
|
||||
record.setAttachmentsJson(hasAttachments ? request.getAttachmentsJson() : null);
|
||||
record.setFollowUser(AuthUtil.getUserId());
|
||||
record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.setFollowTime(LocalDateTime.now());
|
||||
followRecordMapper.insert(record);
|
||||
}
|
||||
@@ -231,6 +386,9 @@ public class ExceptionDisposalServiceImpl
|
||||
private LambdaQueryWrapper<ExceptionDisposal> buildQuery(ExceptionDisposalVO query) {
|
||||
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
// 按运单精确过滤,供司机端「本运单的异常」列表使用。
|
||||
// 不用运单号:它是模糊匹配,YD001 会命中 YD0011。
|
||||
.eq(query.getWaybillId() != null, ExceptionDisposal::getWaybillId, query.getWaybillId())
|
||||
.like(Func.isNotEmpty(query.getWaybillNo()), ExceptionDisposal::getWaybillNo, query.getWaybillNo())
|
||||
.like(Func.isNotEmpty(query.getVehicleNo()), ExceptionDisposal::getVehicleNo, query.getVehicleNo())
|
||||
.like(Func.isNotEmpty(query.getReporterName()), ExceptionDisposal::getReporterName, query.getReporterName())
|
||||
@@ -259,8 +417,8 @@ public class ExceptionDisposalServiceImpl
|
||||
if (vo == null) {
|
||||
return null;
|
||||
}
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
|
||||
vo.setCreateUserName(UserDisplayCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser()));
|
||||
vo.setDisposalStatusName(statusName(entity.getDisposalStatus()));
|
||||
vo.setScenePhotoList(splitPhotos(entity.getScenePhotos()));
|
||||
fillRouteAndCargo(vo, entity.getWaybillId());
|
||||
|
||||
+99
-8
@@ -30,7 +30,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.RiskDisposal;
|
||||
@@ -57,6 +57,7 @@ import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
@@ -85,6 +86,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final String DISPOSAL_PROCESSING = "processing";
|
||||
private static final String RISK_PENDING = "pending";
|
||||
|
||||
/** 小程序首页待处理事项的类型:异常待处置 */
|
||||
private static final String TODO_EXCEPTION = "exception";
|
||||
/** 小程序首页待处理事项的类型:重新派单待处理 */
|
||||
private static final String TODO_REASSIGN = "reassign";
|
||||
/** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */
|
||||
private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0";
|
||||
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
private static final String TRANSPORT_COMMON = "common";
|
||||
@@ -122,8 +130,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
|
||||
badges.setException(home.getStats().getException());
|
||||
badges.setRisk(countPendingRisks());
|
||||
badges.setPendingReassign(countPendingReassignments());
|
||||
home.setBadges(badges);
|
||||
home.setFeed(buildExceptionFeed());
|
||||
home.setFeed(buildAdminFeed());
|
||||
return home;
|
||||
}
|
||||
|
||||
@@ -561,18 +570,66 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
|
||||
}
|
||||
|
||||
private List<AdminTodoItemVO> buildExceptionFeed() {
|
||||
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
|
||||
private long countPendingReassignments() {
|
||||
return waybillService.count(pendingReassignQuery());
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页待处理事项:异常待处置 + 重新派单待处理。
|
||||
* 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。
|
||||
* 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。
|
||||
*/
|
||||
List<AdminTodoItemVO> buildAdminFeed() {
|
||||
List<TimedTodo> merged = new ArrayList<>();
|
||||
merged.addAll(buildExceptionFeed());
|
||||
merged.addAll(buildReassignFeed());
|
||||
merged.sort(java.util.Comparator.comparing(
|
||||
(TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())));
|
||||
java.util.Collections.reverse(merged);
|
||||
int to = Math.min(merged.size(), FEED_LIMIT);
|
||||
return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 待处理事项 + 它的业务发生时间(仅用于排序) */
|
||||
private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) {
|
||||
}
|
||||
|
||||
private List<TimedTodo> buildExceptionFeed() {
|
||||
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream().map(this::toTodoItem).collect(Collectors.toList());
|
||||
return list.stream()
|
||||
.map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<TimedTodo> buildReassignFeed() {
|
||||
List<Waybill> list = waybillService.list(pendingReassignQuery()
|
||||
.orderByDesc(Waybill::getDriverRejectTime)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream()
|
||||
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) {
|
||||
return disposal.getReportTime() != null
|
||||
? disposal.getReportTime()
|
||||
: toLocalDateTime(disposal.getCreateTime());
|
||||
}
|
||||
|
||||
private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) {
|
||||
AdminTodoItemVO item = new AdminTodoItemVO();
|
||||
item.setId(disposal.getId());
|
||||
item.setType("exception");
|
||||
item.setType(TODO_EXCEPTION);
|
||||
item.setTitle("异常待处置");
|
||||
item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null
|
||||
? disposal.getReportTime()
|
||||
@@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private AdminTodoItemVO toReassignTodoItem(Waybill waybill) {
|
||||
AdminTodoItemVO item = new AdminTodoItemVO();
|
||||
item.setId(waybill.getId());
|
||||
item.setType(TODO_REASSIGN);
|
||||
item.setTitle("重新派单待处理");
|
||||
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
|
||||
item.setDesc(buildReassignDesc(waybill));
|
||||
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
|
||||
item.setActionLabel("重新派单");
|
||||
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */
|
||||
private LocalDateTime rejectTimeOf(Waybill waybill) {
|
||||
if (waybill.getDriverRejectTime() != null) {
|
||||
return toLocalDateTime(waybill.getDriverRejectTime());
|
||||
}
|
||||
return toLocalDateTime(waybill.getUpdateTime());
|
||||
}
|
||||
|
||||
/** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */
|
||||
private String buildReassignDesc(Waybill waybill) {
|
||||
String driver = Func.toStr(waybill.getDriverName(), "原司机");
|
||||
String text = "运单已指派" + driver + ",被拒绝,请重新派单";
|
||||
String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim();
|
||||
if (Func.isNotBlank(reason)) {
|
||||
text = text + ":" + reason;
|
||||
}
|
||||
return text.length() > 80 ? text.substring(0, 80) + "…" : text;
|
||||
}
|
||||
|
||||
private String buildExceptionDesc(ExceptionDisposal disposal) {
|
||||
String reporter = Func.toStr(disposal.getReporterName(), "司机");
|
||||
String type = Func.toStr(disposal.getExceptionType(), "异常");
|
||||
@@ -599,13 +688,15 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
}
|
||||
|
||||
private String resolveUserName() {
|
||||
String realName = UserCache.getUserRealName(AuthUtil.getUserId());
|
||||
if (Func.isNotBlank(realName)) {
|
||||
String realName = UserDisplayCache.getUserRealName(AuthUtil.getUserId());
|
||||
if (Func.isNotBlank(realName) && !Func.toStr(AuthUtil.getUserId()).equals(realName)) {
|
||||
return realName;
|
||||
}
|
||||
return Func.toStr(AuthUtil.getUserName(), "");
|
||||
String jwtName = Func.toStr(AuthUtil.getNickName(), AuthUtil.getUserName());
|
||||
return Func.toStr(jwtName, realName);
|
||||
}
|
||||
|
||||
/** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */
|
||||
private String formatTimeAgo(LocalDateTime time) {
|
||||
if (time == null) {
|
||||
return "";
|
||||
|
||||
+1
-1
@@ -677,7 +677,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
freight.put("freightTotal", freightTotal);
|
||||
freight.put("totalFreightAmount", freightTotal.add(Objects.requireNonNullElse(nullableDecimal(dispatch, "otherFeeTotal"), BigDecimal.ZERO)));
|
||||
freight.put("otherFreightAmount", string(dispatch, "otherFeeTotal", ""));
|
||||
freight.put("currency", string(dispatch, "currency", "CNY"));
|
||||
freight.put("currency", string(dispatch, "currency", "RMB"));
|
||||
return JsonUtil.toJson(freight);
|
||||
}
|
||||
private String waybillGroupKey(Map<String, Object> dispatch) { return String.join("\u0000", string(dispatch, "segmentNo", ""), string(dispatch, "carrierType", ""), string(dispatch, "carrierContractId", ""), string(dispatch, "carrierName", ""), string(dispatch, "driverName", ""), string(dispatch, "driverPhone", ""), string(dispatch, "vehicleNo", ""), string(dispatch, "captainName", ""), string(dispatch, "containerNo", ""), string(dispatch, "cabinNo", "")); }
|
||||
|
||||
+20
-2
@@ -320,7 +320,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
if (contractChanged) {
|
||||
settlement.setSettlementType(contractSettlementType(contract));
|
||||
settlement.setCurrency(LOCAL_CURRENCY);
|
||||
settlement.setCurrency(resolveContractCurrency(contract));
|
||||
summaryFeeMapper.delete(Wrappers.<PreSettlementSummaryFee>lambdaQuery()
|
||||
.eq(PreSettlementSummaryFee::getPreSettlementId, settlement.getId()));
|
||||
}
|
||||
@@ -330,7 +330,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
settlement.setSettlementType(resolveSettlementType(request, contract));
|
||||
settlement.setApprovalStatus(STATUS_DRAFT);
|
||||
settlement.setCurrentNode("草稿");
|
||||
settlement.setCurrency(LOCAL_CURRENCY);
|
||||
settlement.setCurrency(resolveContractCurrency(contract));
|
||||
settlement.setLocalCurrency(LOCAL_CURRENCY);
|
||||
}
|
||||
fillContract(settlement, contract);
|
||||
@@ -1281,6 +1281,24 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预结算单结算币种取自合同结算币种,CNY 归一为 RMB。
|
||||
*/
|
||||
private String resolveContractCurrency(ContractManage contract) {
|
||||
if (contract == null || Func.isEmpty(contract.getSettlementCurrency())) {
|
||||
return LOCAL_CURRENCY;
|
||||
}
|
||||
String code = contract.getSettlementCurrency().trim().toUpperCase();
|
||||
int separatorIndex = code.indexOf(" - ");
|
||||
if (separatorIndex > 0) {
|
||||
code = code.substring(0, separatorIndex).trim();
|
||||
}
|
||||
if ("CNY".equals(code)) {
|
||||
return LOCAL_CURRENCY;
|
||||
}
|
||||
return Func.isEmpty(code) ? LOCAL_CURRENCY : code;
|
||||
}
|
||||
|
||||
private String resolveSettlementType(PreSettlementSaveRequest request, ContractManage contract) {
|
||||
if (Boolean.TRUE.equals(request.getAllowSourceMismatch()) && Func.isNotEmpty(request.getSettlementType())) {
|
||||
validateSettlementType(request.getSettlementType());
|
||||
|
||||
+125
-16
@@ -338,19 +338,23 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
TransportBusinessSupport.validateAllDept(projectApply.getAllDept(), "项目立项");
|
||||
LambdaQueryWrapper<ProjectApply> queryWrapper = Wrappers.<ProjectApply>lambdaQuery().eq(ProjectApply::getIsDeleted, 0);
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
// 超管默认可查看全部未删除数据;仅在明确指定承办组织时按组织精确筛选。
|
||||
// 超管默认可查看全部未删除数据;仅在明确指定平台公司时按包含匹配筛选。
|
||||
if (Objects.equals(projectApply.getAllDept(), 1) && Func.isNotEmpty(projectApply.getUndertakeDeptId())) {
|
||||
queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId());
|
||||
applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId());
|
||||
}
|
||||
} else if (!Objects.equals(projectApply.getAllDept(), 1)) {
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项");
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
queryWrapper.and(wrapper -> wrapper.eq(ProjectApply::getUndertakeDeptId, currentDeptId)
|
||||
.or().eq(ProjectApply::getCreateUser, currentUserId)
|
||||
.or().eq(ProjectApply::getHandlerUserId, currentUserId)
|
||||
.or().eq(ProjectApply::getPrincipalUserId, currentUserId));
|
||||
queryWrapper.and(wrapper -> {
|
||||
if (currentDeptId != null) {
|
||||
wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", String.valueOf(currentDeptId));
|
||||
}
|
||||
wrapper.or().eq(ProjectApply::getCreateUser, currentUserId)
|
||||
.or().eq(ProjectApply::getHandlerUserId, currentUserId)
|
||||
.or().eq(ProjectApply::getPrincipalUserId, currentUserId);
|
||||
});
|
||||
} else if (Func.isNotEmpty(projectApply.getUndertakeDeptId())) {
|
||||
queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId());
|
||||
applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId());
|
||||
}
|
||||
queryWrapper.like(Func.isNotEmpty(projectApply.getApplyNo()), ProjectApply::getApplyNo, projectApply.getApplyNo())
|
||||
.like(Func.isNotEmpty(projectApply.getProjectCode()), ProjectApply::getProjectCode, projectApply.getProjectCode())
|
||||
@@ -464,6 +468,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
|
||||
private void prepareCreateOrUpdate(ProjectApply projectApply) {
|
||||
normalizeUndertakeDept(projectApply);
|
||||
if (Func.isEmpty(projectApply.getId())) {
|
||||
// 新增项目不产生变更记录,避免客户端误传变更字段导致记录回显。
|
||||
projectApply.setChangeContent(null);
|
||||
@@ -476,12 +481,12 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
if (Func.isEmpty(projectApply.getUndertakeDeptId())) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("项目立项");
|
||||
projectApply.setUndertakeDeptId(dept.getId());
|
||||
projectApply.setUndertakeDeptId(String.valueOf(dept.getId()));
|
||||
projectApply.setUndertakeDeptName(dept.getDeptName());
|
||||
}
|
||||
if (Func.isEmpty(projectApply.getBusinessDeptId())) {
|
||||
projectApply.setBusinessDeptId(projectApply.getUndertakeDeptId());
|
||||
projectApply.setBusinessDeptName(projectApply.getUndertakeDeptName());
|
||||
projectApply.setBusinessDeptId(firstUndertakeDeptId(projectApply.getUndertakeDeptId()));
|
||||
projectApply.setBusinessDeptName(firstUndertakeDeptName(projectApply.getUndertakeDeptName()));
|
||||
}
|
||||
if (Func.isEmpty(projectApply.getHandlerUserId())) {
|
||||
projectApply.setHandlerUserId(AuthUtil.getUserId());
|
||||
@@ -495,8 +500,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
ProjectApply oldRecord = loadEditable(projectApply.getId(), true);
|
||||
projectApply.setApplyNo(oldRecord.getApplyNo());
|
||||
projectApply.setProjectCode(oldRecord.getProjectCode());
|
||||
projectApply.setUndertakeDeptId(oldRecord.getUndertakeDeptId());
|
||||
projectApply.setUndertakeDeptName(oldRecord.getUndertakeDeptName());
|
||||
// 平台公司支持编辑多选保存,不再强制写回旧值
|
||||
if (Func.isEmpty(projectApply.getUndertakeDeptId())) {
|
||||
projectApply.setUndertakeDeptId(oldRecord.getUndertakeDeptId());
|
||||
projectApply.setUndertakeDeptName(oldRecord.getUndertakeDeptName());
|
||||
}
|
||||
}
|
||||
if (Func.isEmpty(projectApply.getEffectiveType())) {
|
||||
projectApply.setEffectiveType(EFFECTIVE_TEMPORARY);
|
||||
@@ -739,9 +747,6 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
TransportBusinessSupport.validateRequired(projectApply.getCarrierNames(), "请选择下游承运商");
|
||||
validateAmount(projectApply.getFundLimit(), "项目资金使用额度", false);
|
||||
validateAmount(projectApply.getReceivableLimit(), "项目应收账款额度", false);
|
||||
if (projectApply.getReceivableLimit() != null && projectApply.getFundLimit() != null && projectApply.getReceivableLimit().compareTo(projectApply.getFundLimit()) > 0) {
|
||||
throw new ServiceException("项目应收账款额度不能超过项目资金使用额度");
|
||||
}
|
||||
validateDays(projectApply.getReceivableDays(), "应收账款回款期限");
|
||||
validateDays(projectApply.getPaymentDays(), "回款账期");
|
||||
validateAmount(projectApply.getProjectScale(), "项目规模", true);
|
||||
@@ -770,6 +775,34 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
TransportBusinessSupport.validateLength(projectApply.getSourceRemark(), 300, "项目由来说明不能超过300个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getCargoQuantity(), 100, "预估货物数量不能超过100个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getTransportRoute(), 100, "运输线路不能超过100个字");
|
||||
validateSituationRemarkLength(projectApply.getSituationRemark());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验项目情况说明各字段长度(JSON:项目简介/盈利模式/风险点/应急预案)。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void validateSituationRemarkLength(String situationRemark) {
|
||||
if (Func.isBlank(situationRemark)) {
|
||||
return;
|
||||
}
|
||||
Object parsed;
|
||||
try {
|
||||
parsed = JsonUtil.parse(situationRemark, Object.class);
|
||||
} catch (Exception exception) {
|
||||
// 历史纯文本按整段校验。
|
||||
TransportBusinessSupport.validateLength(situationRemark, 1000, "项目情况说明不能超过1000个字");
|
||||
return;
|
||||
}
|
||||
if (!(parsed instanceof Map<?, ?> situationMap)) {
|
||||
TransportBusinessSupport.validateLength(situationRemark, 1000, "项目情况说明不能超过1000个字");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> data = (Map<String, Object>) situationMap;
|
||||
TransportBusinessSupport.validateLength(Func.toStr(data.get("projectIntro")), 1000, "项目简介不能超过1000个字");
|
||||
TransportBusinessSupport.validateLength(Func.toStr(data.get("profitRemark")), 1000, "盈利模式及利润测算情况说明不能超过1000个字");
|
||||
TransportBusinessSupport.validateLength(Func.toStr(data.get("riskPoint")), 1000, "项目风险点不能超过1000个字");
|
||||
TransportBusinessSupport.validateLength(Func.toStr(data.get("emergencyPlan")), 1000, "风险防范措施及应急预案不能超过1000个字");
|
||||
}
|
||||
|
||||
private void validateChange(ProjectApply projectApply, String changeType) {
|
||||
@@ -956,10 +989,86 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项");
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
return Objects.equals(projectApply.getUndertakeDeptId(), currentDeptId)
|
||||
return containsUndertakeDept(projectApply.getUndertakeDeptId(), currentDeptId)
|
||||
|| Objects.equals(projectApply.getCreateUser(), currentUserId)
|
||||
|| Objects.equals(projectApply.getHandlerUserId(), currentUserId)
|
||||
|| Objects.equals(projectApply.getPrincipalUserId(), currentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台公司多值筛选:历史单 ID 与逗号分隔多 ID 均按包含匹配。
|
||||
*/
|
||||
private void applyUndertakeDeptContains(LambdaQueryWrapper<ProjectApply> queryWrapper, String undertakeDeptId) {
|
||||
if (Func.isBlank(undertakeDeptId) || queryWrapper == null) {
|
||||
return;
|
||||
}
|
||||
List<String> deptIds = Arrays.stream(undertakeDeptId.split("[,,]"))
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (deptIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
queryWrapper.and(wrapper -> {
|
||||
boolean first = true;
|
||||
for (String deptId : deptIds) {
|
||||
if (first) {
|
||||
wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId);
|
||||
first = false;
|
||||
} else {
|
||||
wrapper.or().apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void normalizeUndertakeDept(ProjectApply projectApply) {
|
||||
if (projectApply == null) {
|
||||
return;
|
||||
}
|
||||
List<String> deptIds = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptId()).split("[,,]"))
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.filter(id -> !"-1".equals(id))
|
||||
.distinct()
|
||||
.toList();
|
||||
projectApply.setUndertakeDeptId(deptIds.isEmpty() ? null : String.join(",", deptIds));
|
||||
List<String> deptNames = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptName()).split("[,,、]"))
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
projectApply.setUndertakeDeptName(deptNames.isEmpty() ? null : String.join("、", deptNames));
|
||||
}
|
||||
|
||||
private Long firstUndertakeDeptId(String undertakeDeptId) {
|
||||
return Arrays.stream(Func.toStr(undertakeDeptId).split("[,,]"))
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.filter(id -> !"-1".equals(id))
|
||||
.map(Func::toLong)
|
||||
.filter(id -> id != null && id > 0L)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String firstUndertakeDeptName(String undertakeDeptName) {
|
||||
return Arrays.stream(Func.toStr(undertakeDeptName).split("[,,、]"))
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean containsUndertakeDept(String undertakeDeptId, Long deptId) {
|
||||
if (deptId == null || Func.isBlank(undertakeDeptId)) {
|
||||
return false;
|
||||
}
|
||||
String target = String.valueOf(deptId);
|
||||
return Arrays.stream(undertakeDeptId.split("[,,]"))
|
||||
.map(String::trim)
|
||||
.anyMatch(target::equals);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+128
-23
@@ -80,6 +80,7 @@ import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
@@ -431,13 +432,23 @@ public class ReceivablePayableDetailServiceImpl
|
||||
|
||||
@Override
|
||||
public IPage<ReceivablePayableChangeRecordVO> changeRecords(IPage<?> page, Long detailId) {
|
||||
LambdaQueryWrapper<ReceivablePayableChangeRecord> wrapper = Wrappers.<ReceivablePayableChangeRecord>lambdaQuery()
|
||||
.eq(ReceivablePayableChangeRecord::getDetailId, detailId)
|
||||
.eq(ReceivablePayableChangeRecord::getIsDeleted, 0)
|
||||
.orderByDesc(ReceivablePayableChangeRecord::getAdjustTime);
|
||||
IPage<ReceivablePayableChangeRecord> entityPage = changeRecordMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()), wrapper);
|
||||
Page<ReceivablePayableChangeRecordVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream().map(record -> Objects.requireNonNull(
|
||||
List<ReceivablePayableChangeRecord> meaningfulRecords = changeRecordMapper.selectList(
|
||||
Wrappers.<ReceivablePayableChangeRecord>lambdaQuery()
|
||||
.eq(ReceivablePayableChangeRecord::getDetailId, detailId)
|
||||
.eq(ReceivablePayableChangeRecord::getIsDeleted, 0)
|
||||
.orderByDesc(ReceivablePayableChangeRecord::getAdjustTime))
|
||||
.stream()
|
||||
.filter(record -> hasMeaningfulChange(record.getChangeContent()))
|
||||
.toList();
|
||||
long current = page.getCurrent();
|
||||
long size = page.getSize();
|
||||
int fromIndex = (int) Math.max(0L, (current - 1L) * size);
|
||||
int toIndex = (int) Math.min(meaningfulRecords.size(), fromIndex + size);
|
||||
List<ReceivablePayableChangeRecord> pageRecords = fromIndex >= meaningfulRecords.size()
|
||||
? List.of()
|
||||
: meaningfulRecords.subList(fromIndex, toIndex);
|
||||
Page<ReceivablePayableChangeRecordVO> voPage = new Page<>(current, size, meaningfulRecords.size());
|
||||
voPage.setRecords(pageRecords.stream().map(record -> Objects.requireNonNull(
|
||||
BeanUtil.copyProperties(record, ReceivablePayableChangeRecordVO.class))).toList());
|
||||
return voPage;
|
||||
}
|
||||
@@ -470,8 +481,10 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
List<ReceivablePayableCargoFee> afterRows = activeCargoFees(detail.getId());
|
||||
List<String> changes = reconcileUpdatedFeeAmounts(beforeRows, afterRows);
|
||||
LocalDateTime adjustTime = LocalDateTime.now();
|
||||
for (int index = 0; index < changes.size(); index++) {
|
||||
saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), String.format("%04d", index + 1));
|
||||
saveChangeRecord(detail, changes.get(index), request.getAdjustReason(),
|
||||
String.format("%04d", index + 1), adjustTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,14 +625,8 @@ public class ReceivablePayableDetailServiceImpl
|
||||
appendChange(changes, "运输单价", existing.getUnitPrice(), adjusted.getUnitPrice());
|
||||
appendChange(changes, "里程", existing.getMileage(), mileage);
|
||||
appendChange(changes, "运输费", existing.getFreightAmount(), freightAmount);
|
||||
if (!Objects.equals(existing.getRemark(), adjusted.getRemark())) {
|
||||
changes.add("【备注】从[" + Objects.toString(existing.getRemark(), "") + "]调整为["
|
||||
+ Objects.toString(adjusted.getRemark(), "") + "]");
|
||||
}
|
||||
if (!Objects.equals(existing.getChangeReason(), adjusted.getChangeReason())) {
|
||||
changes.add("【变更原因】从[" + Objects.toString(existing.getChangeReason(), "") + "]调整为["
|
||||
+ Objects.toString(adjusted.getChangeReason(), "") + "]");
|
||||
}
|
||||
appendChange(changes, "备注", existing.getRemark(), adjusted.getRemark());
|
||||
appendChange(changes, "变更原因", existing.getChangeReason(), adjusted.getChangeReason());
|
||||
Map<String, Object> oldFeeItems = parseMap(existing.getFeeItemsJson());
|
||||
for (String name : allowedFeeItems) {
|
||||
appendChange(changes, name, decimal(oldFeeItems.get(name)), money(effectiveFeeItems.get(name)));
|
||||
@@ -645,8 +652,9 @@ public class ReceivablePayableDetailServiceImpl
|
||||
if (changes.isEmpty()) {
|
||||
throw new ServiceException("未修改任何费用数据");
|
||||
}
|
||||
LocalDateTime adjustTime = LocalDateTime.now();
|
||||
for (int i = 0; i < changes.size(); i++) {
|
||||
saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1));
|
||||
saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1), adjustTime);
|
||||
}
|
||||
refreshAdjustedDetail(detail, allRows);
|
||||
}
|
||||
@@ -1471,7 +1479,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
|
||||
detail.setBatchNo(waybill.getBatchNo());
|
||||
detail.setUnitPrice(unitPrice);
|
||||
detail.setCurrency("RMB");
|
||||
detail.setCurrency(resolveCurrency(contract, waybill));
|
||||
detail.setFreightAmount(freight);
|
||||
detail.setOtherFeeAmount(other);
|
||||
detail.setTotalAmount(total);
|
||||
@@ -2369,12 +2377,56 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private void appendChange(List<String> changes, String field, String before, String after) {
|
||||
if (!Objects.equals(before, after)) {
|
||||
changes.add("【" + field + "】从[" + Objects.toString(before, "") + "]调整为["
|
||||
+ Objects.toString(after, "") + "]");
|
||||
String oldValue = normalizeChangeText(before);
|
||||
String newValue = normalizeChangeText(after);
|
||||
if (!Objects.equals(oldValue, newValue)) {
|
||||
changes.add("【" + field + "】从[" + oldValue + "]调整为[" + newValue + "]");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更文本归一化:null / 空白视为相同,避免出现「从[]调整为[]」无效记录
|
||||
*/
|
||||
private String normalizeChangeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在实际前后差异;整段内容均无变化时不展示
|
||||
*/
|
||||
private boolean hasMeaningfulChange(String content) {
|
||||
if (Func.isBlank(content)) {
|
||||
return false;
|
||||
}
|
||||
String text = content.trim();
|
||||
int suffixStart = text.lastIndexOf('(');
|
||||
if (suffixStart > 0 && text.endsWith(")")) {
|
||||
text = text.substring(0, suffixStart);
|
||||
}
|
||||
boolean hasPart = false;
|
||||
for (String part : text.split(";")) {
|
||||
if (Func.isBlank(part)) {
|
||||
continue;
|
||||
}
|
||||
hasPart = true;
|
||||
if (isMeaningfulChangePart(part.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return !hasPart;
|
||||
}
|
||||
|
||||
private boolean isMeaningfulChangePart(String part) {
|
||||
int fromIdx = part.indexOf("从[");
|
||||
int toIdx = part.indexOf("]调整为[");
|
||||
if (fromIdx < 0 || toIdx < fromIdx || !part.endsWith("]")) {
|
||||
return true;
|
||||
}
|
||||
String before = part.substring(fromIdx + 2, toIdx);
|
||||
String after = part.substring(toIdx + 5, part.length() - 1);
|
||||
return !Objects.equals(before, after);
|
||||
}
|
||||
|
||||
private String formatValue(BigDecimal value) {
|
||||
return money(value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
@@ -2526,6 +2578,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setOtherFeeAmount(total.subtract(freight));
|
||||
detail.setTotalAmount(total);
|
||||
detail.setUnitPrice(resolveContractUnitPrice(fees));
|
||||
detail.setCurrency(resolveCurrency(contract, waybill));
|
||||
detail.setFeeItemsJson(aggregateFeeItemsJson(fees));
|
||||
updateById(detail);
|
||||
}
|
||||
@@ -2557,6 +2610,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setOtherFeeAmount(total.subtract(freight));
|
||||
detail.setTotalAmount(total);
|
||||
detail.setUnitPrice(resolveContractUnitPrice(fees));
|
||||
detail.setCurrency(resolveCurrency(contract, masterGoods.isEmpty() ? null : masterGoods.get(0)));
|
||||
detail.setFeeItemsJson(aggregateFeeItemsJson(fees));
|
||||
updateById(detail);
|
||||
}
|
||||
@@ -2591,6 +2645,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setOtherFeeAmount(total.subtract(freight));
|
||||
detail.setTotalAmount(total);
|
||||
detail.setUnitPrice(resolveContractUnitPrice(fees));
|
||||
detail.setCurrency(resolveCurrency(contract, waybills.get(0)));
|
||||
detail.setFeeItemsJson(aggregateFeeItemsJson(fees));
|
||||
updateById(detail);
|
||||
}
|
||||
@@ -2612,10 +2667,18 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) {
|
||||
saveChangeRecord(detail, content, reason, "0001");
|
||||
saveChangeRecord(detail, content, reason, "0001", LocalDateTime.now());
|
||||
}
|
||||
|
||||
private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) {
|
||||
saveChangeRecord(detail, content, reason, lineNo, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo,
|
||||
LocalDateTime adjustTime) {
|
||||
if (!hasMeaningfulChange(content)) {
|
||||
return;
|
||||
}
|
||||
ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord();
|
||||
record.setDetailId(detail.getId());
|
||||
record.setLineNo(lineNo);
|
||||
@@ -2624,7 +2687,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
record.setAdjustUser(AuthUtil.getUserId());
|
||||
record.setAdjustUserName(AuthUtil.getUserName());
|
||||
record.setAdjustReason(reason);
|
||||
record.setAdjustTime(LocalDateTime.now());
|
||||
record.setAdjustTime(adjustTime == null ? LocalDateTime.now() : adjustTime);
|
||||
changeRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
@@ -2889,6 +2952,43 @@ public class ReceivablePayableDetailServiceImpl
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算币种优先取合同 settlementCurrency,其次运单 freightJson.currency,默认 RMB。
|
||||
*/
|
||||
private String resolveCurrency(ContractManage contract, Waybill waybill) {
|
||||
if (contract != null && Func.isNotEmpty(contract.getSettlementCurrency())) {
|
||||
return normalizeCurrencyCode(contract.getSettlementCurrency());
|
||||
}
|
||||
String freightCurrency = extractFreightCurrency(waybill);
|
||||
if (Func.isNotEmpty(freightCurrency)) {
|
||||
return normalizeCurrencyCode(freightCurrency);
|
||||
}
|
||||
return "RMB";
|
||||
}
|
||||
|
||||
private String extractFreightCurrency(Waybill waybill) {
|
||||
if (waybill == null || Func.isEmpty(waybill.getFreightJson())) {
|
||||
return null;
|
||||
}
|
||||
Object currency = parseMap(waybill.getFreightJson()).get("currency");
|
||||
return currency == null ? null : String.valueOf(currency);
|
||||
}
|
||||
|
||||
private String normalizeCurrencyCode(String currency) {
|
||||
if (Func.isEmpty(currency)) {
|
||||
return "RMB";
|
||||
}
|
||||
String code = currency.trim().toUpperCase(Locale.ROOT);
|
||||
int separatorIndex = code.indexOf(" - ");
|
||||
if (separatorIndex > 0) {
|
||||
code = code.substring(0, separatorIndex).trim();
|
||||
}
|
||||
if ("CNY".equals(code)) {
|
||||
return "RMB";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseMap(String json) {
|
||||
if (Func.isEmpty(json)) {
|
||||
return new LinkedHashMap<>();
|
||||
@@ -2940,6 +3040,11 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private String formatQuantity(BigDecimal quantity, String unit) {
|
||||
// 无有效运输量(空/0/-1)时不展示数值与单位
|
||||
if (quantity == null || quantity.compareTo(BigDecimal.ZERO) == 0
|
||||
|| quantity.compareTo(BigDecimal.valueOf(-1)) == 0) {
|
||||
return "-";
|
||||
}
|
||||
return money(quantity).stripTrailingZeros().toPlainString() + (unit == null ? "" : " " + unit);
|
||||
}
|
||||
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.mapper.TransportCertRecordMapper;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 认证记录 服务实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
public class TransportCertRecordServiceImpl extends BaseServiceImpl<TransportCertRecordMapper, TransportCertRecord> implements ITransportCertRecordService {
|
||||
|
||||
/**
|
||||
* 合法的审核状态取值
|
||||
*/
|
||||
private static final Set<Integer> VALID_STATUS = Set.of(
|
||||
TransportCertRecord.STATUS_PENDING,
|
||||
TransportCertRecord.STATUS_APPROVED,
|
||||
TransportCertRecord.STATUS_REJECTED,
|
||||
TransportCertRecord.STATUS_SUPERSEDED
|
||||
);
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean save(TransportCertRecord transportCertRecord) {
|
||||
validateRecord(transportCertRecord);
|
||||
return super.save(transportCertRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveBatch(Collection<TransportCertRecord> entityList, int batchSize) {
|
||||
entityList.forEach(this::validateRecord);
|
||||
return super.saveBatch(entityList, batchSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveOrUpdateBatch(Collection<TransportCertRecord> entityList, int batchSize) {
|
||||
entityList.forEach(this::validateRecord);
|
||||
return super.saveOrUpdateBatch(entityList, batchSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用继承自 {@code BaseService} 的「修改状态」
|
||||
* <p>
|
||||
* ⚠️ 本表的 {@code status} 是<b>审核状态</b>,不是「启用 / 停用」。
|
||||
* 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status,
|
||||
* 调用 {@code changeStatus(ids, 1)} 即等于<b>批量通过审核</b>——
|
||||
* 审核人、审核时间、驳回原因全部缺失,既丢失审核留痕,也会凭空造出生效快照。
|
||||
* 审核必须逐条走带留痕的审核方法。
|
||||
*
|
||||
* @param ids 主键集合
|
||||
* @param status 状态
|
||||
* @return 永不返回,直接抛出
|
||||
*/
|
||||
@Override
|
||||
public boolean changeStatus(List<Long> ids, Integer status) {
|
||||
throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId) {
|
||||
return list(recordQuery(bizType, bizId)).stream()
|
||||
.map(TransportCertRecordWrapper.build()::entityVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecord selectPendingRecord(String bizType, Long bizId) {
|
||||
validateBizTarget(bizType, bizId);
|
||||
return getOne(Wrappers.<TransportCertRecord>lambdaQuery()
|
||||
.eq(TransportCertRecord::getBizType, bizType)
|
||||
.eq(TransportCertRecord::getBizId, bizId)
|
||||
.eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证对象检索条件:按提交时间倒序
|
||||
* <p>
|
||||
* 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之:
|
||||
* 认证记录的业务时间轴是<b>提交时间</b>,列表需按提交时间倒序展示每次提交。
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
* @return 检索条件
|
||||
*/
|
||||
private LambdaQueryWrapper<TransportCertRecord> recordQuery(String bizType, Long bizId) {
|
||||
validateBizTarget(bizType, bizId);
|
||||
return Wrappers.<TransportCertRecord>lambdaQuery()
|
||||
.eq(TransportCertRecord::getBizType, bizType)
|
||||
.eq(TransportCertRecord::getBizId, bizId)
|
||||
.orderByDesc(TransportCertRecord::getSubmitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证对象标识校验
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
*/
|
||||
private void validateBizTarget(String bizType, Long bizId) {
|
||||
if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) {
|
||||
throw new ServiceException("认证对象类型与主键不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录落库前的强制校验
|
||||
* <p>
|
||||
* ⚠️ 这里防的是框架的一个<b>静默</b>行为:{@code BaseServiceImpl#resolveEntity} 在插入时
|
||||
* 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1),
|
||||
* 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。
|
||||
* 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。
|
||||
* 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。
|
||||
*
|
||||
* @param transportCertRecord 待落库的认证记录
|
||||
*/
|
||||
private void validateRecord(TransportCertRecord transportCertRecord) {
|
||||
if (Func.isEmpty(transportCertRecord)) {
|
||||
throw new ServiceException("认证记录不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getBizType())) {
|
||||
throw new ServiceException("认证对象类型不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getBizId())) {
|
||||
throw new ServiceException("认证对象主键不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getSnapshotJson())) {
|
||||
throw new ServiceException("认证内容快照不能为空");
|
||||
}
|
||||
Integer status = transportCertRecord.getStatus();
|
||||
if (Func.isEmpty(status)) {
|
||||
throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)");
|
||||
}
|
||||
if (!VALID_STATUS.contains(status)) {
|
||||
throw new ServiceException("认证记录状态取值不正确");
|
||||
}
|
||||
if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) {
|
||||
throw new ServiceException("请输入认证驳回原因");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+17
-3
@@ -42,6 +42,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO;
|
||||
import org.springblade.transport.pojo.vo.TransportVehicleVO;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -51,7 +52,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,6 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicleMapper, TransportVehicle> implements ITransportVehicleService {
|
||||
|
||||
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
|
||||
private static final String PLATE_COLOR_BLUE = "蓝牌";
|
||||
private static final String PLATE_COLOR_YELLOW = "黄牌";
|
||||
private static final String PLATE_COLOR_GREEN = "绿牌";
|
||||
@@ -226,6 +225,11 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
vehicle.setRoadTransportCertImage(trimToNull(vehicle.getRoadTransportCertImage()));
|
||||
vehicle.setRegistrationImage(trimToNull(vehicle.getRegistrationImage()));
|
||||
vehicle.setRemark(trimToNull(vehicle.getRemark()));
|
||||
// BladeX 数字 null 常序列化为 -1;未填外廓尺寸/准牵引总质量不得落库占位值
|
||||
vehicle.setOuterLength(normalizeOptionalInteger(vehicle.getOuterLength()));
|
||||
vehicle.setOuterWidth(normalizeOptionalInteger(vehicle.getOuterWidth()));
|
||||
vehicle.setOuterHeight(normalizeOptionalInteger(vehicle.getOuterHeight()));
|
||||
vehicle.setTractionMassKg(normalizeOptionalInteger(vehicle.getTractionMassKg()));
|
||||
if (vehicle.getCompulsoryScrapLongTerm() == null) {
|
||||
vehicle.setCompulsoryScrapLongTerm(DEFAULT_FALSE);
|
||||
}
|
||||
@@ -266,7 +270,7 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
if (Func.isEmpty(vehicle.getPlateNo())) {
|
||||
throw new ServiceException("车牌号不能为空");
|
||||
}
|
||||
if (!PLATE_NO_PATTERN.matcher(vehicle.getPlateNo()).matches()) {
|
||||
if (!VehiclePlateSupport.isValidPlateNo(vehicle.getPlateNo())) {
|
||||
throw new ServiceException("车牌号格式不正确");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getPlateColor())) {
|
||||
@@ -353,6 +357,16 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选整型字段:null / -1 均视为未填写
|
||||
*/
|
||||
private Integer normalizeOptionalInteger(Integer value) {
|
||||
if (value == null || value == -1) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
+71
-3
@@ -88,6 +88,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
|
||||
private final ObjectProvider<MinioClient> minioClientProvider;
|
||||
private final String minioBucketName;
|
||||
private final String minioRootDirectory;
|
||||
private final String minioTransformEndpoint;
|
||||
private final String minioAccessKey;
|
||||
private final String minioSecretKey;
|
||||
private volatile MinioClient transformPresignClient;
|
||||
|
||||
public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService,
|
||||
IWaybillService waybillService,
|
||||
@@ -95,7 +99,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
|
||||
LoadingManageMapper loadingManageMapper,
|
||||
ApplicationEventPublisher eventPublisher, ObjectProvider<MinioClient> minioClientProvider,
|
||||
@Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName,
|
||||
@Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) {
|
||||
@Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory,
|
||||
@Value("${file.storage.minio.transform-endpoint:${minio.transform-endpoint:}}") String minioTransformEndpoint,
|
||||
@Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String minioAccessKey,
|
||||
@Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String minioSecretKey) {
|
||||
this.voucherWaybillBatchMapper = voucherWaybillBatchMapper;
|
||||
this.projectApplyService = projectApplyService;
|
||||
this.waybillService = waybillService;
|
||||
@@ -106,6 +113,9 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
|
||||
this.minioClientProvider = minioClientProvider;
|
||||
this.minioBucketName = minioBucketName;
|
||||
this.minioRootDirectory = minioRootDirectory;
|
||||
this.minioTransformEndpoint = minioTransformEndpoint;
|
||||
this.minioAccessKey = minioAccessKey;
|
||||
this.minioSecretKey = minioSecretKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1490,14 +1500,72 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
|
||||
|
||||
private String buildVoucherFileUrl(Long fileId, String objectKey) {
|
||||
try {
|
||||
return minioClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.GET).bucket(minioBucketName).object(objectKey).expiry(1, java.util.concurrent.TimeUnit.HOURS).build());
|
||||
String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.GET).bucket(minioBucketName).object(objectKey)
|
||||
.region("us-east-1")
|
||||
.expiry(1, java.util.concurrent.TimeUnit.HOURS).build());
|
||||
return applyTransformPathPrefix(presignedUrl);
|
||||
} catch (Exception exception) {
|
||||
log.warn("生成凭证文件预览地址失败 voucherFileId={}, objectKey={}", fileId, objectKey, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。
|
||||
* 外网域名通常是业务网关,必须固定 region,否则 SDK 会请求 GetBucketLocation 并解析到 HTML。
|
||||
*/
|
||||
private MinioClient resolvePresignClient() {
|
||||
if (Func.isEmpty(minioTransformEndpoint)) {
|
||||
return minioClient();
|
||||
}
|
||||
MinioClient cachedClient = transformPresignClient;
|
||||
if (cachedClient != null) {
|
||||
return cachedClient;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (transformPresignClient == null) {
|
||||
transformPresignClient = org.springblade.transport.config.VoucherMinioConfig.create(
|
||||
minioTransformEndpoint, minioAccessKey, minioSecretKey);
|
||||
}
|
||||
return transformPresignClient;
|
||||
}
|
||||
}
|
||||
|
||||
private String applyTransformPathPrefix(String presignedUrl) {
|
||||
if (Func.isEmpty(minioTransformEndpoint) || Func.isEmpty(presignedUrl)) {
|
||||
return presignedUrl;
|
||||
}
|
||||
java.net.URI transformUri = java.net.URI.create(minioTransformEndpoint.trim());
|
||||
String pathPrefix = normalizePathPrefix(transformUri.getPath());
|
||||
if (Func.isEmpty(pathPrefix)) {
|
||||
return presignedUrl;
|
||||
}
|
||||
java.net.URI url = java.net.URI.create(presignedUrl);
|
||||
String path = url.getRawPath() == null ? "" : url.getRawPath();
|
||||
if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) {
|
||||
return presignedUrl;
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append(url.getScheme()).append("://").append(url.getHost());
|
||||
if (url.getPort() > 0) {
|
||||
result.append(':').append(url.getPort());
|
||||
}
|
||||
result.append(pathPrefix).append(path);
|
||||
if (Func.isNotEmpty(url.getRawQuery())) {
|
||||
result.append('?').append(url.getRawQuery());
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private String normalizePathPrefix(String path) {
|
||||
if (Func.isEmpty(path) || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
|
||||
return normalized.startsWith("/") ? normalized : "/" + normalized;
|
||||
}
|
||||
|
||||
private String normalizePlateNo(String plateNo) {
|
||||
return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase();
|
||||
}
|
||||
|
||||
+102
-13
@@ -342,9 +342,19 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
.eq(WaybillImportBatch::getIsDeleted, 0)
|
||||
.eq(request.getId() != null, WaybillImportBatch::getId, request.getId())
|
||||
.like(Func.isNotEmpty(request.getBatchNo()), WaybillImportBatch::getBatchNo, request.getBatchNo())
|
||||
.apply(Func.isNotEmpty(request.getCarrierId()), "FIND_IN_SET({0}, carrier_ids)", request.getCarrierId())
|
||||
.eq(Func.isNotEmpty(request.getCarrierName()), WaybillImportBatch::getCarrierName, request.getCarrierName())
|
||||
.eq(Func.isNotEmpty(request.getCreateUser()), WaybillImportBatch::getCreateUser, request.getCreateUser())
|
||||
.orderByDesc(WaybillImportBatch::getCreateTime);
|
||||
// 历史批次可能只落了承运商名称、carrier_ids 为空,筛选时需同时按 ID 与名称兼容匹配
|
||||
applyCarrierFilter(queryWrapper, request.getCarrierId());
|
||||
if (Func.isNotEmpty(request.getCreateTimeStart())) {
|
||||
queryWrapper.ge(WaybillImportBatch::getCreateTime,
|
||||
DateUtil.parse(request.getCreateTimeStart(), DateUtil.PATTERN_DATETIME));
|
||||
}
|
||||
if (Func.isNotEmpty(request.getCreateTimeEnd())) {
|
||||
queryWrapper.le(WaybillImportBatch::getCreateTime,
|
||||
DateUtil.parse(request.getCreateTimeEnd(), DateUtil.PATTERN_DATETIME));
|
||||
}
|
||||
IPage<WaybillImportBatch> entityPage = page(page, queryWrapper);
|
||||
List<WaybillImportBatchVO> records = entityPage.getRecords().stream().map(this::toVO).toList();
|
||||
Page<WaybillImportBatchVO> resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
@@ -352,6 +362,24 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
return resultPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 承运商筛选:优先 FIND_IN_SET(carrier_ids),同时兼容仅有 carrier_name 的历史数据。
|
||||
*/
|
||||
private void applyCarrierFilter(LambdaQueryWrapper<WaybillImportBatch> queryWrapper, Long carrierId) {
|
||||
Long normalizedCarrierId = normalizeOptionalId(carrierId);
|
||||
if (normalizedCarrierId == null) {
|
||||
return;
|
||||
}
|
||||
CustomerArchive carrier = customerArchiveService.getById(normalizedCarrierId);
|
||||
String carrierName = carrier == null ? null : Func.toStr(carrier.getFullName()).trim();
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.apply("FIND_IN_SET({0}, IFNULL(carrier_ids,''))", String.valueOf(normalizedCarrierId));
|
||||
if (Func.isNotEmpty(carrierName)) {
|
||||
wrapper.or().eq(WaybillImportBatch::getCarrierName, carrierName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BusinessRemoveResultVO removeBatches(String ids) {
|
||||
@@ -396,8 +424,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
WaybillImportBatch batch = new WaybillImportBatch();
|
||||
BeanUtil.copyProperties(request, batch);
|
||||
batch.setBatchNo(Func.isEmpty(request.getId()) ? nextCode() : request.getBatchNo());
|
||||
batch.setCarrierIds(joinIds(request.getCarrierIds()));
|
||||
batch.setCarrierName(resolveCarrierNames(request.getCarrierIds(), request.getCarrierName()));
|
||||
List<Long> carrierIds = resolveCarrierIds(request);
|
||||
batch.setCarrierIds(joinIds(carrierIds));
|
||||
batch.setCarrierName(resolveCarrierNames(carrierIds, request.getCarrierName()));
|
||||
batch.setImportStatus(importStatus);
|
||||
batch.setImportType(IMPORT_TYPE_SETTLEMENT.equals(request.getImportType()) ? IMPORT_TYPE_SETTLEMENT : IMPORT_TYPE_WAYBILL);
|
||||
ProjectApply project = projectApplyService.getById(request.getProjectId());
|
||||
@@ -455,12 +484,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
waybill.setTrailerVehicleNo(stringValue(row, "trailerVehicleNo"));
|
||||
waybill.setEscortName(stringValue(row, "escortName"));
|
||||
waybill.setEscortPhone(stringValue(row, "escortPhone"));
|
||||
waybill.setMileage(normalizeImportMileage(decimalValue(
|
||||
waybill.setMileage(normalizeImportOptionalDecimal(decimalValue(
|
||||
row, "mileage", "里程", "里程(km)", "里程(公里)"
|
||||
)));
|
||||
waybill.setUnitPrice(decimalValue(row, "unitPrice", "单价"));
|
||||
waybill.setUnitPrice(normalizeImportOptionalDecimal(decimalValue(row, "unitPrice", "单价")));
|
||||
waybill.setPriceUnit(stringValue(row, "priceUnit"));
|
||||
waybill.setOtherFeeTotal(decimalValue(row, "otherFeeTotal", "其他费用合计"));
|
||||
waybill.setOtherFeeTotal(normalizeImportOptionalDecimal(decimalValue(row, "otherFeeTotal", "其他费用合计")));
|
||||
waybill.setRemark(stringValue(row, "remark"));
|
||||
waybill.setRelationNo(stringValue(row, "waybillIdentifier", "同一运单标识号"));
|
||||
waybill.setProjectId(batch.getProjectId());
|
||||
@@ -516,7 +545,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
|
||||
private BigDecimal decimalValue(Map<String, Object> row, String field, String fieldName, String... aliases) {
|
||||
String value = stringValue(row, field, aliases);
|
||||
if (Func.isEmpty(value)) return null;
|
||||
if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value);
|
||||
} catch (NumberFormatException exception) {
|
||||
@@ -524,8 +555,32 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal normalizeImportMileage(BigDecimal mileage) {
|
||||
return mileage != null && mileage.compareTo(BigDecimal.ONE.negate()) == 0 ? null : mileage;
|
||||
/**
|
||||
* BladeX 数字 null 常被序列化为 -1;导入未填的可选金额/里程不得按 -1 落库。
|
||||
*/
|
||||
private BigDecimal normalizeImportOptionalDecimal(BigDecimal value) {
|
||||
return isImportOptionalNumberEmpty(value) ? null : value;
|
||||
}
|
||||
|
||||
private boolean isImportOptionalNumberEmpty(Object value) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
if (value instanceof BigDecimal bigDecimal) {
|
||||
return bigDecimal.compareTo(BigDecimal.ONE.negate()) == 0;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return Double.compare(number.doubleValue(), -1D) == 0;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isEmpty(text)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text).compareTo(BigDecimal.ONE.negate()) == 0;
|
||||
} catch (NumberFormatException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate parseDate(Object value, String fieldName, boolean required, String... aliases) {
|
||||
@@ -551,6 +606,38 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
return Func.toLongList(carrierIds).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析承运商 ID 列表:优先 carrierIds,其次 carrierId,最后按承运商名称回查客商档案。
|
||||
*/
|
||||
private List<Long> resolveCarrierIds(WaybillImportBatchRequest request) {
|
||||
if (request == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> carrierIds = Func.isEmpty(request.getCarrierIds())
|
||||
? new ArrayList<>()
|
||||
: request.getCarrierIds().stream()
|
||||
.map(this::normalizeOptionalId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
if (Func.isNotEmpty(carrierIds)) {
|
||||
return carrierIds;
|
||||
}
|
||||
Long carrierId = normalizeOptionalId(request.getCarrierId());
|
||||
if (carrierId != null) {
|
||||
return List.of(carrierId);
|
||||
}
|
||||
String carrierName = Func.toStr(request.getCarrierName()).trim();
|
||||
if (Func.isEmpty(carrierName)) {
|
||||
return List.of();
|
||||
}
|
||||
CustomerArchive carrier = customerArchiveService.getOne(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.eq(CustomerArchive::getFullName, carrierName)
|
||||
.eq(CustomerArchive::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
return carrier == null || carrier.getId() == null ? List.of() : List.of(carrier.getId());
|
||||
}
|
||||
|
||||
private String resolveCarrierNames(List<Long> carrierIds, String carrierName) {
|
||||
if (Func.isNotEmpty(carrierName)) return carrierName;
|
||||
if (Func.isEmpty(carrierIds)) return null;
|
||||
@@ -809,7 +896,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
|
||||
private void validatePositiveNumber(Map<String, Object> row, String field, String fieldName, List<String> errors) {
|
||||
String value = stringValue(row, field, fieldName);
|
||||
if (Func.isEmpty(value)) {
|
||||
if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,7 +923,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
|
||||
private void validateFreightTotal(Map<String, Object> row, List<String> errors) {
|
||||
String freightTotalStr = stringValue(row, "freightTotal", "运费合计");
|
||||
if (Func.isEmpty(freightTotalStr)) {
|
||||
if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -845,8 +932,10 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
String freightStr = stringValue(row, "freight", "运费");
|
||||
String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计");
|
||||
|
||||
BigDecimal freight = Func.isNotEmpty(freightStr) ? new BigDecimal(freightStr) : BigDecimal.ZERO;
|
||||
BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) ? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO;
|
||||
BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr)
|
||||
? new BigDecimal(freightStr) : BigDecimal.ZERO;
|
||||
BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr)
|
||||
? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO;
|
||||
|
||||
BigDecimal calculatedTotal = freight.add(otherFeeTotal);
|
||||
if (freightTotal.compareTo(calculatedTotal) != 0) {
|
||||
|
||||
+449
-19
@@ -28,9 +28,11 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
@@ -49,6 +51,7 @@ import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
|
||||
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillLocateVO;
|
||||
@@ -97,6 +100,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
|
||||
private static final String STATUS_DRAFT = "draft";
|
||||
|
||||
/** 打卡记录类型:在途打卡(其余一律按过程节点打卡处理) */
|
||||
private static final String ENROUTE_PUNCH_TYPE = "enroute";
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private ILoadingManageService loadingManageService;
|
||||
|
||||
@@ -452,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
continue;
|
||||
}
|
||||
|
||||
// 接单节点不落打卡流水:它是否完成由运单的接单事实决定,不看打卡表。
|
||||
// 司机确认接单即为已打卡;项目「无需确认接单」时,运单进入进行中即视为该环节已完成。
|
||||
// 后者没有接单动作,也就没有完成时刻,时间栏留空——运单上没有任何字段记着那一刻。
|
||||
if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) {
|
||||
boolean accepted = WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus());
|
||||
boolean noConfirm = !WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson);
|
||||
if (accepted) {
|
||||
item.setPunched(true);
|
||||
item.setStatusName("已打卡");
|
||||
item.setPunchTime(formatPunchTime(waybill.getDriverAcceptTime()));
|
||||
} else if (noConfirm && passedAcceptStage(waybill.getBusinessStatus())) {
|
||||
item.setPunched(true);
|
||||
item.setStatusName("已打卡");
|
||||
item.setPunchTime("");
|
||||
} else {
|
||||
item.setPunched(false);
|
||||
item.setStatusName("未打卡");
|
||||
item.setPunchTime("");
|
||||
}
|
||||
records.add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName);
|
||||
if (punched != null) {
|
||||
List<String> types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName);
|
||||
@@ -483,6 +512,300 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean savePunchRecord(WaybillPunchRecordSaveRequest request) {
|
||||
if (request == null || request.getWaybillId() == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
Waybill waybill = getById(request.getWaybillId());
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("运单不存在");
|
||||
}
|
||||
// 只有显式传 false 才是取消打卡,未传按已打卡处理
|
||||
boolean punched = !Boolean.FALSE.equals(request.getPunched());
|
||||
if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
|
||||
return saveEnroutePunchRecord(waybill, request, punched);
|
||||
}
|
||||
return saveNodePunchRecord(waybill, request, punched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过程节点打卡:已打卡补录/修正,未打卡清除该节点流水。
|
||||
*/
|
||||
private boolean saveNodePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) {
|
||||
Long waybillId = waybill.getId();
|
||||
String processJson = resolveLiveProcessJson(waybill);
|
||||
Map<String, Object> nodeConfig = findEnabledNodeConfig(processJson, request.getNodeCode(), request.getNodeName());
|
||||
// 节点以过程配置为准:配置改过 key/name 后,前端带上来的可能是旧值
|
||||
String nodeCode = nodeConfig == null
|
||||
? TransportBusinessSupport.trimToEmpty(request.getNodeCode())
|
||||
: WaybillProcessSupport.nodeKey(nodeConfig);
|
||||
String nodeName = nodeConfig == null
|
||||
? TransportBusinessSupport.trimToEmpty(request.getNodeName())
|
||||
: WaybillProcessSupport.nodeName(nodeConfig);
|
||||
if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) {
|
||||
throw new ServiceException("过程节点不能为空");
|
||||
}
|
||||
// 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。
|
||||
// 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。
|
||||
if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) {
|
||||
throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡");
|
||||
}
|
||||
List<WaybillNodePunch> rows = nodePunchRows(waybillId, nodeCode, nodeName);
|
||||
if (!punched) {
|
||||
if (!rows.isEmpty()) {
|
||||
waybillNodePunchMapper.deleteBatchIds(rows.stream().map(WaybillNodePunch::getId).toList());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
WaybillNodePunch punch = pickLatestNodePunch(rows, nodeCode, nodeName);
|
||||
boolean isNew = punch == null;
|
||||
if (isNew) {
|
||||
punch = new WaybillNodePunch();
|
||||
punch.setWaybillId(waybillId);
|
||||
punch.setWaybillNo(waybill.getWaybillNo());
|
||||
// 管理端补录没有打卡司机,driver_id 留空,不记到任何司机头上
|
||||
punch.setExceptionFlag(0);
|
||||
}
|
||||
if (Func.isNotEmpty(nodeCode)) {
|
||||
punch.setNodeCode(nodeCode);
|
||||
}
|
||||
if (Func.isNotEmpty(nodeName)) {
|
||||
punch.setNodeName(nodeName);
|
||||
}
|
||||
punch.setPunchTime(parsePunchTime(request.getPunchTime()));
|
||||
punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress()));
|
||||
punch.setLongitude(parseCoordinate(request.getLongitude(), "经度"));
|
||||
punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度"));
|
||||
punch.setWeight(TransportBusinessSupport.trimToNull(request.getWeight()));
|
||||
punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume()));
|
||||
punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity()));
|
||||
punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark()));
|
||||
List<Map<String, String>> photoItems = punchPhotoItems(request.getPhotos());
|
||||
assertPunchPhotoTypeLimit(photoItems);
|
||||
punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems));
|
||||
// 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护
|
||||
if (isNew) {
|
||||
waybillNodePunchMapper.insert(punch);
|
||||
} else {
|
||||
updateNodePunchFields(punch);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式按字段更新节点打卡。
|
||||
* <p>
|
||||
* 不能用 updateById:它按「非空才更新」生成 SQL,把地址/货量/照片清空时这些字段为 null,
|
||||
* 会被整条跳过——界面上删掉的内容保存后还在。
|
||||
*/
|
||||
private void updateNodePunchFields(WaybillNodePunch punch) {
|
||||
waybillNodePunchMapper.update(null, Wrappers.<WaybillNodePunch>lambdaUpdate()
|
||||
.eq(WaybillNodePunch::getId, punch.getId())
|
||||
.set(WaybillNodePunch::getNodeCode, punch.getNodeCode())
|
||||
.set(WaybillNodePunch::getNodeName, punch.getNodeName())
|
||||
.set(WaybillNodePunch::getPunchTime, punch.getPunchTime())
|
||||
.set(WaybillNodePunch::getAddress, punch.getAddress())
|
||||
.set(WaybillNodePunch::getLongitude, punch.getLongitude())
|
||||
.set(WaybillNodePunch::getLatitude, punch.getLatitude())
|
||||
.set(WaybillNodePunch::getWeight, punch.getWeight())
|
||||
.set(WaybillNodePunch::getVolume, punch.getVolume())
|
||||
.set(WaybillNodePunch::getQuantity, punch.getQuantity())
|
||||
.set(WaybillNodePunch::getRemark, punch.getRemark())
|
||||
.set(WaybillNodePunch::getPhotos, punch.getPhotos())
|
||||
// entity 传 null,MP 不会自动填充审计字段,这里补上
|
||||
.set(WaybillNodePunch::getUpdateTime, new Date())
|
||||
.set(WaybillNodePunch::getUpdateUser, AuthUtil.getUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在途打卡:时间轴上的在途节点只展示最新一条流水。
|
||||
*/
|
||||
private boolean saveEnroutePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) {
|
||||
Long waybillId = waybill.getId();
|
||||
List<WaybillEnroutePunch> rows = waybillEnroutePunchMapper.selectList(Wrappers.<WaybillEnroutePunch>lambdaQuery()
|
||||
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
|
||||
.orderByAsc(WaybillEnroutePunch::getPunchTime)
|
||||
.orderByAsc(WaybillEnroutePunch::getId));
|
||||
if (!punched) {
|
||||
// 在途节点没有节点 key 可定位单条,取消打卡即清空该运单全部在途流水,
|
||||
// 否则更早的流水会顶上来继续显示已打卡
|
||||
if (!rows.isEmpty()) {
|
||||
waybillEnroutePunchMapper.deleteBatchIds(rows.stream().map(WaybillEnroutePunch::getId).toList());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
WaybillEnroutePunch punch = rows.isEmpty() ? null : rows.get(rows.size() - 1);
|
||||
boolean isNew = punch == null;
|
||||
if (isNew) {
|
||||
punch = new WaybillEnroutePunch();
|
||||
punch.setWaybillId(waybillId);
|
||||
punch.setWaybillNo(waybill.getWaybillNo());
|
||||
}
|
||||
punch.setPunchTime(parsePunchTime(request.getPunchTime()));
|
||||
punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress()));
|
||||
punch.setLongitude(parseCoordinate(request.getLongitude(), "经度"));
|
||||
punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度"));
|
||||
// 在途打卡表只有一个照片字段(单张),多张只保留第一张
|
||||
punch.setPhoto(firstPunchPhotoUrl(request.getPhotos()));
|
||||
if (isNew) {
|
||||
waybillEnroutePunchMapper.insert(punch);
|
||||
} else {
|
||||
// 同节点打卡:显式 set,否则清空地址/照片保存后不生效
|
||||
waybillEnroutePunchMapper.update(null, Wrappers.<WaybillEnroutePunch>lambdaUpdate()
|
||||
.eq(WaybillEnroutePunch::getId, punch.getId())
|
||||
.set(WaybillEnroutePunch::getPunchTime, punch.getPunchTime())
|
||||
.set(WaybillEnroutePunch::getAddress, punch.getAddress())
|
||||
.set(WaybillEnroutePunch::getLongitude, punch.getLongitude())
|
||||
.set(WaybillEnroutePunch::getLatitude, punch.getLatitude())
|
||||
.set(WaybillEnroutePunch::getPhoto, punch.getPhoto())
|
||||
.set(WaybillEnroutePunch::getUpdateTime, new Date())
|
||||
.set(WaybillEnroutePunch::getUpdateUser, AuthUtil.getUserId()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该节点名下的全部打卡流水(含历史),按打卡时间升序。
|
||||
* <p>
|
||||
* 取消打卡要清空全部,只软删最新一条会让更早的历史流水顶上来继续显示已打卡。
|
||||
*/
|
||||
private List<WaybillNodePunch> nodePunchRows(Long waybillId, String nodeCode, String nodeName) {
|
||||
return waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId)
|
||||
.orderByAsc(WaybillNodePunch::getPunchTime)
|
||||
.orderByAsc(WaybillNodePunch::getId))
|
||||
.stream()
|
||||
.filter(punch -> {
|
||||
String rowCode = TransportBusinessSupport.trimToEmpty(punch.getNodeCode());
|
||||
String rowName = TransportBusinessSupport.trimToEmpty(punch.getNodeName());
|
||||
if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(rowCode)) {
|
||||
return true;
|
||||
}
|
||||
return Func.isNotEmpty(nodeName) && nodeName.equals(rowName);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该节点最新一条流水,口径与展示层一致:nodeCode 命中优先,未命中才回退 nodeName。
|
||||
*/
|
||||
private WaybillNodePunch pickLatestNodePunch(List<WaybillNodePunch> rows, String nodeCode, String nodeName) {
|
||||
WaybillNodePunch byCode = null;
|
||||
WaybillNodePunch byName = null;
|
||||
for (WaybillNodePunch row : rows) {
|
||||
if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(row.getNodeCode()))) {
|
||||
byCode = row;
|
||||
} else if (Func.isNotEmpty(nodeName) && nodeName.equals(TransportBusinessSupport.trimToEmpty(row.getNodeName()))) {
|
||||
byName = row;
|
||||
}
|
||||
}
|
||||
return byCode != null ? byCode : byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过程配置中启用(未启用时不参与打卡,补录无从校验)的节点配置;找不到返回 null。
|
||||
*/
|
||||
private Map<String, Object> findEnabledNodeConfig(String processJson, String nodeCode, String nodeName) {
|
||||
String code = TransportBusinessSupport.trimToEmpty(nodeCode);
|
||||
String name = TransportBusinessSupport.trimToEmpty(nodeName);
|
||||
List<Map<String, Object>> nodes = WaybillProcessSupport.listEnabledProcessNodes(processJson);
|
||||
if (nodes.isEmpty()) {
|
||||
nodes = WaybillProcessSupport.listDriverPunchNodes(processJson);
|
||||
}
|
||||
for (Map<String, Object> node : nodes) {
|
||||
if (Func.isNotEmpty(code) && code.equalsIgnoreCase(WaybillProcessSupport.nodeKey(node))) {
|
||||
return node;
|
||||
}
|
||||
if (Func.isNotEmpty(name) && name.equals(WaybillProcessSupport.nodeName(node))) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。
|
||||
*/
|
||||
private List<Map<String, String>> punchPhotoItems(List<WaybillPunchRecordSaveRequest.Photo> photos) {
|
||||
List<Map<String, String>> items = new ArrayList<>();
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
|
||||
String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
|
||||
if (Func.isEmpty(url)) {
|
||||
continue;
|
||||
}
|
||||
String type = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getType());
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
item.put("type", Func.isEmpty(type) ? ("凭证" + (items.size() + 1)) : type);
|
||||
item.put("url", url);
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言打卡照片每种类型都不超过单类上限。
|
||||
* <p>
|
||||
* 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。
|
||||
* 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。
|
||||
*/
|
||||
private void assertPunchPhotoTypeLimit(List<Map<String, String>> photoItems) {
|
||||
Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||
for (Map<String, String> item : photoItems) {
|
||||
countByType.merge(item.get("type"), 1, Integer::sum);
|
||||
}
|
||||
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
|
||||
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
|
||||
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
|
||||
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String firstPunchPhotoUrl(List<WaybillPunchRecordSaveRequest.Photo> photos) {
|
||||
if (photos == null) {
|
||||
return null;
|
||||
}
|
||||
for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
|
||||
String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
|
||||
if (Func.isNotEmpty(url)) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打卡时间为空表示「此刻补录」;格式非法直接报错,不静默改成当前时间。
|
||||
*/
|
||||
private Date parsePunchTime(String text) {
|
||||
String value = TransportBusinessSupport.trimToEmpty(text);
|
||||
if (Func.isEmpty(value)) {
|
||||
return new Date();
|
||||
}
|
||||
try {
|
||||
return DateUtil.parse(value, DateUtil.PATTERN_DATETIME);
|
||||
} catch (Exception exception) {
|
||||
throw new ServiceException("打卡时间格式不正确,应为 yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal parseCoordinate(String text, String label) {
|
||||
String value = TransportBusinessSupport.trimToEmpty(text);
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new ServiceException(label + "格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private WaybillNodePunch findLatestNodePunch(Map<String, WaybillNodePunch> index, String nodeCode, String nodeName) {
|
||||
if (index == null || index.isEmpty()) {
|
||||
return null;
|
||||
@@ -636,6 +959,18 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return photo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单是否已越过接单阶段。
|
||||
* <p>
|
||||
* 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、
|
||||
* 调度中这些并未越过接单的阶段,取反会把它们判成已接单。
|
||||
*/
|
||||
private static boolean passedAcceptStage(String businessStatus) {
|
||||
return "running".equals(businessStatus)
|
||||
|| "completed".equals(businessStatus)
|
||||
|| "cancelled".equals(businessStatus);
|
||||
}
|
||||
|
||||
private String formatPunchTime(Date time) {
|
||||
if (time == null) {
|
||||
return "";
|
||||
@@ -727,10 +1062,13 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
waybill.setMasterNo(null);
|
||||
waybill.setMileageRemark(null);
|
||||
}
|
||||
// 新建若已带业务状态(如复制提交)则在过程校正后回写,保证与原运单一致
|
||||
// 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致
|
||||
String preservedBusinessStatus = created
|
||||
? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus())
|
||||
: null;
|
||||
if (isCompletedBusinessStatus(preservedBusinessStatus)) {
|
||||
preservedBusinessStatus = "completed";
|
||||
}
|
||||
// 无过程快照时按项目回填;再按接单设置决定 pending / running
|
||||
fillProjectProcessConfig(waybill);
|
||||
prepare(waybill);
|
||||
@@ -1006,36 +1344,48 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
target.setFreightJson(source.getFreightJson());
|
||||
target.setAttachmentsJson(source.getAttachmentsJson());
|
||||
target.setRemark(source.getRemark());
|
||||
// 复制后业务状态与原运单保持一致
|
||||
String sourceBusinessStatus = source.getBusinessStatus();
|
||||
target.setBusinessStatus(sourceBusinessStatus);
|
||||
// 复制新建固定为草稿,不沿用原运单业务状态
|
||||
target.setBusinessStatus(STATUS_DRAFT);
|
||||
target.setWaybillNo(nextCode());
|
||||
clearDriverAcceptRecord(target);
|
||||
fillProjectProcessConfig(target);
|
||||
prepare(target);
|
||||
applyDriverAcceptBusinessStatus(target);
|
||||
if (Func.isNotEmpty(sourceBusinessStatus)) {
|
||||
target.setBusinessStatus(sourceBusinessStatus);
|
||||
}
|
||||
target.setBusinessStatus(STATUS_DRAFT);
|
||||
validate(target);
|
||||
save(target);
|
||||
// 原运单已完成时,复制后按合同系统计费规则生成应收应付明细
|
||||
generateReceivablePayableIfCompleted(target);
|
||||
return detail(target.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建/复制运单若业务状态为已完成,按合同系统计费模式生成应收、应付明细。
|
||||
* <p>使用同事务入口,避免 REQUIRES_NEW 读不到尚未提交的新运单。</p>
|
||||
* 新建/复制运单若业务状态为已完成,按与「完成运单」相同的合同系统计费规则生成应收、应付明细。
|
||||
* <p>使用同事务入口 {@link IReceivablePayableDetailService#generateForImportedWaybills},
|
||||
* 避免 {@code generateForCompletedWaybills} 的 REQUIRES_NEW 读不到尚未提交的新运单。</p>
|
||||
*/
|
||||
private void generateReceivablePayableIfCompleted(Waybill waybill) {
|
||||
if (waybill == null || Func.isEmpty(waybill.getId())) {
|
||||
return;
|
||||
}
|
||||
if (!"completed".equals(waybill.getBusinessStatus())) {
|
||||
Waybill saved = getById(waybill.getId());
|
||||
if (saved == null || Objects.equals(saved.getIsDeleted(), 1)) {
|
||||
return;
|
||||
}
|
||||
receivablePayableDetailService.generateForImportedWaybills(List.of(waybill));
|
||||
String businessStatus = TransportBusinessSupport.trimToNull(saved.getBusinessStatus());
|
||||
if (Func.isEmpty(businessStatus)) {
|
||||
businessStatus = TransportBusinessSupport.trimToNull(waybill.getBusinessStatus());
|
||||
}
|
||||
if (!isCompletedBusinessStatus(businessStatus)) {
|
||||
return;
|
||||
}
|
||||
// 统一落库英文状态码,与完成运单、列表展示口径一致
|
||||
if (!"completed".equals(businessStatus)) {
|
||||
saved.setBusinessStatus("completed");
|
||||
updateById(saved);
|
||||
}
|
||||
receivablePayableDetailService.generateForImportedWaybills(List.of(saved));
|
||||
}
|
||||
|
||||
private static boolean isCompletedBusinessStatus(String businessStatus) {
|
||||
return "completed".equals(businessStatus) || "已完成".equals(businessStatus);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1458,7 +1808,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整录入多货物时,运单主表 quantity 按 goodsJson 数量合计回写,避免只落首行数量。
|
||||
* 从 goodsJson 回写运单主表货物字段:
|
||||
* 1. 顶层数量单位 / 货物名称 / 货物类型为空时,取首条有效货物补齐;
|
||||
* 2. 完整录入多货物时,数量按 goodsJson 合计回写。
|
||||
*/
|
||||
private void syncQuantityFromGoodsJson(Waybill waybill) {
|
||||
if (waybill == null || Func.isEmpty(waybill.getGoodsJson())) {
|
||||
@@ -1470,7 +1822,53 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
if (Func.isEmpty(goodsList) || goodsList.size() <= 1) {
|
||||
if (Func.isEmpty(goodsList)) {
|
||||
return;
|
||||
}
|
||||
Map firstGoods = null;
|
||||
for (Map goods : goodsList) {
|
||||
if (goods == null) {
|
||||
continue;
|
||||
}
|
||||
firstGoods = goods;
|
||||
break;
|
||||
}
|
||||
if (firstGoods != null) {
|
||||
if (Func.isEmpty(waybill.getQuantityUnit())) {
|
||||
String quantityUnit = firstNonBlank(firstGoods, "quantityUnit", "goodsQuantityUnit", "unit");
|
||||
if (Func.isNotEmpty(quantityUnit)) {
|
||||
waybill.setQuantityUnit(quantityUnit);
|
||||
}
|
||||
}
|
||||
if (Func.isEmpty(waybill.getCargoName())) {
|
||||
String cargoName = firstNonBlank(firstGoods, "cargoName", "goodsName");
|
||||
if (Func.isNotEmpty(cargoName)) {
|
||||
waybill.setCargoName(cargoName);
|
||||
}
|
||||
}
|
||||
if (Func.isEmpty(waybill.getCargoType())) {
|
||||
String cargoType = firstNonBlank(firstGoods, "cargoType", "goodsType");
|
||||
if (Func.isNotEmpty(cargoType)) {
|
||||
waybill.setCargoType(cargoType);
|
||||
}
|
||||
}
|
||||
if (waybill.getQuantity() == null && goodsList.size() == 1) {
|
||||
Object quantityValue = firstGoods.get("quantity");
|
||||
if (quantityValue == null) {
|
||||
quantityValue = firstGoods.get("cargoQuantity");
|
||||
}
|
||||
if (quantityValue == null) {
|
||||
quantityValue = firstGoods.get("goodsQuantity");
|
||||
}
|
||||
if (quantityValue != null && Func.isNotEmpty(String.valueOf(quantityValue).trim())) {
|
||||
BigDecimal quantity = new BigDecimal(String.valueOf(quantityValue).trim());
|
||||
if (!isSentinelMinusOne(quantity)) {
|
||||
waybill.setQuantity(quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (goodsList.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
@@ -1491,6 +1889,23 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNonBlank(Map goods, String... keys) {
|
||||
if (goods == null || keys == null) {
|
||||
return null;
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object value = goods.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isNotEmpty(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void validate(Waybill waybill) {
|
||||
TransportBusinessSupport.validateRequired(waybill.getProjectName(), "项目不能为空");
|
||||
TransportBusinessSupport.validateRequired(waybill.getContractName(), "客户合同不能为空");
|
||||
@@ -1505,7 +1920,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
if (isRoadTransport(waybill.getTransportType())) {
|
||||
if ("承运商".equals(waybill.getCarrierType())) {
|
||||
TransportBusinessSupport.validateRequired(waybill.getCarrierName(), "承运商不能为空");
|
||||
} else {
|
||||
} else if (!isBatchImport(waybill)) {
|
||||
TransportBusinessSupport.validateRequired(waybill.getDriverName(), "司机不能为空");
|
||||
}
|
||||
}
|
||||
@@ -1658,6 +2073,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return value.contains("公路") || value.contains("道路") || value.contains("road") || "gl".equals(value);
|
||||
}
|
||||
|
||||
private boolean isBatchImport(Waybill waybill) {
|
||||
return waybill != null && TransportBusinessSupport.DATA_SOURCE_BATCH_IMPORT.equals(waybill.getDataSource());
|
||||
}
|
||||
|
||||
private void fillJsonFromFlatFields(Waybill waybill) {
|
||||
if (Func.isEmpty(waybill.getTaskEntryMode())) {
|
||||
waybill.setTaskEntryMode("simple");
|
||||
@@ -1811,9 +2230,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
|
||||
private void putIfNotEmpty(Map<String, Object> map, String key, Object value) {
|
||||
if (Func.isNotEmpty(value)) {
|
||||
map.put(key, value);
|
||||
if (Func.isEmpty(value) || isSentinelMinusOneValue(value)) {
|
||||
return;
|
||||
}
|
||||
map.put(key, value);
|
||||
}
|
||||
|
||||
private boolean isSentinelMinusOneValue(Object value) {
|
||||
if (value instanceof BigDecimal bigDecimal) {
|
||||
return isSentinelMinusOne(bigDecimal);
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return Double.compare(number.doubleValue(), -1D) == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private synchronized String nextCode() {
|
||||
|
||||
+62
@@ -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.support;
|
||||
|
||||
/**
|
||||
* 认证审核的权限表达式
|
||||
* <p>
|
||||
* 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑:
|
||||
* 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}),
|
||||
* 以及会漏掉实际承担审核的岗位(例如「运管」)。
|
||||
* <p>
|
||||
* 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口
|
||||
* 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。
|
||||
* <p>
|
||||
* 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun},
|
||||
* 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。
|
||||
* {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式,
|
||||
* 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class CertAuditPermissions {
|
||||
|
||||
/**
|
||||
* 待审列表与审核详情:司机或车辆任一审核菜单即可
|
||||
*/
|
||||
public static final String AUDIT_VIEW =
|
||||
"hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')";
|
||||
|
||||
/**
|
||||
* 审核动作:司机或车辆任一「审核」按钮权限即可
|
||||
*/
|
||||
public static final String AUDIT_ACTION =
|
||||
"hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')";
|
||||
|
||||
private CertAuditPermissions() {
|
||||
}
|
||||
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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.support;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 认证可用性门槛
|
||||
* <p>
|
||||
* 在运营状态(启用 / 停用)之上叠加一层「是否已认证」:
|
||||
* 车辆需运营状态为启用且存在生效快照;司机需存在生效快照。
|
||||
* <p>
|
||||
* 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存
|
||||
* 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。
|
||||
* <p>
|
||||
* 两个开关默认开启,可关掉以回退到不校验认证的行为:
|
||||
* <pre>
|
||||
* transport:
|
||||
* certification:
|
||||
* vehicle-gate-enabled: false
|
||||
* driver-gate-enabled: false
|
||||
* </pre>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Component
|
||||
@Getter
|
||||
@Setter
|
||||
public class CertificationGate {
|
||||
|
||||
/**
|
||||
* 车辆认证门槛开关
|
||||
*/
|
||||
@Value("${transport.certification.vehicle-gate-enabled:true}")
|
||||
private boolean vehicleGateEnabled = true;
|
||||
|
||||
/**
|
||||
* 司机认证门槛开关
|
||||
*/
|
||||
@Value("${transport.certification.driver-gate-enabled:true}")
|
||||
private boolean driverGateEnabled = true;
|
||||
|
||||
/**
|
||||
* 判定车辆是否可用
|
||||
* <p>
|
||||
* 运营状态优先:停用的车即便已认证也不可用(认证门槛只叠加,不覆盖运营状态)。
|
||||
*
|
||||
* @param enabledState 运营状态:TRUE 启用
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 是否可用
|
||||
*/
|
||||
public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) {
|
||||
return vehicleUnavailableReason(enabledState, effectiveRecordId) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 车辆不可用的原因
|
||||
* <p>
|
||||
* 停用与未认证是两件事,处置方式也不同:一个要找调度启用车辆,一个要去提交认证。
|
||||
* 合并成同一句话,会把「车已认证、只是被停用」误导成「认证没通过」,
|
||||
* 让人白做一轮重新认证。
|
||||
*
|
||||
* @param enabledState 运营状态:TRUE 启用
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 不可用原因;可用时返回 null
|
||||
*/
|
||||
public String vehicleUnavailableReason(Boolean enabledState, Long effectiveRecordId) {
|
||||
if (!Boolean.TRUE.equals(enabledState)) {
|
||||
return "该车辆已停用,暂不可接单,请联系调度启用";
|
||||
}
|
||||
if (!vehicleGateEnabled) {
|
||||
return null;
|
||||
}
|
||||
return CertificationRuleSupport.isCertified(effectiveRecordId)
|
||||
? null
|
||||
: "该车辆尚未通过认证审核,暂不可接单,请先提交认证";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定司机是否可接单
|
||||
*
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 是否可用
|
||||
*/
|
||||
public boolean isDriverAvailable(Long effectiveRecordId) {
|
||||
if (!driverGateEnabled) {
|
||||
return true;
|
||||
}
|
||||
return CertificationRuleSupport.isCertified(effectiveRecordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 司机不可用的提示文案
|
||||
*
|
||||
* @return 面向司机的中文提示
|
||||
*/
|
||||
public String driverUnavailableReason() {
|
||||
return "您尚未通过认证审核,暂不可接单,请先完成认证";
|
||||
}
|
||||
|
||||
}
|
||||
+180
@@ -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>
|
||||
* 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.support;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
|
||||
/**
|
||||
* 认证记录状态机规则
|
||||
* <p>
|
||||
* 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。
|
||||
* 这几条规则是整个认证改造的地基,改坏的后果是线上事故:
|
||||
* 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class CertificationRuleSupport {
|
||||
|
||||
private static final String STATUS_TEXT_PENDING = "审核中";
|
||||
private static final String STATUS_TEXT_APPROVED = "已通过";
|
||||
private static final String STATUS_TEXT_REJECTED = "已驳回";
|
||||
private static final String STATUS_TEXT_SUPERSEDED = "已作废";
|
||||
private static final String STATUS_TEXT_UNKNOWN = "未知状态";
|
||||
|
||||
private CertificationRuleSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条新提交的记录准备成待审
|
||||
* <p>
|
||||
* 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案,
|
||||
* 否则前端会显示「审核中」却带着旧的驳回原因。
|
||||
*
|
||||
* @param incoming 待入库的记录
|
||||
*/
|
||||
public static void prepareForSubmit(TransportCertRecord incoming) {
|
||||
incoming.setStatus(TransportCertRecord.STATUS_PENDING);
|
||||
incoming.setRejectReason(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 再次提交时,把该认证对象**前一条待审**记录转为已作废
|
||||
* <p>
|
||||
* 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。
|
||||
* 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。
|
||||
*
|
||||
* @param previousPending 该对象当前那条待审记录,可为 null
|
||||
* @return 需要更新为已作废的记录;无需更新时返回 null
|
||||
*/
|
||||
public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) {
|
||||
if (previousPending == null) {
|
||||
return null;
|
||||
}
|
||||
if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED);
|
||||
return previousPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定认证对象是否已认证
|
||||
* <p>
|
||||
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
|
||||
* 重新提交、待审、被驳回都不会让对象变回未认证。
|
||||
*
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 是否已认证
|
||||
*/
|
||||
public static boolean isCertified(Long effectiveRecordId) {
|
||||
return Func.isNotEmpty(effectiveRecordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定一条记录是否可被审核
|
||||
* <p>
|
||||
* 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏
|
||||
* (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。
|
||||
*
|
||||
* @param record 认证记录
|
||||
* @return 是否可审核
|
||||
*/
|
||||
public static boolean isAuditable(TransportCertRecord record) {
|
||||
return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验审核入参是否合法
|
||||
*
|
||||
* @param pass 是否通过
|
||||
* @param rejectReason 驳回原因
|
||||
* @return 是否合法
|
||||
*/
|
||||
public static boolean isRejectReasonValid(boolean pass, String rejectReason) {
|
||||
if (pass) {
|
||||
return true;
|
||||
}
|
||||
String reason = Func.toStr(rejectReason, "").trim();
|
||||
return Func.isNotEmpty(reason)
|
||||
&& reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案
|
||||
*
|
||||
* @return 恒为 null
|
||||
*/
|
||||
public static String reasonForApprove() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回时的驳回原因:去首尾空白后落库
|
||||
*
|
||||
* @param rejectReason 原始原因
|
||||
* @return 去空白后的原因
|
||||
*/
|
||||
public static String reasonForReject(String rejectReason) {
|
||||
return Func.toStr(rejectReason, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态对应的中文文案
|
||||
* <p>
|
||||
* 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。
|
||||
*
|
||||
* @param status 状态值
|
||||
* @return 中文文案
|
||||
*/
|
||||
public static String statusText(Integer status) {
|
||||
if (TransportCertRecord.STATUS_PENDING.equals(status)) {
|
||||
return STATUS_TEXT_PENDING;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_APPROVED.equals(status)) {
|
||||
return STATUS_TEXT_APPROVED;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_REJECTED.equals(status)) {
|
||||
return STATUS_TEXT_REJECTED;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) {
|
||||
return STATUS_TEXT_SUPERSEDED;
|
||||
}
|
||||
return STATUS_TEXT_UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验认证对象类型
|
||||
*
|
||||
* @param bizType 类型
|
||||
* @return 是否为合法类型
|
||||
*/
|
||||
public static boolean isValidBizType(String bizType) {
|
||||
return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)
|
||||
|| TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType);
|
||||
}
|
||||
|
||||
}
|
||||
+47
@@ -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.support;
|
||||
|
||||
/**
|
||||
* 异常处置的权限表达式
|
||||
* <p>
|
||||
* 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。
|
||||
* <p>
|
||||
* 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun},
|
||||
* 故函数名必须与它的公开方法一致({@code hasMenu})。
|
||||
* 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作
|
||||
* 属性访问而非权限判断,请求时抛 500 而不是 403。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class ExceptionDisposalPermissions {
|
||||
|
||||
/**
|
||||
* 编辑异常处置的上报内容
|
||||
*/
|
||||
public static final String EDIT = "hasMenu('exception_disposal_edit')";
|
||||
|
||||
private ExceptionDisposalPermissions() {
|
||||
}
|
||||
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 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.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 把百度 OCR 的原始结果归一化成业务字段
|
||||
* <p>
|
||||
* 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…),
|
||||
* 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键,
|
||||
* 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。
|
||||
* <p>
|
||||
* 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填;
|
||||
* 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。
|
||||
* <p>
|
||||
* <b>必须做别名容错的原因</b>:官方文档列举的是「号牌号码」「车辆识别代码」,
|
||||
* 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出
|
||||
* 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**——
|
||||
* 不报错、不抛异常,最难排查。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class OcrFieldSupport {
|
||||
|
||||
/**
|
||||
* {@code words_result} 里每个条目形如 {@code {"words": "桂A12345"}}
|
||||
*/
|
||||
private static final String WORDS = "words";
|
||||
|
||||
/**
|
||||
* 表示「长期有效」的词:命中后不产出日期,而把对应的长期有效标志置 1
|
||||
*/
|
||||
private static final String LONG_TERM_TEXT = "长期";
|
||||
|
||||
/**
|
||||
* 日期写法:4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(年月日 / 点 / 横线)
|
||||
*/
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})");
|
||||
|
||||
private OcrFieldSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按证件类型把原始结果归一化
|
||||
*
|
||||
* @param type 证件类型
|
||||
* @param side 证件面:front / back(目前两个面共用一套字段别名)
|
||||
* @param result 百度原始返回
|
||||
* @return 归一化字段(键与业务字段同名);识别不到任何字段时返回空 Map
|
||||
*/
|
||||
public static Map<String, Object> extract(BaiduOcrType type, String side, JsonNode result) {
|
||||
Map<String, String> words = readWords(result);
|
||||
Map<String, Object> fields = new LinkedHashMap<>();
|
||||
if (words.isEmpty()) {
|
||||
return fields;
|
||||
}
|
||||
for (Map.Entry<String, List<String>> entry : aliasTable(type).entrySet()) {
|
||||
String raw = firstMatch(words, entry.getValue());
|
||||
if (Func.isEmpty(raw)) {
|
||||
continue;
|
||||
}
|
||||
putField(fields, entry.getKey(), raw);
|
||||
}
|
||||
// 外廓尺寸是唯一「一项要拆成三个字段」的,单独处理
|
||||
splitOuterSize(words, fields);
|
||||
// 道路运输证的「有效期」常写成一个区间:止期已由上面取到最后一段,
|
||||
// 起期要从同一段文本里补,否则用户得照着一张图再敲一遍
|
||||
splitRangeStart(words, fields, "有效期", "roadTransportCertStartDate");
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「有效期」这类区间文本里取出起期,补进对应的起期字段
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param fields 结果集
|
||||
* @param alias 区间所在的键名
|
||||
* @param startKey 要补的起期字段名
|
||||
*/
|
||||
private static void splitRangeStart(Map<String, String> words, Map<String, Object> fields,
|
||||
String alias, String startKey) {
|
||||
if (fields.containsKey(startKey)) {
|
||||
return;
|
||||
}
|
||||
String text = firstMatch(words, List.of(alias));
|
||||
if (Func.isEmpty(text)) {
|
||||
return;
|
||||
}
|
||||
List<String> dates = matchDates(text);
|
||||
// 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错
|
||||
if (dates.size() >= 2) {
|
||||
fields.put(startKey, dates.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 {@code words_result} 成「中文键 → 文本值」
|
||||
*
|
||||
* @param result 百度原始返回
|
||||
* @return 键值对;结构不符时返回空 Map
|
||||
*/
|
||||
private static Map<String, String> readWords(JsonNode result) {
|
||||
Map<String, String> words = new LinkedHashMap<>();
|
||||
if (result == null) {
|
||||
return words;
|
||||
}
|
||||
JsonNode wordsResult = result.path("words_result");
|
||||
if (!wordsResult.isObject()) {
|
||||
return words;
|
||||
}
|
||||
wordsResult.fields().forEachRemaining(entry -> {
|
||||
JsonNode value = entry.getValue();
|
||||
if (value == null || value.isNull()) {
|
||||
return;
|
||||
}
|
||||
// 可能是 {"words": "..."},也可能直接是字符串
|
||||
String text = value.isObject() ? value.path(WORDS).asText("") : value.asText("");
|
||||
if (Func.isNotEmpty(text)) {
|
||||
words.put(entry.getKey(), text.trim());
|
||||
}
|
||||
});
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取第一个命中的别名值
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param aliases 候选键名,按优先级排列
|
||||
* @return 命中值;都没命中返回 null
|
||||
*/
|
||||
private static String firstMatch(Map<String, String> words, List<String> aliases) {
|
||||
for (String alias : aliases) {
|
||||
String value = words.get(alias);
|
||||
if (Func.isNotEmpty(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
// 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键
|
||||
for (String alias : aliases) {
|
||||
for (Map.Entry<String, String> entry : words.entrySet()) {
|
||||
if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一个字段,按字段语义做值归一化
|
||||
* <p>
|
||||
* 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期,
|
||||
* 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。
|
||||
*
|
||||
* @param fields 结果集
|
||||
* @param key 业务字段名
|
||||
* @param raw 识别到的原始文本
|
||||
*/
|
||||
private static void putField(Map<String, Object> fields, String key, String raw) {
|
||||
String text = raw.trim();
|
||||
if (isDateField(key)) {
|
||||
if (text.contains(LONG_TERM_TEXT)) {
|
||||
String longTermKey = longTermKeyOf(key);
|
||||
if (longTermKey != null) {
|
||||
fields.put(longTermKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String date = normalizeDate(text, key);
|
||||
if (date != null) {
|
||||
fields.put(key, date);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isNumberField(key)) {
|
||||
Integer number = normalizeNumber(text);
|
||||
if (number != null) {
|
||||
fields.put(key, number);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fields.put(key, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「外廓尺寸」一项拆成外廓长/宽/高三个字段
|
||||
* <p>
|
||||
* 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param fields 结果集
|
||||
*/
|
||||
private static void splitOuterSize(Map<String, String> words, Map<String, Object> fields) {
|
||||
String size = firstMatch(words, List.of("外廓尺寸"));
|
||||
if (Func.isEmpty(size)) {
|
||||
return;
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String part : size.split("[^0-9]+")) {
|
||||
if (Func.isNotEmpty(part)) {
|
||||
parts.add(part);
|
||||
}
|
||||
}
|
||||
// 必须正好三段才写回:只识别到两段时多半是漏识别,
|
||||
// 按顺序硬填会把宽度填成长度、高度填成宽度
|
||||
if (parts.size() != 3) {
|
||||
return;
|
||||
}
|
||||
String[] keys = {"outerLength", "outerWidth", "outerHeight"};
|
||||
for (int index = 0; index < keys.length; index++) {
|
||||
Integer number = normalizeNumber(parts.get(index));
|
||||
if (number != null) {
|
||||
fields.put(keys[index], number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 各证件类型的字段别名表
|
||||
*
|
||||
* @param type 证件类型
|
||||
* @return 「业务字段名 → 百度键名候选」
|
||||
*/
|
||||
private static Map<String, List<String>> aliasTable(BaiduOcrType type) {
|
||||
if (type == null) {
|
||||
return Map.of();
|
||||
}
|
||||
String name = type.name();
|
||||
if (BaiduOcrType.ID_CARD.name().equals(name)) {
|
||||
return idCardAliases();
|
||||
}
|
||||
if (BaiduOcrType.DRIVING_LICENSE.name().equals(name)) {
|
||||
return drivingLicenseAliases();
|
||||
}
|
||||
if (BaiduOcrType.VEHICLE_LICENSE.name().equals(name)) {
|
||||
return vehicleLicenseAliases();
|
||||
}
|
||||
if (BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE.name().equals(name)) {
|
||||
return roadTransportAliases();
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> idCardAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("realName", List.of("姓名"));
|
||||
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
|
||||
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
|
||||
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> drivingLicenseAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("driverFileNo", List.of("档案编号", "证号"));
|
||||
aliases.put("driverClass", List.of("准驾车型"));
|
||||
aliases.put("driverValidStart", List.of("有效起始日期", "有效期起", "初次领证日期"));
|
||||
aliases.put("driverValidEnd", List.of("有效期限", "有效期止", "有效期限止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> vehicleLicenseAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("plateNo", List.of("号牌号码", "车牌号码", "号牌"));
|
||||
aliases.put("vehicleType", List.of("车辆类型"));
|
||||
aliases.put("ownerName", List.of("所有人"));
|
||||
aliases.put("vin", List.of("车辆识别代码", "车辆识别代号", "车架号", "VIN"));
|
||||
aliases.put("drivingLicenseNo", List.of("档案编号", "行驶证档案编号"));
|
||||
aliases.put("plateColor", List.of("号牌颜色", "车牌颜色"));
|
||||
aliases.put("energyType", List.of("能源种类", "能源类型"));
|
||||
aliases.put("approvedLoadKg", List.of("核定载质量"));
|
||||
aliases.put("tractionMassKg", List.of("准牵引总质量"));
|
||||
aliases.put("compulsoryScrapDate", List.of("强制报废期止", "强制报废日期"));
|
||||
aliases.put("drivingLicenseStartDate", List.of("注册日期", "发证日期"));
|
||||
aliases.put("drivingLicenseEndDate", List.of("检验有效期止", "有效期止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> roadTransportAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("roadTransportCertNo", List.of("道路运输证号", "运输证号"));
|
||||
aliases.put("ownerName", List.of("业户名称", "所有人"));
|
||||
aliases.put("roadTransportCertEndDate", List.of("有效期", "有效期止", "有效期至"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static boolean isDateField(String key) {
|
||||
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
}
|
||||
|
||||
private static boolean isNumberField(String key) {
|
||||
return "approvedLoadKg".equals(key) || "tractionMassKg".equals(key);
|
||||
}
|
||||
|
||||
private static String longTermKeyOf(String dateKey) {
|
||||
if ("compulsoryScrapDate".equals(dateKey)) {
|
||||
return "compulsoryScrapLongTerm";
|
||||
}
|
||||
if ("drivingLicenseEndDate".equals(dateKey)) {
|
||||
return "drivingLicenseLongTerm";
|
||||
}
|
||||
if ("roadTransportCertEndDate".equals(dateKey)) {
|
||||
return "roadTransportCertLongTerm";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字归一化:去掉「kg」「毫米」等单位与千分位后取整
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @return 整数;取不到合理数字返回 null
|
||||
*/
|
||||
private static Integer normalizeNumber(String text) {
|
||||
String digits = text.replaceAll("[^0-9]", "");
|
||||
// 载质量、外廓尺寸最多 6 位;过长说明识别串了字段,宁可不填也不填错
|
||||
if (digits.isEmpty() || digits.length() > 6) {
|
||||
return null;
|
||||
}
|
||||
return Integer.valueOf(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期归一化
|
||||
* <p>
|
||||
* 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312}
|
||||
* 这几种写法,统一成 {@code yyyy-MM-dd}。
|
||||
* <p>
|
||||
* 区间(如「2015.03.12-2035.03.12」「2020-01-01 至 2030-01-01」)**按字段语义取一端**:
|
||||
* 以 Start 结尾的取第一段,其余(止期类)取最后一段。取错一端不会报错,
|
||||
* 只会把有效期起填成止——比不填更难发现。
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @param key 业务字段名,用于判断该取区间的哪一端
|
||||
* @return 归一化日期;无法识别返回 null
|
||||
*/
|
||||
private static String normalizeDate(String text, String key) {
|
||||
List<String> dates = matchDates(text);
|
||||
if (!dates.isEmpty()) {
|
||||
return key.endsWith("Start") ? dates.get(0) : dates.get(dates.size() - 1);
|
||||
}
|
||||
String compact = text.replaceAll("[^0-9]", "");
|
||||
if (compact.length() == 8) {
|
||||
return formatDate(compact);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出文本里所有日期(按出现顺序)
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @return 归一化后的日期列表
|
||||
*/
|
||||
private static List<String> matchDates(String text) {
|
||||
List<String> dates = new ArrayList<>();
|
||||
Matcher matcher = DATE_PATTERN.matcher(text);
|
||||
while (matcher.find()) {
|
||||
dates.add(String.format("%s-%02d-%02d", matcher.group(1),
|
||||
Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3))));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private static String formatDate(String digits) {
|
||||
return digits.substring(0, 4) + "-" + digits.substring(4, 6) + "-" + digits.substring(6, 8);
|
||||
}
|
||||
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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.support;
|
||||
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 证件类型映射:小程序命名 → 百度 OCR 证件类型
|
||||
* <p>
|
||||
* ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由:
|
||||
* <table border="1">
|
||||
* <caption>命名对照</caption>
|
||||
* <tr><th>证件</th><th>小程序传</th><th>后端 OCR 用</th></tr>
|
||||
* <tr><td>驾驶证</td><td>{@code driver}</td><td>{@code driving_license}</td></tr>
|
||||
* <tr><td>行驶证</td><td>{@code vehicle}</td><td>{@code vehicle_license}</td></tr>
|
||||
* </table>
|
||||
* 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之,
|
||||
* 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。
|
||||
* 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。
|
||||
* <p>
|
||||
* 采用白名单而非「透传 + 兜底为通用识别」还有一个原因:
|
||||
* 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class OcrTypeMapping {
|
||||
|
||||
/**
|
||||
* 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝
|
||||
*/
|
||||
private static final Map<String, BaiduOcrType> TYPE_ALIASES = Map.ofEntries(
|
||||
// 身份证
|
||||
Map.entry("IDCARD", BaiduOcrType.ID_CARD),
|
||||
Map.entry("ID_CARD", BaiduOcrType.ID_CARD),
|
||||
// 驾驶证:小程序叫 driver
|
||||
Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE),
|
||||
Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE),
|
||||
// 行驶证:小程序叫 vehicle
|
||||
Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE),
|
||||
Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE),
|
||||
// 道路运输证
|
||||
Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE),
|
||||
Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE)
|
||||
);
|
||||
|
||||
/**
|
||||
* 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示
|
||||
*/
|
||||
private static final Map<String, String> KNOWN_UNSUPPORTED = Map.of(
|
||||
"QUALIFICATION", "从业资格证"
|
||||
);
|
||||
|
||||
private static final String SIDE_FRONT = "front";
|
||||
private static final String SIDE_BACK = "back";
|
||||
|
||||
private OcrTypeMapping() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把调用方传来的证件类型转成百度 OCR 证件类型
|
||||
*
|
||||
* @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification,
|
||||
* 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate
|
||||
* @return 百度 OCR 证件类型
|
||||
* @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR
|
||||
*/
|
||||
public static BaiduOcrType toBaiduType(String type) {
|
||||
String normalized = normalize(type);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("OCR证件类型不能为空");
|
||||
}
|
||||
BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized);
|
||||
if (baiduOcrType != null) {
|
||||
return baiduOcrType;
|
||||
}
|
||||
String unsupportedName = KNOWN_UNSUPPORTED.get(normalized);
|
||||
if (unsupportedName != null) {
|
||||
// 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功
|
||||
throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写");
|
||||
}
|
||||
throw new IllegalArgumentException("不支持的OCR证件类型:" + type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化正反面参数
|
||||
* <p>
|
||||
* 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。
|
||||
* <p>
|
||||
* ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**:
|
||||
* 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数,
|
||||
* 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件
|
||||
* 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清
|
||||
* 「默认取正面」和「这个证件根本不该带这个参数」。
|
||||
*
|
||||
* @param side 正反面:front / back
|
||||
* @return 规范化结果 front / back;调用方未指定时返回 null
|
||||
*/
|
||||
public static String normalizeSide(String side) {
|
||||
String normalized = normalize(side);
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化:去首尾空白、去分隔符差异、统一大写
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 归一化结果;空值返回空串
|
||||
*/
|
||||
private static String normalize(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim()
|
||||
.replace('-', '_')
|
||||
.replace(' ', '_')
|
||||
.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 司机档案与车辆表之间的车牌软关联支持类
|
||||
* <p>
|
||||
* 司机「驾驶车辆」与车辆表「车牌号」之间**没有外键**,只有文本关联,
|
||||
* 且库中实际存在写法差异:间隔符(·、•、.)、横线、空格、大小写不一致,
|
||||
* 以及一个字段里用逗号/顿号/分号/斜杠/竖线并列多辆车。
|
||||
* 认证提交时的「只能操作本人绑定车辆」校验依赖本类,因此匹配规则集中在此处维护,
|
||||
* 避免各业务各自实现一套导致口径分叉。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class VehiclePlateSupport {
|
||||
|
||||
/**
|
||||
* 并列车牌的常见分隔符(中英文顿号、逗号、分号、斜杠、竖线)
|
||||
*/
|
||||
private static final String SEPARATOR_PATTERN = "[,,、;;/|]";
|
||||
|
||||
/**
|
||||
* 车牌中需要剔除的排版字符(各类空白、横线与间隔符)
|
||||
*/
|
||||
private static final String NOISE_PATTERN = "[\\s\\-·•..]";
|
||||
|
||||
private VehiclePlateSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「驾驶车辆」字段拆成规范化后的车牌列表(已去重、保持原顺序)
|
||||
*
|
||||
* @param drivingVehicle 司机档案里的驾驶车辆字段,可能并列多辆
|
||||
* @return 规范化车牌列表;空值返回空列表
|
||||
*/
|
||||
public static List<String> splitPlates(String drivingVehicle) {
|
||||
if (Func.isEmpty(drivingVehicle)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
String normalized = drivingVehicle.replaceAll(SEPARATOR_PATTERN, ",");
|
||||
Set<String> plates = new LinkedHashSet<>();
|
||||
for (String part : Func.toStrList(",", normalized)) {
|
||||
String plate = normalize(part);
|
||||
if (Func.isNotEmpty(plate)) {
|
||||
plates.add(plate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(plates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 车牌规范化:去掉排版字符并转大写,使不同写法能比对
|
||||
*
|
||||
* @param plateNo 原始车牌
|
||||
* @return 规范化结果;空值返回空串
|
||||
*/
|
||||
public static String normalize(String plateNo) {
|
||||
if (Func.isEmpty(plateNo)) {
|
||||
return "";
|
||||
}
|
||||
return plateNo.trim().replaceAll(NOISE_PATTERN, "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定某车牌是否属于该司机绑定的车辆
|
||||
* <p>
|
||||
* 空值一律判为「不绑定」——不能因为双方都为空就放行,那等于越权。
|
||||
*
|
||||
* @param drivingVehicle 司机档案里的驾驶车辆字段
|
||||
* @param plateNo 待判定的车牌
|
||||
* @return 是否绑定
|
||||
*/
|
||||
public static boolean isBound(String drivingVehicle, String plateNo) {
|
||||
String target = normalize(plateNo);
|
||||
if (Func.isEmpty(target)) {
|
||||
return false;
|
||||
}
|
||||
return splitPlates(drivingVehicle).contains(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个司机最多能绑定的车辆数
|
||||
* <p>
|
||||
* 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束,
|
||||
* 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符,
|
||||
* 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。
|
||||
*/
|
||||
public static final int MAX_BOUND_VEHICLES = 5;
|
||||
|
||||
/**
|
||||
* 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字)
|
||||
* <p>
|
||||
* 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌,
|
||||
* 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。
|
||||
* 传入值必须是 {@link #normalize} 之后的形态(已去排版字符并转大写)。
|
||||
*/
|
||||
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
|
||||
|
||||
/**
|
||||
* 车牌格式是否合法
|
||||
*
|
||||
* @param plateNo 车牌(会先做规范化)
|
||||
* @return 是否合法
|
||||
*/
|
||||
public static boolean isValidPlateNo(String plateNo) {
|
||||
String normalized = normalize(plateNo);
|
||||
return Func.isNotEmpty(normalized) && PLATE_NO_PATTERN.matcher(normalized).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式
|
||||
* <p>
|
||||
* 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。
|
||||
* 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查),
|
||||
* 而小程序写入的是逗号并列的多辆车,两边口径本就不一致:
|
||||
* 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。
|
||||
*
|
||||
* @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆
|
||||
* @throws ServiceException 车辆数超过上限,或存在格式非法的车牌
|
||||
*/
|
||||
public static void validateBoundVehicles(String drivingVehicle) {
|
||||
List<String> plates = splitPlates(drivingVehicle);
|
||||
if (plates.size() > MAX_BOUND_VEHICLES) {
|
||||
throw new ServiceException("一个司机最多绑定" + MAX_BOUND_VEHICLES + "辆车");
|
||||
}
|
||||
for (String plate : plates) {
|
||||
if (!PLATE_NO_PATTERN.matcher(plate).matches()) {
|
||||
throw new ServiceException("车牌号格式不正确:" + plate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配
|
||||
* <p>
|
||||
* ⚠️ 只做精确匹配是不够的。{@link #splitPlates} 返回的已是**规范化**车牌
|
||||
* (去空格/间隔符/横线并转大写),若直接拿它与 {@code plate_no} 原始列做 IN,
|
||||
* 库中任何带间隔符或大小写差异的车牌都会**匹配不到**。
|
||||
* 后果不是「少查一条」,而是调用方据此判定「该司机名下没有可用车辆」——
|
||||
* 认证门槛会因此把**已认证的司机误判成未认证并拒绝接单**。
|
||||
* <p>
|
||||
* 库中已知存在这类写法差异({@code DriverAppServiceImpl.myVehicles()} 早就为此加了
|
||||
* 同样的兜底),故此处把规则收敛成一处,避免各调用点各自实现、各自漏掉。
|
||||
*
|
||||
* @param wrapper 查询条件
|
||||
* @param normalizedPlates 已规范化的车牌列表
|
||||
*/
|
||||
public static void applyPlateMatch(LambdaQueryWrapper<TransportVehicle> wrapper,
|
||||
List<String> normalizedPlates) {
|
||||
if (normalizedPlates == null || normalizedPlates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
wrapper.and(w -> {
|
||||
w.in(TransportVehicle::getPlateNo, normalizedPlates);
|
||||
for (String plate : normalizedPlates) {
|
||||
w.or().apply(NORMALIZED_PLATE_SQL + " = {0}", plate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 {@link #normalize} 等价的 SQL 表达式(列名固定为 {@code plate_no})
|
||||
* <p>
|
||||
* 两者必须保持一致:Java 侧剔除的字符集变了,这里也要跟着变。
|
||||
* {@code VehiclePlateSupportTest} 会断言两边覆盖同一批字符,防止悄悄分叉。
|
||||
*/
|
||||
public static final String NORMALIZED_PLATE_SQL =
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no)"
|
||||
+ ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')";
|
||||
|
||||
}
|
||||
+265
-4
@@ -32,10 +32,14 @@ import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运单过程配置解析(对齐 web 端 waybill-manage / process-config)
|
||||
@@ -49,8 +53,18 @@ public final class WaybillProcessSupport {
|
||||
public static final String ACCEPT_REJECTED = "rejected";
|
||||
private static final String CONFIRM_YES = "yes";
|
||||
private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept";
|
||||
private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete";
|
||||
private static final String NODE_TRANSIT = "transit";
|
||||
private static final String NODE_TRANSIT_NAME = "在途";
|
||||
/** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */
|
||||
private static final String DISPOSAL_COMPLETED = "completed";
|
||||
private static final String DISPOSAL_REVOKED = "revoked";
|
||||
/** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */
|
||||
public static final List<String> SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED);
|
||||
/** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */
|
||||
public static final String PUNCH_PHOTO_TYPE = "打卡照片";
|
||||
/** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */
|
||||
public static final int MAX_PHOTOS_PER_TYPE = 9;
|
||||
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
|
||||
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
@@ -231,6 +245,21 @@ public final class WaybillProcessSupport {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次打卡的节点是否是司机可打卡节点的末位。
|
||||
* <p>
|
||||
* 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位——
|
||||
* 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。
|
||||
*/
|
||||
public static boolean isLastDriverPunchNode(String processJson, String nodeCode) {
|
||||
List<Map<String, Object>> nodes = listDriverPunchNodes(processJson);
|
||||
if (nodes.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String code = stringVal(nodeCode);
|
||||
return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用中的过程节点(按配置顺序,含接单/回单)。
|
||||
*/
|
||||
@@ -285,14 +314,38 @@ public final class WaybillProcessSupport {
|
||||
return isTruthy(node.get("location"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。
|
||||
* <p>
|
||||
* 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。
|
||||
* 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关,
|
||||
* 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。
|
||||
*/
|
||||
public static boolean nodeNeedCargo(Map<String, Object> node) {
|
||||
return isTruthy(node.get("uploadCargo"));
|
||||
return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty();
|
||||
}
|
||||
|
||||
public static boolean nodeNeedVoucher(Map<String, Object> node) {
|
||||
return isTruthy(node.get("uploadVoucher"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。
|
||||
* <p>
|
||||
* 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。
|
||||
* 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。
|
||||
*/
|
||||
public static List<String> nodePhotoTypes(Map<String, Object> node) {
|
||||
List<String> types = new ArrayList<>();
|
||||
types.add(PUNCH_PHOTO_TYPE);
|
||||
for (String voucherType : nodeStringList(node, "voucherTypes")) {
|
||||
if (!types.contains(voucherType)) {
|
||||
types.add(voucherType);
|
||||
}
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static List<String> nodeStringList(Map<String, Object> node, String field) {
|
||||
Object raw = node.get(field);
|
||||
@@ -327,6 +380,176 @@ public final class WaybillProcessSupport {
|
||||
return Func.isEmpty(name) ? nodeKey(node) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。
|
||||
* <p>
|
||||
* 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置
|
||||
* (否则第一个打卡节点永远等不到前置打卡记录)。
|
||||
* <p>
|
||||
* **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成;
|
||||
* 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。
|
||||
*
|
||||
* @param processJson 过程配置
|
||||
* @param punchedNodeKeys 该运单已打卡的节点 key
|
||||
* @param nodeCode 待判定的节点 key
|
||||
*/
|
||||
public static Set<String> unfinishedPrerequisiteKeys(
|
||||
String processJson, Collection<String> punchedNodeKeys, String nodeCode) {
|
||||
if (Func.isEmpty(nodeCode)) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<String> punched = punchedNodeKeys == null
|
||||
? Collections.emptySet() : new HashSet<>(punchedNodeKeys);
|
||||
Set<String> unfinished = new LinkedHashSet<>();
|
||||
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
|
||||
// 在途不参与顺序:既不作为别人的前置,也不被别人拦
|
||||
if (isTransitNode(node)) {
|
||||
continue;
|
||||
}
|
||||
String key = nodeKey(node);
|
||||
if (nodeCode.equals(key)) {
|
||||
// 走到自己:只关心它前面那些
|
||||
return unfinished;
|
||||
}
|
||||
if (!punched.contains(key)) {
|
||||
unfinished.add(key);
|
||||
}
|
||||
}
|
||||
// 节点不在本配置里:不做顺序判定,交给节点存在性校验去报错
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
|
||||
* <p>
|
||||
* 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。
|
||||
*/
|
||||
public static boolean sameNodeCode(String left, String right) {
|
||||
String leftCode = stringVal(left);
|
||||
String rightCode = stringVal(right);
|
||||
return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。
|
||||
*/
|
||||
public static boolean isWaybillLevelException(String exceptionNodeCode) {
|
||||
return stringVal(exceptionNodeCode).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 该异常是否与某节点相关。
|
||||
* <p>
|
||||
* 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。
|
||||
* 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。
|
||||
*/
|
||||
public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) {
|
||||
return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过程配置里的在途节点 key;配置中无在途节点时返回空串。
|
||||
*/
|
||||
public static String transitNodeKey(String processJson) {
|
||||
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
|
||||
if (isTransitNode(node)) {
|
||||
return nodeKey(node);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算被异常门禁阻断的过程节点 key。
|
||||
* <p>
|
||||
* 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。
|
||||
* 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。
|
||||
* <p>
|
||||
* **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡,
|
||||
* 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成,
|
||||
* 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途,
|
||||
* 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。
|
||||
* <p>
|
||||
* 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。
|
||||
* 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。
|
||||
*
|
||||
* @param processJson 过程配置
|
||||
* @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常
|
||||
*/
|
||||
public static Set<String> blockedNodeKeys(String processJson, Collection<String> unresolvedNodeCodes) {
|
||||
Set<String> blocked = new LinkedHashSet<>(
|
||||
blockedNodeKeysFrom(processJson, earliestBlockingNodeCode(processJson, unresolvedNodeCodes)));
|
||||
if (unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) {
|
||||
return blocked;
|
||||
}
|
||||
String transitKey = transitNodeKey(processJson);
|
||||
if (Func.isNotEmpty(transitKey)) {
|
||||
blocked.add(transitKey);
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。
|
||||
* <p>
|
||||
* 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。
|
||||
*/
|
||||
public static String earliestBlockingNodeCode(String processJson, Collection<String> unresolvedNodeCodes) {
|
||||
List<Map<String, Object>> punchNodes = listDriverPunchNodes(processJson);
|
||||
if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Set<String> reported = new HashSet<>();
|
||||
for (String code : unresolvedNodeCodes) {
|
||||
String key = stringVal(code);
|
||||
// 未指明节点的异常锁全单,优先级高于任何具体节点
|
||||
if (Func.isEmpty(key)) {
|
||||
return "";
|
||||
}
|
||||
reported.add(key);
|
||||
}
|
||||
for (Map<String, Object> node : punchNodes) {
|
||||
String key = nodeKey(node);
|
||||
if (reported.contains(key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。
|
||||
*/
|
||||
public static Set<String> blockedNodeKeysFrom(String processJson, String fromNodeCode) {
|
||||
List<Map<String, Object>> punchNodes = listDriverPunchNodes(processJson);
|
||||
Set<String> blocked = new LinkedHashSet<>();
|
||||
if (punchNodes.isEmpty() || fromNodeCode == null) {
|
||||
return blocked;
|
||||
}
|
||||
// 空串代表「未指明节点」——全局入口(首页快捷格、打卡页底部按钮)不指定环节,
|
||||
// 这类异常无从定位区间,只能阻断全部可打卡节点。
|
||||
if (Func.isEmpty(fromNodeCode)) {
|
||||
for (Map<String, Object> node : punchNodes) {
|
||||
blocked.add(nodeKey(node));
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
int start = -1;
|
||||
for (int i = 0; i < punchNodes.size(); i++) {
|
||||
if (fromNodeCode.equals(nodeKey(punchNodes.get(i)))) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start < 0) {
|
||||
return blocked;
|
||||
}
|
||||
for (int i = start; i < punchNodes.size(); i++) {
|
||||
blocked.add(nodeKey(punchNodes.get(i)));
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static List<Map<String, Object>> parseProcessNodes(String processJson) {
|
||||
if (Func.isEmpty(processJson)) {
|
||||
@@ -370,10 +593,48 @@ public final class WaybillProcessSupport {
|
||||
return "return".equals(key) || "回单".equals(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否接单节点:key=accept 或 name=接单。
|
||||
* <p>
|
||||
* 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定,
|
||||
* 不落打卡流水,两处的判据必须是同一个。
|
||||
*/
|
||||
public static boolean isAcceptNode(String nodeCode, String nodeName) {
|
||||
return "accept".equals(nodeCode) || "接单".equals(nodeName);
|
||||
}
|
||||
|
||||
private static boolean isAcceptNode(Map<String, Object> node) {
|
||||
String key = stringVal(node.get("key"));
|
||||
String name = stringVal(node.get("name"));
|
||||
return "accept".equals(key) || "接单".equals(name);
|
||||
return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。
|
||||
*/
|
||||
public static boolean isDeliveryNode(String nodeCode, String nodeName) {
|
||||
String key = stringVal(nodeCode);
|
||||
String name = stringVal(nodeName);
|
||||
return "arrival".equals(key) || "到货".equals(name)
|
||||
|| "unload".equals(key) || "卸货".equals(name)
|
||||
|| "sign".equals(key) || "签收".equals(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。
|
||||
* <p>
|
||||
* 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认;
|
||||
* 启用但 confirmMode 缺失按「需确认完成」算。
|
||||
*/
|
||||
public static boolean returnNeedsNoConfirmation(String processJson) {
|
||||
for (Map<String, Object> node : parseProcessNodes(processJson)) {
|
||||
if (!isReturnNode(node)) {
|
||||
continue;
|
||||
}
|
||||
if (!isEnabled(node)) {
|
||||
return true;
|
||||
}
|
||||
return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode")));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isTransitNode(Map<String, Object> node) {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
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 org.springblade.transport.support.CertificationRuleSupport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 认证记录包装类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertRecord, TransportCertRecordVO> {
|
||||
|
||||
public static TransportCertRecordWrapper build() {
|
||||
return new TransportCertRecordWrapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) {
|
||||
TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class));
|
||||
return fillDisplayFields(transportCertRecordVO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充面向前端的展示字段:四个姓名与状态文案
|
||||
* <p>
|
||||
* 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段,
|
||||
* 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。
|
||||
* 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。
|
||||
*
|
||||
* @param transportCertRecordVO 认证记录视图对象
|
||||
* @return 同一个对象,便于链式调用
|
||||
*/
|
||||
public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) {
|
||||
// create/update/submit 可能来自小程序用户;audit 一般为后台系统用户
|
||||
transportCertRecordVO.setCreateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getCreateUser()));
|
||||
transportCertRecordVO.setUpdateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getUpdateUser()));
|
||||
transportCertRecordVO.setSubmitUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getSubmitUser()));
|
||||
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser()));
|
||||
// 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法
|
||||
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus()));
|
||||
return transportCertRecordVO;
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -48,7 +48,19 @@ public class TransportVehicleWrapper extends BaseEntityWrapper<TransportVehicle,
|
||||
public TransportVehicleVO entityVO(TransportVehicle vehicle) {
|
||||
TransportVehicleVO vo = Objects.requireNonNull(BeanUtil.copyProperties(vehicle, TransportVehicleVO.class));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(vehicle.getCreateUser()));
|
||||
// BladeX 数字 null 常序列化为 -1;详情展示时把未填尺寸还原为空
|
||||
vo.setOuterLength(normalizeOptionalInteger(vo.getOuterLength()));
|
||||
vo.setOuterWidth(normalizeOptionalInteger(vo.getOuterWidth()));
|
||||
vo.setOuterHeight(normalizeOptionalInteger(vo.getOuterHeight()));
|
||||
vo.setTractionMassKg(normalizeOptionalInteger(vo.getTractionMassKg()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Integer normalizeOptionalInteger(Integer value) {
|
||||
if (value == null || value == -1) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-1
@@ -62,7 +62,7 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
|
||||
waybillVO.setBusinessStatusName(businessStatusName(displayStatus));
|
||||
waybillVO.setTransportTypeName(transportTypeName(waybill.getTransportType()));
|
||||
waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
|
||||
// 未维护里程等占位值 -1 对外展示为空
|
||||
// 未维护里程/费用等占位值 -1 对外展示为空
|
||||
if (isSentinelMinusOne(waybillVO.getMileage())) {
|
||||
waybillVO.setMileage(null);
|
||||
}
|
||||
@@ -72,6 +72,9 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
|
||||
if (isSentinelMinusOne(waybillVO.getUnitPrice())) {
|
||||
waybillVO.setUnitPrice(null);
|
||||
}
|
||||
if (isSentinelMinusOne(waybillVO.getOtherFeeTotal())) {
|
||||
waybillVO.setOtherFeeTotal(null);
|
||||
}
|
||||
// 计划未选时 planId 可能为库内 -1 或序列化占位,统一清空避免前端回显 -1
|
||||
if (waybillVO.getPlanId() != null && waybillVO.getPlanId() <= 0L) {
|
||||
waybillVO.setPlanId(null);
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package org.springblade.transport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 测试基础设施自检:确认 JUnit 5 能在本模块被发现并执行。
|
||||
*/
|
||||
class TestInfraSmokeTest {
|
||||
|
||||
@Test
|
||||
void junit5Runs() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
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.mockito.ArgumentCaptor;
|
||||
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.ITransportPlanService;
|
||||
import org.springblade.transport.service.IWaybillImportBatchService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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>
|
||||
* 用真实的 HTTP 请求走一遍路由与 JSON 反序列化:字段名写错、凭证图数组结构对不上,
|
||||
* 都会在这里失败,而不是等前端点保存时才发现。
|
||||
*/
|
||||
class WaybillPunchRecordEndpointTest {
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
WaybillController controller = new WaybillController(
|
||||
waybillService,
|
||||
mock(IProjectApplyService.class),
|
||||
mock(IContractManageService.class),
|
||||
mock(ICustomerArchiveService.class),
|
||||
mock(ITransportPlanService.class),
|
||||
mock(IWaybillImportBatchService.class));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /waybill-manage/punch-record/save:绑定打卡明细与凭证图")
|
||||
void bindPunchRecordBody() throws Exception {
|
||||
when(waybillService.savePunchRecord(any())).thenReturn(true);
|
||||
String body = """
|
||||
{
|
||||
"waybillId": 6001,
|
||||
"type": "node",
|
||||
"nodeCode": "load",
|
||||
"nodeName": "装货",
|
||||
"punched": true,
|
||||
"punchTime": "2026-10-06 09:30:00",
|
||||
"address": "上海市青浦区",
|
||||
"longitude": "121.47",
|
||||
"latitude": "31.23",
|
||||
"weight": "12.5",
|
||||
"volume": "",
|
||||
"quantity": "3",
|
||||
"remark": "补录",
|
||||
"photos": [{"type": "委托单", "url": "https://oss/a.jpg"}]
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/waybill-manage/punch-record/save")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
|
||||
ArgumentCaptor<WaybillPunchRecordSaveRequest> captor =
|
||||
ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class);
|
||||
verify(waybillService).savePunchRecord(captor.capture());
|
||||
WaybillPunchRecordSaveRequest request = captor.getValue();
|
||||
assertEquals(6001L, request.getWaybillId());
|
||||
assertEquals("node", request.getType());
|
||||
assertEquals("load", request.getNodeCode());
|
||||
assertEquals("装货", request.getNodeName());
|
||||
assertEquals(Boolean.TRUE, request.getPunched());
|
||||
assertEquals("2026-10-06 09:30:00", request.getPunchTime());
|
||||
assertEquals("上海市青浦区", request.getAddress());
|
||||
assertEquals("121.47", request.getLongitude());
|
||||
assertEquals("31.23", request.getLatitude());
|
||||
assertEquals("12.5", request.getWeight());
|
||||
assertEquals("3", request.getQuantity());
|
||||
assertEquals("补录", request.getRemark());
|
||||
assertNotNull(request.getPhotos());
|
||||
assertEquals(1, request.getPhotos().size());
|
||||
assertEquals("委托单", request.getPhotos().get(0).getType());
|
||||
assertEquals("https://oss/a.jpg", request.getPhotos().get(0).getUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取消打卡:punched=false 原样传入服务层")
|
||||
void bindUnpunchedFlag() throws Exception {
|
||||
when(waybillService.savePunchRecord(any())).thenReturn(true);
|
||||
String body = """
|
||||
{"waybillId": 6001, "type": "enroute", "punched": false}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/waybill-manage/punch-record/save")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
|
||||
ArgumentCaptor<WaybillPunchRecordSaveRequest> captor =
|
||||
ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class);
|
||||
verify(waybillService).savePunchRecord(captor.capture());
|
||||
assertEquals(Boolean.FALSE, captor.getValue().getPunched());
|
||||
assertEquals("enroute", captor.getValue().getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("服务层返回 false:响应标记为失败")
|
||||
void reportFailureWhenServiceRejects() throws Exception {
|
||||
when(waybillService.savePunchRecord(any())).thenReturn(false);
|
||||
mockMvc.perform(post("/waybill-manage/punch-record/save")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"waybillId\": 6001}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(false));
|
||||
}
|
||||
|
||||
}
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
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.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.mapper.TransportCertRecordMapper;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
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.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 管理端认证审核测试。
|
||||
* <p>
|
||||
* 守住两条最容易写坏的不变式:
|
||||
* 驳回**不得改变**生效快照;已终态记录**不可再审**(再审会把生效快照改坏)。
|
||||
*/
|
||||
class CertAuditServiceImplTest {
|
||||
|
||||
private static final Long RECORD_ID = 5001L;
|
||||
private static final Long VEHICLE_ID = 8001L;
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private TransportCertRecordMapper certRecordMapper;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private IDriverService driverService;
|
||||
private CertAuditServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
certRecordMapper = mock(TransportCertRecordMapper.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
|
||||
// 目标对象的写入默认成功;「写入一行都没命中」由专门的用例覆盖
|
||||
when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(true);
|
||||
when(driverService.updateById(any(Driver.class))).thenReturn(true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 通过
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:记录转已通过,并把该对象的生效快照指针切到它")
|
||||
void approveSwitchesEffectivePointer() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(recordCaptor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, recordCaptor.getValue().getStatus());
|
||||
assertNull(recordCaptor.getValue().getRejectReason(), "通过时必须清空驳回原因");
|
||||
assertNotNull(recordCaptor.getValue().getAuditTime(), "审核时间必须留痕");
|
||||
assertNotNull(recordCaptor.getValue().getAuditUser(), "审核人必须留痕");
|
||||
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(vehicleCaptor.capture());
|
||||
assertEquals(RECORD_ID, vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"通过后指针应指向该记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:把快照的证件内容写回车辆档案,让「档案 = 生效值」成立")
|
||||
void approveWritesSnapshotBackToVehicle() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
TransportVehicle patched = captor.getValue();
|
||||
assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新");
|
||||
assertEquals("管理挂靠", patched.getBusinessRelation());
|
||||
assertEquals(18000, patched.getApprovedLoadKg());
|
||||
assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE");
|
||||
assertNull(patched.getPlateNo(), "车牌是车辆身份,不是本次提交能改的内容");
|
||||
assertNull(patched.getOrganizationName(), "所属组织由管理端维护,不该被提交内容覆盖");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:快照是旧格式时明确失败,不能「通过但什么也没写」")
|
||||
void approveRejectsLegacySnapshot() {
|
||||
// 旧快照的键名与当前字段定义不同,会解析成「全是 null」而不报错——最危险的一种情况
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING,
|
||||
"{\"licenseNo\":\"450100123456\",\"licenseFrontUrl\":\"http://oss/old.jpg\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, true, null));
|
||||
assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage());
|
||||
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:切换指针而不是追加,同对象仍只有一条生效快照")
|
||||
void approveReplacesPointerInsteadOfAppending() {
|
||||
Long oldEffectiveId = 111L;
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
|
||||
// 只更新了这一个字段所在的车辆行,没有新增任何"生效标记"行
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:同步刷新展示缓存为已通过")
|
||||
void approveRefreshesDisplayCache() {
|
||||
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<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, captor.getValue().getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:指针写到司机表")
|
||||
void approveDriverSwitchesDriverPointer() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:patch 必须带上车辆绑定,不得把 driving_vehicle 清空")
|
||||
void approveDriverKeepsDrivingVehicle() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
Driver existing = driver(null);
|
||||
existing.setDrivingVehicle("桂A12345,桂B67890");
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(existing);
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
// driving_vehicle 标了 FieldStrategy.ALWAYS,patch 里为 null 就会把该列写成 NULL;
|
||||
// 而司机端车辆列表正是按这一列匹配的,清空即「暂无绑定车辆」且界面上无法恢复
|
||||
assertEquals("桂A12345,桂B67890", captor.getValue().getDrivingVehicle(),
|
||||
"审核通过不得把司机的车辆绑定清空");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:目标对象一行都没命中时必须整体失败,不得「记录已通过、档案没变」")
|
||||
void approveFailsWhenVehicleNotUpdated() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
|
||||
when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, true, null));
|
||||
assertTrue(exception.getMessage().contains("未生效"), exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:司机档案没命中时同样必须整体失败,不得静默放行")
|
||||
void approveFailsWhenDriverNotUpdated() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING));
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
|
||||
when(driverService.updateById(any(Driver.class))).thenReturn(false);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, true, null));
|
||||
assertTrue(exception.getMessage().contains("司机"), exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 驳回
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:记录转已驳回并留原因,但**生效快照与可用性不变**(核心不变式)")
|
||||
void rejectKeepsEffectivePointer() {
|
||||
Long existingEffectiveId = 777L;
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, false, "证件照片模糊");
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(recordCaptor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_REJECTED, recordCaptor.getValue().getStatus());
|
||||
assertEquals("证件照片模糊", recordCaptor.getValue().getRejectReason());
|
||||
|
||||
// 展示缓存可以更新,但**指针绝不能被改动**
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(vehicleCaptor.capture());
|
||||
assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"驳回不得修改生效快照指针,否则已认证的车会被无辜下线");
|
||||
assertEquals(TransportCertRecord.STATUS_REJECTED, vehicleCaptor.getValue().getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:已认证的对象被驳回后,生效快照指针仍指向原来那条")
|
||||
void rejectNeverClearsExistingPointer() {
|
||||
Long existingEffectiveId = 777L;
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, false, "运输证过期");
|
||||
|
||||
// 指针字段没有被写成 null,也没有被写成新记录
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertNull(captor.getValue().getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:不填原因必须拒绝")
|
||||
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, " "));
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:原因超过 200 字必须拒绝(与列宽一致)")
|
||||
void rejectReasonTooLong() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, false, "长".repeat(201)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 终态保护
|
||||
|
||||
@Test
|
||||
@DisplayName("终态不可再审:已通过/已驳回/已作废一律拒绝")
|
||||
void terminalRecordsCannotBeAuditedAgain() {
|
||||
for (Integer terminal : new Integer[]{
|
||||
TransportCertRecord.STATUS_APPROVED,
|
||||
TransportCertRecord.STATUS_REJECTED,
|
||||
TransportCertRecord.STATUS_SUPERSEDED}) {
|
||||
TransportCertRecord record = vehicleRecord(terminal);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null),
|
||||
"状态 " + terminal + " 属终态,不应可再审");
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"),
|
||||
"状态 " + terminal + " 属终态,不应可再审");
|
||||
}
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录不存在:拒绝")
|
||||
void recordNotFound() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(null);
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录主键为空:拒绝")
|
||||
void recordIdRequired() {
|
||||
assertThrows(ServiceException.class, () -> service.audit(null, true, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 审核详情(diff 用)
|
||||
|
||||
@Test
|
||||
@DisplayName("审核详情:同时给出待审快照与当前生效值,供前端做 diff")
|
||||
void detailProvidesBothSides() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
record.setSnapshotJson("{\"vin\":\"NEW\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
|
||||
TransportCertRecord oldEffective = new TransportCertRecord();
|
||||
oldEffective.setId(777L);
|
||||
oldEffective.setSnapshotJson("{\"vin\":\"OLD\"}");
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
|
||||
when(certRecordService.getById(777L)).thenReturn(oldEffective);
|
||||
|
||||
CertAuditDetailVO detail = service.detail(RECORD_ID);
|
||||
|
||||
assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
|
||||
assertEquals("{\"vin\":\"OLD\"}", detail.getEffectiveSnapshotJson(),
|
||||
"审核页需要旧生效值才能看出司机改了什么");
|
||||
assertEquals(777L, detail.getEffectiveRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核详情:从未认证过时,生效值一侧为 null(而不是报错)")
|
||||
void detailWithoutEffective() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
record.setSnapshotJson("{\"vin\":\"NEW\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
CertAuditDetailVO detail = service.detail(RECORD_ID);
|
||||
|
||||
assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
|
||||
assertNull(detail.getEffectiveSnapshotJson());
|
||||
assertNull(detail.getEffectiveRecordId());
|
||||
}
|
||||
|
||||
// -------------------------------------------- 驳回原因的展示缓存
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void approveClearsStaleVehicleRejectReason() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Wrapper<TransportVehicle>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(transportVehicleService).update(captor.capture());
|
||||
LambdaUpdateWrapper<TransportVehicle> wrapper =
|
||||
(LambdaUpdateWrapper<TransportVehicle>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)");
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == VEHICLE_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定")
|
||||
@SuppressWarnings("unchecked")
|
||||
void approveClearsStaleDriverRejectReason() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Wrapper<Driver>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(driverService).update(captor.capture());
|
||||
LambdaUpdateWrapper<Driver> wrapper = (LambdaUpdateWrapper<Driver>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null));
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == DRIVER_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
assertFalse(wrapper.getSqlSet().contains("driving_vehicle"),
|
||||
"清空驳回原因不得顺带动车辆绑定");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:原因去空白后写入档案,且不发清空语句")
|
||||
void rejectWritesReasonAndDoesNotClear() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
|
||||
|
||||
service.audit(RECORD_ID, false, " 行驶证模糊 ");
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(),
|
||||
"驳回原因去首尾空白后落库");
|
||||
verify(transportVehicleService, never()).update(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 辅助
|
||||
|
||||
private TransportCertRecord vehicleRecord(Integer status) {
|
||||
return vehicleRecord(status, completeVehicleSnapshot());
|
||||
}
|
||||
|
||||
private TransportCertRecord vehicleRecord(Integer status, String snapshotJson) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(RECORD_ID);
|
||||
record.setBizType(TransportCertRecord.BIZ_TYPE_VEHICLE);
|
||||
record.setBizId(VEHICLE_ID);
|
||||
record.setBizKey("桂A32523");
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson(snapshotJson);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份字段齐全的车辆认证快照
|
||||
* <p>
|
||||
* 审核通过时会把快照内容写回车辆档案,故用例不能再拿空快照走通过流程;
|
||||
* 关键字段(档案编号、核定载质量)是「这份快照是否为当前格式」的判据。
|
||||
*/
|
||||
static String completeVehicleSnapshot() {
|
||||
return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000,"
|
||||
+ "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\","
|
||||
+ "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}";
|
||||
}
|
||||
|
||||
private TransportCertRecord driverRecord(Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(RECORD_ID);
|
||||
record.setBizType(TransportCertRecord.BIZ_TYPE_DRIVER);
|
||||
record.setBizId(DRIVER_ID);
|
||||
record.setBizKey("张三");
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson("{}");
|
||||
return record;
|
||||
}
|
||||
|
||||
private TransportVehicle vehicle(Long effectiveId) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(VEHICLE_ID);
|
||||
vehicle.setPlateNo("桂A32523");
|
||||
vehicle.setEffectiveCertRecordId(effectiveId);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
private Driver driver(Long effectiveId) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setDriverName("张三");
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
return driver;
|
||||
}
|
||||
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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>
|
||||
* 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
|
||||
* 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字);
|
||||
* 标了长期有效的字段不能再下发一个作废的止期。
|
||||
*/
|
||||
class DriverAppServiceImplTest {
|
||||
|
||||
private static final String MOBILE = "15577122721";
|
||||
private static final Long USER_ID = 7001L;
|
||||
|
||||
private IDriverService driverService;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private DriverAppServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
driverService = mock(IDriverService.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效且没有止期时下发空串,不能下发「长期」——否则它会被当日期填进日期框")
|
||||
void longTermWithoutEndDateSendsEmptyText() {
|
||||
DriverVehicleCardVO card = firstCard(vehicle(LocalDate.of(2022, 8, 17), null, 1));
|
||||
|
||||
assertEquals("", card.getDrivingLicenseEndDate(), "长期有效时日期字段只能是空串");
|
||||
assertEquals(1, card.getDrivingLicenseLongTerm(), "长期有效标志本身要照常下发");
|
||||
assertEquals("2022-08-17", card.getDrivingLicenseStartDate(), "起期有值时照常下发");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效时即使库里还留着止期也不下发:那个止期是上一版证件作废的值")
|
||||
void longTermWithStaleEndDateSendsEmptyText() {
|
||||
DriverVehicleCardVO card = firstCard(vehicle(null, LocalDate.of(2040, 8, 14), 1));
|
||||
|
||||
assertEquals("", card.getDrivingLicenseEndDate());
|
||||
assertEquals("", card.getRoadTransportCertEndDate());
|
||||
assertEquals("", card.getCompulsoryScrapDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未标长期有效时照常下发止期,空值一律是空串而不是 null 字面量")
|
||||
void normalVehicleSendsRealDates() {
|
||||
TransportVehicle vehicle = vehicle(null, LocalDate.of(2030, 8, 14), 0);
|
||||
vehicle.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1));
|
||||
vehicle.setRoadTransportCertEndDate(null);
|
||||
vehicle.setRoadTransportCertLongTerm(0);
|
||||
vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 4));
|
||||
DriverVehicleCardVO card = firstCard(vehicle);
|
||||
|
||||
assertEquals("2030-08-14", card.getDrivingLicenseEndDate());
|
||||
assertEquals("2035-01-01", card.getCompulsoryScrapDate());
|
||||
assertEquals("", card.getRoadTransportCertEndDate(), "没填的止期是空串");
|
||||
assertEquals("2028-12-04", card.getRoadTransportCertStartDate());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 手机号解析
|
||||
|
||||
@Test
|
||||
@DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案")
|
||||
void twelveDigitAccountIsUsableAsPhone() {
|
||||
assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权")
|
||||
void maskedUserPhoneFallsBackToAccount() {
|
||||
MpUser user = new MpUser();
|
||||
user.setPhone("175****12082");
|
||||
user.setAccount("175874212082");
|
||||
|
||||
assertEquals("黄大富", currentByPhone(null, null, user).getDriverName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案")
|
||||
void otherPeoplesMobileIsRejected() {
|
||||
assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class,
|
||||
() -> currentByPhone("13900000001", "175874212082", null)).getMessage());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助
|
||||
|
||||
/** 造一辆只关心有效期三个字段的车 */
|
||||
private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(8001L);
|
||||
vehicle.setPlateNo("桂AAW3255");
|
||||
vehicle.setDrivingLicenseStartDate(licenseStart);
|
||||
vehicle.setDrivingLicenseEndDate(licenseEnd);
|
||||
vehicle.setDrivingLicenseLongTerm(licenseLongTerm);
|
||||
// 三组有效期都按同一个长期有效标志造,便于一次断言三处
|
||||
vehicle.setCompulsoryScrapDate(null);
|
||||
vehicle.setCompulsoryScrapLongTerm(licenseLongTerm);
|
||||
vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 3));
|
||||
vehicle.setRoadTransportCertEndDate(LocalDate.of(2043, 8, 18));
|
||||
vehicle.setRoadTransportCertLongTerm(licenseLongTerm);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
/** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */
|
||||
private DriverVehicleCardVO firstCard(TransportVehicle vehicle) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("韦司机");
|
||||
driver.setMobile(MOBILE);
|
||||
driver.setDrivingVehicle(vehicle.getPlateNo());
|
||||
|
||||
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of(vehicle));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+715
@@ -0,0 +1,715 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
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.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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>
|
||||
* 重点守住票 02 的三条验收点与不变式:
|
||||
* 提交不得改变生效快照、同对象至多一条待审、越权必须拒绝。
|
||||
*/
|
||||
class DriverCertServiceImplTest {
|
||||
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final Long VEHICLE_ID = 8001L;
|
||||
private static final String PLATE = "桂A32523";
|
||||
|
||||
private IDriverAppService driverAppService;
|
||||
private IDriverService driverService;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private DriverCertServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
driverAppService = mock(IDriverAppService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
service = new DriverCertServiceImpl(driverAppService, driverService,
|
||||
transportVehicleService, certRecordService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 车辆提交
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:落一条待审记录,且状态必须是待审(不能被框架默认值变成已通过)")
|
||||
void submitVehicleCreatesPendingRecord() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
|
||||
.thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
request.setVin("VIN123");
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(captor.capture());
|
||||
TransportCertRecord saved = captor.getValue();
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getStatus());
|
||||
assertEquals(TransportCertRecord.BIZ_TYPE_VEHICLE, saved.getBizType());
|
||||
assertEquals(VEHICLE_ID, saved.getBizId());
|
||||
assertEquals(PLATE, saved.getBizKey());
|
||||
assertTrue(saved.getSnapshotJson().contains("VIN123"), "快照应承载本次提交的全量内容");
|
||||
assertNull(saved.getRejectReason(), "新提交不得继承上一条的驳回原因");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交人必须记用户ID:记成司机档案ID会让审核页「提交人」显示一串数字")
|
||||
void submitRecordsUserIdAsSubmitUser() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest vehicleRequest = completeVehicleRequest(PLATE);
|
||||
vehicleRequest.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(vehicleRequest);
|
||||
|
||||
PersonCertSubmitRequest personRequest = new PersonCertSubmitRequest();
|
||||
personRequest.setRealName("张三");
|
||||
service.submitPersonCert(personRequest);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService, times(2)).save(captor.capture());
|
||||
|
||||
for (TransportCertRecord saved : captor.getAllValues()) {
|
||||
assertEquals(USER_ID, saved.getSubmitUser(),
|
||||
"提交人必须是用户ID(审核页靠它翻译姓名),不能是司机档案ID");
|
||||
assertNotEquals(saved.getBizId(), saved.getSubmitUser(),
|
||||
"提交人与认证对象主键是两种ID,不应相同");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:不修改生效快照指针(提交不影响车辆可用性)")
|
||||
void submitVehicleDoesNotTouchEffectivePointer() {
|
||||
|
||||
Long effectiveId = 777L;
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
// 更新车辆时只能用展示缓存字段,且绝不能带上 effectiveCertRecordId
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
TransportVehicle updated = captor.getValue();
|
||||
assertNull(updated.getEffectiveCertRecordId(),
|
||||
"提交链路不得写生效快照指针,否则「曾通过即永久可用」会被破坏");
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, updated.getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:前一条待审被置为已作废,不存在两条待审")
|
||||
void submitVehicleSupersedesPreviousPending() {
|
||||
TransportCertRecord previous = record(500L, TransportCertRecord.STATUS_PENDING);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
|
||||
.thenReturn(previous);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(captor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, captor.getValue().getStatus());
|
||||
assertEquals(500L, captor.getValue().getId(), "被作废的应是原来那条待审");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:前一条已是终态时不得被改动(尤其不能碰已通过的那条)")
|
||||
void submitVehicleLeavesTerminalRecordsAlone() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
|
||||
// selectPendingRecord 只返回待审;已通过的不该被它返回
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 越权
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:提交未绑定到自己名下的车辆必须拒绝")
|
||||
void submitVehicleRejectsUnboundVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:车牌写法差异不影响绑定判定(间隔符/大小写)")
|
||||
void submitVehicleAcceptsBoundVehicleWithMessyPlate() {
|
||||
// 司机档案里带间隔符,车辆表里是干净写法
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523"));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523");
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
verify(certRecordService).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:司机未绑定任何车辆时提交必须拒绝")
|
||||
void submitVehicleRejectsWhenNoBoundVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(null));
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:不可查看他人车辆的认证记录列表")
|
||||
void listRecordsRejectsOthersVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:不可查看其他司机的认证记录列表")
|
||||
void listRecordsRejectsOtherDriver() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, 12345L));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 个人认证
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:落待审记录,且再提交时作废前一条待审")
|
||||
void submitPersonCertSupersedes() {
|
||||
TransportCertRecord previous = record(600L, TransportCertRecord.STATUS_PENDING);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
|
||||
.thenReturn(previous);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
request.setIdCardNo("110101199001011234");
|
||||
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> saved = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(saved.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getValue().getStatus());
|
||||
assertEquals(TransportCertRecord.BIZ_TYPE_DRIVER, saved.getValue().getBizType());
|
||||
assertEquals(DRIVER_ID, saved.getValue().getBizId());
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> updated = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(updated.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, updated.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:不写生效快照指针(只刷展示缓存)")
|
||||
void submitPersonCertDoesNotTouchEffectivePointer() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
assertNull(captor.getValue().getEffectiveCertRecordId(),
|
||||
"提交链路不得写生效快照指针");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void submitPersonCertClearsStaleRejectReason() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
request.setIdCardNo("110101199001011234");
|
||||
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<Wrapper<Driver>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(driverService).update(captor.capture());
|
||||
LambdaUpdateWrapper<Driver> wrapper = (LambdaUpdateWrapper<Driver>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null), "清空必须显式写 null");
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == DRIVER_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:内容为空必须拒绝")
|
||||
void submitPersonCertRejectsEmpty() {
|
||||
assertThrows(ServiceException.class, () -> service.submitPersonCert(null));
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 生效值读取
|
||||
|
||||
@Test
|
||||
@DisplayName("读生效值:有指针时返回指针指向的那条快照内容(不是最新提交)")
|
||||
void getEffectivePersonCertReadsPointedRecord() {
|
||||
Long effectiveId = 777L;
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
|
||||
PersonCertSubmitRequest effectiveContent = new PersonCertSubmitRequest();
|
||||
effectiveContent.setRealName("旧生效值");
|
||||
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
|
||||
effective.setSnapshotJson(JSON.toJSONString(effectiveContent));
|
||||
when(certRecordService.getById(effectiveId)).thenReturn(effective);
|
||||
|
||||
PersonCertSubmitRequest result = service.getEffectivePersonCert();
|
||||
|
||||
assertEquals("旧生效值", result.getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("读生效值:从未认证通过时返回 null(而不是抛错或返回最新提交)")
|
||||
void getEffectivePersonCertReturnsNullWhenNeverCertified() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
|
||||
assertNull(service.getEffectivePersonCert());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)")
|
||||
void resubmitKeepsEffectiveValue() {
|
||||
Long effectiveId = 777L;
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest oldContent = new PersonCertSubmitRequest();
|
||||
oldContent.setRealName("旧的已通过内容");
|
||||
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
|
||||
effective.setSnapshotJson(JSON.toJSONString(oldContent));
|
||||
when(certRecordService.getById(effectiveId)).thenReturn(effective);
|
||||
|
||||
// 提交新内容
|
||||
PersonCertSubmitRequest newContent = new PersonCertSubmitRequest();
|
||||
newContent.setRealName("新提交待审内容");
|
||||
service.submitPersonCert(newContent);
|
||||
|
||||
// 生效值不受影响
|
||||
PersonCertSubmitRequest stillEffective = service.getEffectivePersonCert();
|
||||
assertEquals("旧的已通过内容", stillEffective.getRealName());
|
||||
assertNotEquals("新提交待审内容", stillEffective.getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录列表:类型非法必须拒绝")
|
||||
void listRecordsRejectsInvalidBizType() {
|
||||
assertThrows(ServiceException.class, () -> service.listRecords("truck", 1L));
|
||||
assertThrows(ServiceException.class, () -> service.listRecords(null, 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录列表:本人司机记录按传入条件查询(走统一查询入口)")
|
||||
void listRecordsForSelf() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
|
||||
.thenReturn(List.of());
|
||||
|
||||
assertTrue(service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID).isEmpty());
|
||||
verify(certRecordService, times(1))
|
||||
.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 删除车辆认证
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:逻辑删除车辆,且**不删**认证记录")
|
||||
void deleteVehicleCertIsLogicalOnly() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
|
||||
|
||||
service.deleteVehicleCert(VEHICLE_ID);
|
||||
|
||||
// 走的是 deleteLogic(is_deleted=1),不是物理删除
|
||||
verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID));
|
||||
verify(transportVehicleService, never()).removeById(anyLong());
|
||||
// 认证记录是历史依据,任何情况下都不得被删
|
||||
verify(certRecordService, never()).removeById(anyLong());
|
||||
verify(certRecordService, never()).removeByIds(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:越权必须拒绝")
|
||||
void deleteVehicleCertRejectsUnbound() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.deleteVehicleCert(VEHICLE_ID));
|
||||
verify(transportVehicleService, never()).deleteLogic(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:车牌写法差异不影响绑定判定")
|
||||
void deleteVehicleCertAcceptsMessyPlate() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523"));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null));
|
||||
|
||||
service.deleteVehicleCert(VEHICLE_ID);
|
||||
verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 新增车辆
|
||||
|
||||
@Test
|
||||
@DisplayName("新增车辆:车牌未登记时就地建档并绑定到本人,且新车不被视为已认证")
|
||||
void submitVehicleCreatesUnregisteredVehicle() {
|
||||
Driver driver = driver(null); // 司机名下还没有任何车
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
// 全库都查不到这个车牌
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂A·32523");
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
// 建档:保存了一辆车,车挂牌照为规范化结果,且状态为启用
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).save(vehicleCaptor.capture());
|
||||
assertEquals("桂A32523", vehicleCaptor.getValue().getPlateNo());
|
||||
assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType());
|
||||
assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"新建的车不得自带生效快照——是否已认证只能由审核通过决定");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(),
|
||||
"所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错");
|
||||
assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(),
|
||||
"业务关系由司机在表单里选,服务端照实落库、不替用户猜");
|
||||
assertNotNull(vehicleCaptor.getValue().getRegistrationNo(),
|
||||
"机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(),
|
||||
"所有人是行驶证上的登记主体,与所属组织是两个字段");
|
||||
|
||||
// 绑定:司机的 driving_vehicle 被追加该车牌
|
||||
ArgumentCaptor<Driver> driverCaptor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(driverCaptor.capture());
|
||||
assertEquals("桂A32523", driverCaptor.getValue().getDrivingVehicle());
|
||||
|
||||
// 仍然产生一条待审记录
|
||||
verify(certRecordService).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增车辆:车牌已登记在别人名下必须拒绝,不能顺手改归属")
|
||||
void submitVehicleRejectsPlateOwnedByAnotherDriver() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂B11111"));
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(vehicle(VEHICLE_ID, "桂A32523", null))); // 已登记,但不在本人名下
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523");
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增车辆:已登记且属于本人时直接复用,不重复建档")
|
||||
void submitVehicleReusesOwnRegisteredVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523"));
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(vehicle(VEHICLE_ID, "桂A·32523", null))); // 写法不同但同一辆
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523");
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(captor.capture());
|
||||
assertEquals(VEHICLE_ID, captor.getValue().getBizId(), "应挂到既有车辆上");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增车辆:车牌为空必须拒绝")
|
||||
void submitVehicleRejectsBlankPlate() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523"));
|
||||
|
||||
// 内容给全,确保被拒的原因是车牌为空而不是别的字段(否则这条用例形同虚设)
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(" ");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertTrue(exception.getMessage().contains("车牌"), exception.getMessage());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助
|
||||
|
||||
@Test
|
||||
@DisplayName("重新认证同样要过内容校验:只校验「新建车辆」等于给重新认证留了后门")
|
||||
void submitVehicleValidatesContentOnResubmit() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
request.setOwnerName("");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("所有人不能为空", exception.getMessage());
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新认证不要求司机档案有所属组织:那一列只在新建车辆时写,与管理归属无关")
|
||||
void submitVehicleResubmitDoesNotRequireOrganization() {
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setOrganizationName(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
verify(certRecordService).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机建车:字段不齐时给可读提示,而不是让数据库报 NOT NULL")
|
||||
void submitVehicleRejectsIncompleteRequest() {
|
||||
Driver driver = driver(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523");
|
||||
request.setApprovedLoadKg(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("核定载质量不能为空", exception.getMessage());
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车")
|
||||
void submitVehicleRejectsDriverWithoutOrganization() {
|
||||
Driver driver = driver(null);
|
||||
driver.setOrganizationName(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(completeVehicleRequest("桂A32523")));
|
||||
assertTrue(exception.getMessage().contains("所属组织"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效期只填止期:起期为空也允许提交(司机端不采集起期)")
|
||||
void submitVehicleAcceptsBlankStartDate() {
|
||||
Driver driver = driver(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(null);
|
||||
request.setRoadTransportCertStartDate(null);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).save(vehicleCaptor.capture());
|
||||
assertNotNull(vehicleCaptor.getValue().getDrivingLicenseEndDate(), "止期照常写入车辆行");
|
||||
assertNull(vehicleCaptor.getValue().getDrivingLicenseStartDate(), "起期为空就是空,不该编一个值出来");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效期未勾长期有效时止期必填:起期可以空,止期不能")
|
||||
void submitVehicleRejectsBlankEndDate() {
|
||||
Driver driver = driver(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(null);
|
||||
request.setDrivingLicenseEndDate(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("请选择行驶证有效期", exception.getMessage());
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效时止期可以为空,此时不再比较起止先后")
|
||||
void submitVehicleAllowsLongTermWithoutEndDate() {
|
||||
Driver driver = driver(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1));
|
||||
request.setDrivingLicenseEndDate(null);
|
||||
request.setDrivingLicenseLongTerm(1);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(captor.capture());
|
||||
assertTrue(captor.getValue().getSnapshotJson().contains("\"drivingLicenseLongTerm\":1"),
|
||||
"长期有效标志要落进快照");
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份字段齐全的车辆认证提交内容
|
||||
* <p>
|
||||
* 车辆表有十个 NOT NULL 且无默认值的列,少一个就会触发数据库错误而不是业务提示,
|
||||
* 故用例统一从这里取请求,避免每个用例各自拼一半字段。
|
||||
*/
|
||||
private VehicleCertSubmitRequest completeVehicleRequest(String plateNo) {
|
||||
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
|
||||
request.setPlateNo(plateNo);
|
||||
request.setVehicleType("重型货车");
|
||||
request.setOwnerName("桂物物流集团");
|
||||
request.setPlateColor("黄牌");
|
||||
request.setEnergyType("柴油");
|
||||
request.setBusinessRelation("管理挂靠");
|
||||
request.setApprovedLoadKg(18000);
|
||||
request.setTractionMassKg(35000);
|
||||
request.setOuterLength(5990);
|
||||
request.setOuterWidth(2300);
|
||||
request.setOuterHeight(3100);
|
||||
request.setDrivingLicenseNo("450100123456");
|
||||
request.setDrivingLicenseImage("http://oss.example.com/xsz-front.jpg");
|
||||
request.setDrivingLicenseStartDate(LocalDate.of(2018, 3, 9));
|
||||
request.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9));
|
||||
request.setRoadTransportCertNo("桂交运管0001");
|
||||
request.setRoadTransportCertStartDate(LocalDate.of(2020, 1, 1));
|
||||
request.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1));
|
||||
request.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1));
|
||||
return request;
|
||||
}
|
||||
|
||||
private Driver driver(String drivingVehicle) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
// 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("张三");
|
||||
// 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败,
|
||||
// 故建车链路要求它非空,fixture 也必须给
|
||||
driver.setOrganizationName("桂物物流集团");
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private TransportVehicle vehicle(Long id, String plateNo, Long effectiveId) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(id);
|
||||
vehicle.setPlateNo(plateNo);
|
||||
vehicle.setEffectiveCertRecordId(effectiveId);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
private TransportCertRecord record(Long id, Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(id);
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson("{}");
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
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.mockito.ArgumentCaptor;
|
||||
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;
|
||||
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 节点打卡照片的类型与张数口径。
|
||||
* <p>
|
||||
* 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 URL 列表,类型只能按下标回推凭证类型。
|
||||
* 两者并存期间,新版每个类型至少 1 张(含固定的「打卡照片」)、每类各自至多 9 张;
|
||||
* 旧版必须照旧提交成功——用「每个类型至少 1 张」去拦旧版,等于让未升级的小程序一次性失效,
|
||||
* 而旧版根本表达不出「打卡照片」。
|
||||
*/
|
||||
class DriverNodePunchPhotoTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3255";
|
||||
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
|
||||
private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg";
|
||||
private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg";
|
||||
/** 该节点勾了两类凭证,故照片类型取值域是「打卡照片 + 磅单 + 签收单」 */
|
||||
private static final String PROCESS_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true,
|
||||
"location":false,"uploadVoucher":true,"voucherTypes":["磅单","签收单"]}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.getOne(any())).thenReturn(waybill());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(waybillService.updateById(any())).thenReturn(true);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1);
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 新版:显式类型
|
||||
|
||||
@Test
|
||||
@DisplayName("显式类型按提交原样落库,「打卡照片」不会被改写成凭证类型")
|
||||
void typedPhotoItemsKeepExplicitType() {
|
||||
submit(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO))));
|
||||
|
||||
List<Map<String, String>> items = savedPhotoItems();
|
||||
assertEquals(List.of("打卡照片", "磅单", "签收单"), typesOf(items));
|
||||
assertEquals(List.of(CLOCK_PHOTO, WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("域外类型被拒且不落库")
|
||||
void typeOutsideNodeDomainIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO),
|
||||
photo("卸货照片", "https://oss/unload.jpg"))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("卸货照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺少某个必填类型时被拒:每个类型至少 1 张")
|
||||
void missingVoucherTypeIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("签收单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺少「打卡照片」时被拒:它不由过程配置决定,每个打卡节点都必须有")
|
||||
void missingClockPhotoIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("打卡照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 9 张可提交")
|
||||
void ninthPhotoOfSameTypeIsAccepted() {
|
||||
submit(nodeSubmit(typedPhotos("磅单", 9)));
|
||||
|
||||
assertEquals(9, countByType(savedPhotoItems(), "磅单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 10 张被拒且不落库:上限按类型各自计数")
|
||||
void tenthPhotoOfSameTypeIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(typedPhotos("磅单", 10)));
|
||||
|
||||
assertTrue(exception.getMessage().contains("磅单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 回显
|
||||
|
||||
@Test
|
||||
@DisplayName("回显按类型原样透传:「打卡照片」不会被丢弃,也不会被改写成凭证类型")
|
||||
void punchNodeDisplayKeepsEveryType() {
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(storedPunch()));
|
||||
|
||||
DriverPunchNodeVO node = service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
|
||||
.filter(item -> "load".equals(item.getKey()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertEquals(List.of("打卡照片", "磅单"),
|
||||
node.getPhotos().stream().map(DriverPunchPhotoVO::getType).toList());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 旧版:裸 URL 列表
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版裸 URL 列表仍能提交成功,类型按下标回推凭证类型")
|
||||
void legacyPhotoUrlsStillSubmit() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotos(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO));
|
||||
|
||||
submit(dto);
|
||||
|
||||
List<Map<String, String>> items = savedPhotoItems();
|
||||
assertEquals(List.of("磅单", "签收单"), typesOf(items));
|
||||
assertEquals(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版不带「打卡照片」也放行:裸 URL 列表表达不出这个类型")
|
||||
void legacyPhotoUrlsWithoutClockPhotoStillSubmit() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotos(List.of(WAYBILL_PHOTO));
|
||||
|
||||
submit(dto);
|
||||
|
||||
assertEquals(List.of("磅单"), typesOf(savedPhotoItems()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版与新版都没带照片时被拒")
|
||||
void withoutAnyPhotoIsRejected() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
|
||||
ServiceException exception = submitExpectRejected(dto);
|
||||
|
||||
assertTrue(exception.getMessage().contains("照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void submit(NodeSubmitDTO dto) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
service.submitNode(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceException submitExpectRejected(NodeSubmitDTO dto) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return assertThrows(ServiceException.class, () -> service.submitNode(dto));
|
||||
}
|
||||
}
|
||||
|
||||
/** 已入库的打卡流水;断言只在这里取一次插入参数 */
|
||||
private WaybillNodePunch savedPunch() {
|
||||
ArgumentCaptor<WaybillNodePunch> captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
|
||||
verify(nodePunchMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, String>> savedPhotoItems() {
|
||||
return (List<Map<String, String>>) JsonUtil.parse(savedPunch().getPhotos(), List.class);
|
||||
}
|
||||
|
||||
private List<String> typesOf(List<Map<String, String>> items) {
|
||||
return items.stream().map(item -> item.get("type")).toList();
|
||||
}
|
||||
|
||||
private List<String> urlsOf(List<Map<String, String>> items) {
|
||||
return items.stream().map(item -> item.get("url")).toList();
|
||||
}
|
||||
|
||||
private int countByType(List<Map<String, String>> items, String type) {
|
||||
return (int) items.stream().filter(item -> type.equals(item.get("type"))).count();
|
||||
}
|
||||
|
||||
/** 每个必填类型各 1 张,另有 count 张「磅单」 */
|
||||
private List<PunchPhotoDTO> typedPhotos(String type, int count) {
|
||||
List<PunchPhotoDTO> items = new ArrayList<>();
|
||||
items.add(photo("打卡照片", CLOCK_PHOTO));
|
||||
items.add(photo("签收单", RECEIPT_PHOTO));
|
||||
for (int i = 0; i < count; i++) {
|
||||
items.add(photo(type, "https://oss/" + type + i + ".jpg"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private NodeSubmitDTO nodeSubmit(List<PunchPhotoDTO> photoItems) {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotoItems(photoItems);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PunchPhotoDTO photo(String type, String url) {
|
||||
PunchPhotoDTO photo = new PunchPhotoDTO();
|
||||
photo.setType(type);
|
||||
photo.setUrl(url);
|
||||
return photo;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/** 库里的打卡流水:一条带「打卡照片」与「磅单」的回显数据 */
|
||||
private WaybillNodePunch storedPunch() {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setId(6601L);
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode("load");
|
||||
punch.setNodeName("装货");
|
||||
punch.setPunchTime(new Date());
|
||||
punch.setPhotos(JsonUtil.toJson(List.of(
|
||||
Map.of("type", "打卡照片", "url", CLOCK_PHOTO),
|
||||
Map.of("type", "磅单", "url", WAYBILL_PHOTO))));
|
||||
return punch;
|
||||
}
|
||||
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
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.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;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
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.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端节点卡片上的异常标签与「相关异常」列表。
|
||||
* <p>
|
||||
* 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。
|
||||
* 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的;
|
||||
* 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。
|
||||
*/
|
||||
class DriverPunchNodeCardTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5501L;
|
||||
|
||||
/** 接单不参与打卡,故不出现在节点卡片里 */
|
||||
private static final String PROCESS_JSON = """
|
||||
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
Waybill waybill = waybill();
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch("load")));
|
||||
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常挂到每个可打卡节点,含已打卡的与在途的")
|
||||
void waybillLevelExceptionIsTaggedOnEveryNode() {
|
||||
stubUnresolved(nodeException("load", 11L), waybillException(12L));
|
||||
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes();
|
||||
|
||||
assertEquals(3, nodes.size(), "接单不是打卡节点,不该出现在卡片里");
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("load").getDone()), "装货这条流水已打卡");
|
||||
for (DriverPunchNodeVO node : nodes.values()) {
|
||||
assertTrue(Boolean.TRUE.equals(node.getHasWaybillException()),
|
||||
"整单异常作用于整张运单," + node.getKey() + " 也该挂标签");
|
||||
assertTrue(node.getRelatedExceptionIds().contains("12"),
|
||||
node.getKey() + " 的相关异常里应当有整单异常");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点自己的异常只进它自己的相关列表,不牵连后续节点")
|
||||
void nodeExceptionOnlyBelongsToItsOwnNode() {
|
||||
stubUnresolved(nodeException("load", 11L));
|
||||
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes();
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("load").getHasException()));
|
||||
assertEquals(List.of("11"), nodes.get("load").getRelatedExceptionIds());
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasException()),
|
||||
"签收只是被装货的异常牵连阻断,不挂「异常」标签");
|
||||
assertTrue(nodes.get("sign").getRelatedExceptionIds().isEmpty());
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasWaybillException()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("两种异常同时存在时,该节点的相关列表有两条且按上报时间升序")
|
||||
void bothKindsAreListedInReportOrder() {
|
||||
stubUnresolved(nodeException("load", 11L), waybillException(12L));
|
||||
|
||||
DriverPunchNodeVO load = punchNodes().get("load");
|
||||
|
||||
assertEquals(List.of("11", "12"), load.getRelatedExceptionIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("异常全部了结后两个字段都回到「无」")
|
||||
void withoutUnresolvedExceptionsNoTagAndNoRelatedIds() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes();
|
||||
|
||||
for (DriverPunchNodeVO node : nodes.values()) {
|
||||
assertFalse(Boolean.TRUE.equals(node.getHasWaybillException()));
|
||||
assertTrue(node.getRelatedExceptionIds().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未了结异常按上报时间升序取出,卡片顺序才有意义")
|
||||
void unresolvedExceptionsAreReadInReportOrder() {
|
||||
stubUnresolved(nodeException("load", 11L));
|
||||
|
||||
punchNodes();
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture());
|
||||
for (LambdaQueryWrapper<ExceptionDisposal> wrapper : captor.getAllValues()) {
|
||||
String sql = wrapper.getTargetSql();
|
||||
assertTrue(sql.contains("report_time"), "未了结异常要按上报时间取:" + sql);
|
||||
assertTrue(sql.contains("disposal_status"), "未了结的判据要落在处置状态上:" + sql);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("相关异常 id 序列化成字符串,前端拿到大整数不会丢精度")
|
||||
void relatedExceptionIdsSerializeAsStrings() {
|
||||
stubUnresolved(nodeException("load", 9007199254740993L));
|
||||
|
||||
String json = JsonUtil.toJson(punchNodes().get("load"));
|
||||
|
||||
assertTrue(json.contains("\"9007199254740993\""), json);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubUnresolved(ExceptionDisposal... disposals) {
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(disposals));
|
||||
}
|
||||
|
||||
private Map<String, DriverPunchNodeVO> punchNodes() {
|
||||
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
|
||||
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
|
||||
}
|
||||
|
||||
private ExceptionDisposal nodeException(String nodeCode, Long id) {
|
||||
return exception(id, nodeCode, LocalDateTime.of(2026, 10, 6, 9, 30));
|
||||
}
|
||||
|
||||
private ExceptionDisposal waybillException(Long id) {
|
||||
return exception(id, null, LocalDateTime.of(2026, 10, 6, 10, 0));
|
||||
}
|
||||
|
||||
private ExceptionDisposal exception(Long id, String nodeCode, LocalDateTime reportTime) {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(id);
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setNodeCode(nodeCode);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setIsDeleted(0);
|
||||
disposal.setReportTime(reportTime);
|
||||
return disposal;
|
||||
}
|
||||
|
||||
private WaybillNodePunch nodePunch(String nodeCode) {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setId(6501L);
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode(nodeCode);
|
||||
punch.setPunchTime(new Date());
|
||||
return punch;
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
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.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;
|
||||
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机打完末位打卡节点后运单自动完成。
|
||||
* <p>
|
||||
* 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、
|
||||
* 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水,
|
||||
* 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。
|
||||
*/
|
||||
class DriverWaybillAutoCompleteTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5801L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3255";
|
||||
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
|
||||
|
||||
/** 末位打卡节点是「签收」,没有回单节点 */
|
||||
private static final String SIGN_LAST_JSON = """
|
||||
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位打卡节点是「签收」,回单启用且需确认完成 */
|
||||
private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """
|
||||
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 末位打卡节点是「发货」,不是到货/卸货/签收 */
|
||||
private static final String DISPATCH_LAST_JSON = """
|
||||
[{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(waybillService.updateById(any())).thenReturn(true);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 命中
|
||||
|
||||
@Test
|
||||
@DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted")
|
||||
void lastDeliveryPunchCompletesWaybill() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断")
|
||||
void completionFlagFollowsServiceReturn() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 不命中
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是发货时只打卡不完成")
|
||||
void punchOnNonDeliveryLastNodeDoesNotComplete() {
|
||||
givenWaybill(DISPATCH_LAST_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("dispatch");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用且需确认完成时只打卡不完成")
|
||||
void returnNeedingConfirmationDoesNotComplete() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 前置:运单本身能否完成
|
||||
|
||||
@Test
|
||||
@DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功")
|
||||
void draftWaybillIsPunchedButNotCompleted() {
|
||||
givenWaybill(SIGN_LAST_JSON, "draft");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待执行与调度中间态同样允许自动完成")
|
||||
void dispatchableStatusesStillComplete() {
|
||||
for (String status : List.of("pending", "waiting_dispatch", "dispatching")) {
|
||||
givenWaybill(SIGN_LAST_JSON, status);
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦")
|
||||
void unresolvedExceptionBlocksAutoComplete() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload")));
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private DriverNodePunchVO submit(String nodeCode) {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode(nodeCode);
|
||||
dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO)));
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return service.submitNode(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private void givenWaybill(String processJson, String businessStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610080001");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getOne(any())).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private PunchPhotoDTO photo(String type, String url) {
|
||||
PunchPhotoDTO photo = new PunchPhotoDTO();
|
||||
photo.setType(type);
|
||||
photo.setUrl(url);
|
||||
return photo;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private ExceptionDisposal unresolvedException(String nodeCode) {
|
||||
ExceptionDisposal exception = new ExceptionDisposal();
|
||||
exception.setNodeCode(nodeCode);
|
||||
exception.setDisposalStatus("processing");
|
||||
return exception;
|
||||
}
|
||||
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端卡片上的发货地 / 到货地。
|
||||
* <p>
|
||||
* 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列),
|
||||
* 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。
|
||||
*/
|
||||
class DriverWaybillCardLocationTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
|
||||
private static final String PROCESS_JSON = """
|
||||
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道";
|
||||
private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址")
|
||||
void nameFallsBackToAddressWhenBlank() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setDepartureName(null);
|
||||
waybill.setDepartureAddress(DEPARTURE_ADDRESS);
|
||||
waybill.setArrivalName("");
|
||||
waybill.setArrivalAddress(ARRIVAL_ADDRESS);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID);
|
||||
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromName());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToName());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToAddress());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("地点名称有值时用它,不被地址覆盖")
|
||||
void nameWinsWhenPresent() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setDepartureName("南宁市西乡塘区");
|
||||
waybill.setDepartureAddress(DEPARTURE_ADDRESS);
|
||||
waybill.setArrivalName("河池市金城江区");
|
||||
waybill.setArrivalAddress(ARRIVAL_ADDRESS);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID);
|
||||
|
||||
assertEquals("南宁市西乡塘区", card.getFromName());
|
||||
assertEquals("河池市金城江区", card.getToName());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToAddress());
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD-20261007-0006");
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
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.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 节点是否已打卡的判定。
|
||||
* <p>
|
||||
* 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除,
|
||||
* 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。
|
||||
* 节点编码按忽略大小写比对——流水里的写法与过程配置的 key 不保证一致。
|
||||
*/
|
||||
class DriverWaybillNodePunchTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5201L;
|
||||
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
mock(IWaybillService.class),
|
||||
mock(IDriverService.class),
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有该节点的打卡流水就是未打卡")
|
||||
void withoutPunchRecordNodeIsNotPunched() {
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertFalse(service.isNodePunched(WAYBILL_ID, "load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有该节点的打卡流水即已打卡")
|
||||
void punchRecordMakesNodePunched() {
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("load")));
|
||||
|
||||
assertTrue(service.isNodePunched(WAYBILL_ID, "load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("别的节点的打卡流水不算本节点已打卡")
|
||||
void otherNodePunchDoesNotCount() {
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("arrive_scene")));
|
||||
|
||||
assertFalse(service.isNodePunched(WAYBILL_ID, "load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("流水里的节点编码大小写与查询不一致时仍算同一节点")
|
||||
void nodeCodeComparisonIgnoresCase() {
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("LOAD")));
|
||||
|
||||
assertTrue(service.isNodePunched(WAYBILL_ID, "load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单或节点缺失时判为未打卡,不抛异常")
|
||||
void missingArgumentsMeanNotPunched() {
|
||||
assertFalse(service.isNodePunched(null, "load"));
|
||||
assertFalse(service.isNodePunched(WAYBILL_ID, " "));
|
||||
}
|
||||
|
||||
private WaybillNodePunch punch(String nodeCode) {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setId(6201L);
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode(nodeCode);
|
||||
return punch;
|
||||
}
|
||||
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
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.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;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
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.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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机对运单的归属校验(异常上报等按运单操作的前置条件)。
|
||||
* <p>
|
||||
* 口径:当前用户手机号匹配运单 {@code driverPhone}。
|
||||
* 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。
|
||||
*/
|
||||
class DriverWaybillOwnershipTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5301L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PHONE = "13800138000";
|
||||
|
||||
private IDriverService driverService;
|
||||
private IWaybillService waybillService;
|
||||
private DriverWaybillServiceImpl 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() {
|
||||
driverService = mock(IDriverService.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
mock(WaybillNodePunchMapper.class),
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("登录账号没有司机档案时被拒:管理端与调度员不提供代报")
|
||||
void withoutDriverProfileIsRejected() {
|
||||
when(driverService.getOne(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runOwnershipCheck(WAYBILL_ID));
|
||||
|
||||
assertEquals("未找到当前登录司机档案", exception.getMessage());
|
||||
verify(waybillService, never()).getOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机档案与登录态都没有手机号时被拒")
|
||||
void withoutPhoneIsRejected() {
|
||||
Driver driver = driver(null);
|
||||
when(driverService.getOne(any())).thenReturn(driver);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runOwnershipCheck(WAYBILL_ID));
|
||||
|
||||
assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage());
|
||||
}
|
||||
|
||||
@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));
|
||||
when(waybillService.getOne(any())).thenReturn(waybill());
|
||||
|
||||
assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID));
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单ID为空时被拒")
|
||||
void withoutWaybillIdIsRejected() {
|
||||
when(driverService.getOne(any())).thenReturn(driver(PHONE));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runOwnershipCheck(null));
|
||||
|
||||
assertEquals("运单ID不能为空", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
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);
|
||||
service.assertCurrentDriverOwnsWaybill(waybillId);
|
||||
}
|
||||
}
|
||||
|
||||
private Driver driver(String mobile) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("韦司机");
|
||||
driver.setMobile(mobile);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.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;
|
||||
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机拒单后运单对本人脱离。
|
||||
* <p>
|
||||
* 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。
|
||||
* <p>
|
||||
* 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行
|
||||
* 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验,
|
||||
* 不假装 mock 会替我们过滤。
|
||||
*/
|
||||
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 Long REJECTED_ID = 7201L;
|
||||
|
||||
private IDriverService driverService;
|
||||
private CertificationGate certificationGate;
|
||||
private IWaybillService waybillService;
|
||||
private DriverWaybillServiceImpl 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() {
|
||||
driverService = mock(IDriverService.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
certificationGate = mock(CertificationGate.class);
|
||||
lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
certificationGate,
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
mock(WaybillNodePunchMapper.class),
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
lenient().when(driverService.getOne(any())).thenReturn(driver());
|
||||
lenient().when(waybillService.updateById(any())).thenReturn(true);
|
||||
// syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE;
|
||||
// 真实实现是把入参校正后返回同一个对象
|
||||
lenient().when(waybillService.syncDriverAcceptState(any()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列")
|
||||
void pendingPreviewQueryExcludesRejected() {
|
||||
when(waybillService.list(ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any())).thenReturn(List.of());
|
||||
|
||||
runAsDriver(() -> service.pendingPreview(5));
|
||||
|
||||
String sql = capturedListQuery().getTargetSql();
|
||||
assertTrue(sql.contains("driver_accept_status"),
|
||||
"取单条件必须排除已拒单:" + sql);
|
||||
assertTrue(sql.toUpperCase().contains("AND"),
|
||||
"排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单列表的取单条件同样排除已拒单")
|
||||
void pageQueryExcludesRejected() {
|
||||
when(waybillService.list(ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any())).thenReturn(List.of());
|
||||
|
||||
runAsDriver(() -> service.page(1, 10, 0, null));
|
||||
|
||||
String sql = capturedListQuery().getTargetSql();
|
||||
assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tab 计数不再把已拒单算进待接单")
|
||||
void tabCountsOnFilteredInputCountsOnlyUnrejected() {
|
||||
// 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成
|
||||
when(waybillService.list(ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any())).thenReturn(List.of(
|
||||
waybill(7202L, "YD-20261001-0002", "pending", "pending"),
|
||||
waybill(7203L, "YD-20261001-0003", "running", "accepted"),
|
||||
waybill(7204L, "YD-20261001-0004", "completed", "accepted")));
|
||||
|
||||
DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts());
|
||||
|
||||
assertEquals(1, counts.getPending(), "待接单只算那张未被拒的");
|
||||
assertEquals(1, counts.getDoing());
|
||||
assertEquals(1, counts.getDone());
|
||||
assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未拒单的运单不被误伤:待接与运输中照常出现")
|
||||
void unrejectedWaybillsUnaffected() {
|
||||
when(waybillService.list(ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any())).thenReturn(List.of(
|
||||
waybill(7202L, "YD-20261001-0002", "pending", "pending"),
|
||||
waybill(7203L, "YD-20261001-0003", "running", "accepted")));
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.page(1, 10, null, null));
|
||||
|
||||
assertEquals(2, page.getTotal(), "未被拒的运单条数不变");
|
||||
assertEquals(2, page.getRecords().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单运单的详情不可达:取单条件已把它挡住")
|
||||
void detailOfRejectedIsUnreachable() {
|
||||
// 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错
|
||||
when(waybillService.getOne(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.detail(REJECTED_ID)));
|
||||
|
||||
assertEquals("运单不存在或无权操作", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单运单不能自行接回来")
|
||||
void acceptOfRejectedIsRejected() {
|
||||
when(waybillService.getOne(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.accept(REJECTED_ID)));
|
||||
|
||||
assertEquals("运单不存在或无权操作", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据")
|
||||
void enrouteSubmitKeepsUnfilteredScope() {
|
||||
EnrouteSubmitDTO dto = new EnrouteSubmitDTO();
|
||||
dto.setWaybillId(REJECTED_ID);
|
||||
when(waybillService.getOne(any())).thenReturn(rejectedWaybill());
|
||||
|
||||
// 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」,
|
||||
// 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.submitEnroute(dto)));
|
||||
|
||||
assertEquals("该运单未启用在途打卡", exception.getMessage(),
|
||||
"在途打卡不应因运单已拒单而在取单阶段被挡下");
|
||||
verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private <T> T runAsDriver(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> capturedListQuery() {
|
||||
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(waybillService).list(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("黄大富");
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private Waybill rejectedWaybill() {
|
||||
return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected");
|
||||
}
|
||||
|
||||
private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(no);
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setDriverAcceptStatus(acceptStatus);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
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.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
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.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collection;
|
||||
|
||||
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;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常处置列表按运单过滤。
|
||||
* <p>
|
||||
* 过滤必须落在运单主键上:运单号是模糊匹配,{@code YD001} 会命中 {@code YD0011},
|
||||
* 从某个节点点进来的「本运单异常」会多出别人的记录。
|
||||
*/
|
||||
class ExceptionDisposalListQueryTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5401L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
mock(ExceptionDisposalFollowRecordMapper.class),
|
||||
mock(IWaybillService.class),
|
||||
mock(IDriverWaybillService.class));
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配")
|
||||
void waybillIdFilterIsExact() {
|
||||
ExceptionDisposalVO query = new ExceptionDisposalVO();
|
||||
query.setWaybillId(WAYBILL_ID);
|
||||
|
||||
LambdaQueryWrapper<ExceptionDisposal> wrapper = capturedQuery(query);
|
||||
|
||||
assertTrue(wrapper.getTargetSql().contains("waybill_id ="),
|
||||
"按运单过滤要用等值:" + wrapper.getTargetSql());
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().stream()
|
||||
.anyMatch(value -> WAYBILL_ID.equals(value)
|
||||
|| (value instanceof Collection<?> values && values.contains(WAYBILL_ID))),
|
||||
"过滤条件要带上运单ID:" + wrapper.getParamNameValuePairs());
|
||||
assertFalse(wrapper.getTargetSql().contains("waybill_no"),
|
||||
"运单号是 like,会把前缀相同的运单一起捞回来:" + wrapper.getTargetSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不传运单ID时不加运单条件,列表原有入口行为不变")
|
||||
void withoutWaybillIdNoWaybillCondition() {
|
||||
LambdaQueryWrapper<ExceptionDisposal> wrapper = capturedQuery(new ExceptionDisposalVO());
|
||||
|
||||
assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private LambdaQueryWrapper<ExceptionDisposal> capturedQuery(ExceptionDisposalVO query) {
|
||||
IPage<ExceptionDisposalVO> page = service.selectPage(new Page<>(1, 10), query);
|
||||
assertTrue(page.getRecords().isEmpty());
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(disposalMapper).selectPage(any(), 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);
|
||||
}
|
||||
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。
|
||||
* <p>
|
||||
* 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事:
|
||||
* 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定,
|
||||
* 但解除条件互不相同,所以断言分开下。
|
||||
*/
|
||||
class ExceptionDisposalReportGateTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5101L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private IWaybillService waybillService;
|
||||
private IDriverWaybillService driverWaybillService;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverWaybillService = mock(IDriverWaybillService.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService);
|
||||
inject("baseMapper", disposalMapper);
|
||||
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(disposalMapper.selectCount(any())).thenReturn(0L);
|
||||
when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 已打卡
|
||||
|
||||
@Test
|
||||
@DisplayName("已打卡的普通节点不能再上报异常")
|
||||
void punchedNodeRejectsReport() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已打卡,不能上报异常", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途已打卡仍可上报:它可重复打卡、没有完成含义,打卡与否不说明环节已经过去")
|
||||
void punchedTransitNodeStillAcceptsReport() {
|
||||
stubNode("transit", "在途", "transit");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "transit")).thenReturn(true);
|
||||
|
||||
submitOk("transit");
|
||||
|
||||
assertEquals("transit", savedDisposal().getNodeCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未打卡的普通节点照常可上报")
|
||||
void unpunchedNodeAcceptsReport() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
|
||||
|
||||
submitOk("load");
|
||||
|
||||
assertEquals("load", savedDisposal().getNodeCode());
|
||||
assertEquals("装货", savedDisposal().getNodeName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 已有未了结异常
|
||||
|
||||
@Test
|
||||
@DisplayName("节点已有待处理异常时拒绝再报")
|
||||
void pendingNodeExceptionRejectsReport() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁")
|
||||
void processingNodeExceptionRejectsReport() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
List<Object> params = capturedUnresolvedParams();
|
||||
assertTrue(params.contains("completed") && params.contains("revoked"),
|
||||
"未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报")
|
||||
void settledNodeExceptionAllowsReportAgain() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(0L);
|
||||
|
||||
submitOk("load");
|
||||
|
||||
assertEquals("load", savedDisposal().getNodeCode());
|
||||
assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")),
|
||||
"只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上")
|
||||
void otherNodesExceptionDoesNotBlockReport() {
|
||||
stubNode("unload", "卸货", "cargo");
|
||||
stubUnresolvedCount(0L);
|
||||
|
||||
submitOk("unload");
|
||||
|
||||
assertEquals("unload", savedDisposal().getNodeCode());
|
||||
assertTrue(capturedUnresolvedParams().contains("unload"),
|
||||
"节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点")
|
||||
void nodeCodeComparisonIgnoresCase() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage());
|
||||
assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(TRIM(node_code)) ="),
|
||||
"忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql());
|
||||
assertTrue(capturedUnresolvedParams().contains("load"),
|
||||
"参数取节点 key 的小写形式:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("门禁拿过程配置里的节点 key 去比对,而不是请求体里那串写法")
|
||||
void gateUsesNodeKeyResolvedFromProcessConfig() {
|
||||
// 请求体写的是大写,过程配置里的 key 是小写;打卡流水按配置的 key 落库
|
||||
when(driverWaybillService.findPunchNode(WAYBILL_ID, "LOAD")).thenReturn(node("load", "装货", "cargo"));
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true);
|
||||
|
||||
ServiceException exception = submitExpectRejected("LOAD");
|
||||
|
||||
assertEquals("该节点已打卡,不能上报异常", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未了结异常的查询按运单收敛,并排除已删除的行")
|
||||
void unresolvedQueryIsScopedToWaybillAndSkipsDeletedRows() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
|
||||
|
||||
submitOk("load");
|
||||
|
||||
String sql = capturedUnresolvedQuery().getTargetSql();
|
||||
|
||||
assertTrue(sql.contains("waybill_id"),
|
||||
"未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql);
|
||||
assertTrue(sql.contains("is_deleted"),
|
||||
"已删除的异常不算未了结:" + sql);
|
||||
assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID),
|
||||
"运单条件取本次上报的运单:" + capturedUnresolvedParams());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 整单异常不受门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报")
|
||||
void waybillLevelReportIsNotGated() {
|
||||
stubUnresolvedCount(1L);
|
||||
|
||||
submitOk(null);
|
||||
|
||||
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
|
||||
verify(disposalMapper).insert(captor.capture());
|
||||
assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个");
|
||||
verify(driverWaybillService, never()).findPunchNode(any(), any());
|
||||
verify(driverWaybillService, never()).isNodePunched(any(), any());
|
||||
verify(disposalMapper, never()).selectCount(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("上报人取登录态,请求体里的上报人一律被忽略")
|
||||
void reporterComesFromLoginState() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
|
||||
ExceptionDisposal request = request("load");
|
||||
request.setReporterId(999999L);
|
||||
request.setReporterName("冒充的人");
|
||||
|
||||
runSubmit(request);
|
||||
|
||||
assertEquals(USER_ID, savedDisposal().getReporterId());
|
||||
assertEquals("韦司机", savedDisposal().getReporterName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 上报人归属
|
||||
|
||||
@Test
|
||||
@DisplayName("上报人不属于该运单时在落库前就被拒")
|
||||
void reportIsRejectedWhenReporterDoesNotOwnWaybill() {
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false);
|
||||
doThrow(new ServiceException("运单不存在或无权操作"))
|
||||
.when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("运单不存在或无权操作", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常同样校验上报人:没有节点不等于没有上报人")
|
||||
void waybillLevelReportAlsoChecksReporterOwnership() {
|
||||
doThrow(new ServiceException("未找到当前登录司机档案"))
|
||||
.when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID);
|
||||
|
||||
ServiceException exception = submitExpectRejected(null);
|
||||
|
||||
assertEquals("未找到当前登录司机档案", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubNode(String key, String name, String type) {
|
||||
when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type));
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false);
|
||||
}
|
||||
|
||||
private void stubUnresolvedCount(long count) {
|
||||
when(disposalMapper.selectCount(any())).thenReturn(count);
|
||||
}
|
||||
|
||||
/** 上报门禁查未了结异常的那条 count 查询 */
|
||||
private LambdaQueryWrapper<ExceptionDisposal> capturedUnresolvedQuery() {
|
||||
ArgumentCaptor<LambdaQueryWrapper<ExceptionDisposal>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(disposalMapper).selectCount(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/** 上述查询的参数值,集合型参数摊平成元素 */
|
||||
private List<Object> capturedUnresolvedParams() {
|
||||
LambdaQueryWrapper<ExceptionDisposal> query = capturedUnresolvedQuery();
|
||||
// MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值
|
||||
query.getTargetSql();
|
||||
Collection<Object> values = query.getParamNameValuePairs().values();
|
||||
List<Object> params = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value instanceof Collection<?> nested) {
|
||||
params.addAll(nested);
|
||||
} else {
|
||||
params.add(value);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private ServiceException submitExpectRejected(String nodeCode) {
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> runSubmit(request(nodeCode)));
|
||||
// 门禁在落库之前:被拒时库里不能多一行
|
||||
verify(disposalMapper, never()).insert(any(ExceptionDisposal.class));
|
||||
return exception;
|
||||
}
|
||||
|
||||
private void submitOk(String nodeCode) {
|
||||
runSubmit(request(nodeCode));
|
||||
}
|
||||
|
||||
private void runSubmit(ExceptionDisposal request) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
|
||||
MockedStatic<UserDisplayCache> userCache = mockStatic(UserDisplayCache.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦司机");
|
||||
service.submitReport(request);
|
||||
}
|
||||
}
|
||||
|
||||
private ExceptionDisposal savedDisposal() {
|
||||
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
|
||||
verify(disposalMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private ExceptionDisposal request(String nodeCode) {
|
||||
ExceptionDisposal request = new ExceptionDisposal();
|
||||
request.setWaybillId(WAYBILL_ID);
|
||||
request.setExceptionType("cargo_damage");
|
||||
request.setReportDescription("装货时发现外包装破损");
|
||||
request.setNodeCode(nodeCode);
|
||||
return request;
|
||||
}
|
||||
|
||||
private Map<String, Object> node(String key, String name, String type) {
|
||||
Map<String, Object> cfg = new HashMap<>();
|
||||
cfg.put("key", key);
|
||||
cfg.put("name", name);
|
||||
cfg.put("type", type);
|
||||
cfg.put("enabled", true);
|
||||
cfg.put("punch", true);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setVehicleNo("桂AAW3255");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 调度端首页待处理事项:异常待处置 + 重新派单待处理。
|
||||
* <p>
|
||||
* 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign
|
||||
* 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间
|
||||
* (后者会被任何无关改动刷新)。
|
||||
* <p>
|
||||
* 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存,
|
||||
* 在单测里没有上下文。
|
||||
*/
|
||||
class ManageAdminHomeFeedTest {
|
||||
|
||||
private static final Long EXCEPTION_ID = 8801L;
|
||||
private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001";
|
||||
|
||||
private ManageWaybillServiceImpl service;
|
||||
private IWaybillService waybillService;
|
||||
private IExceptionDisposalService exceptionService;
|
||||
|
||||
@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() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
exceptionService = mock(IExceptionDisposalService.class);
|
||||
service = new ManageWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
exceptionService,
|
||||
mock(IRiskDisposalService.class),
|
||||
mock(IDriverWaybillService.class));
|
||||
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定")
|
||||
void rejectedWaybillAppearsAsReassignTodo() {
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
|
||||
.thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突")));
|
||||
|
||||
List<AdminTodoItemVO> feed = service.buildAdminFeed();
|
||||
|
||||
assertEquals(1, feed.size());
|
||||
AdminTodoItemVO item = feed.get(0);
|
||||
assertEquals("reassign", item.getType());
|
||||
assertEquals("重新派单待处理", item.getTitle());
|
||||
assertEquals("重新派单", item.getActionLabel());
|
||||
assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo());
|
||||
assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键");
|
||||
assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc());
|
||||
assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(),
|
||||
"跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据");
|
||||
assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒单原因为空时正文不带空冒号")
|
||||
void withoutRejectReasonKeepsDescClean() {
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
|
||||
.thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null)));
|
||||
|
||||
AdminTodoItemVO item = service.buildAdminFeed().get(0);
|
||||
|
||||
assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc());
|
||||
assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有拒单时只出异常条目,不凭空造重派条目")
|
||||
void withoutRejectedWaybillNoReassignTodo() {
|
||||
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(List.of(exceptionDisposal()));
|
||||
|
||||
List<AdminTodoItemVO> feed = service.buildAdminFeed();
|
||||
|
||||
assertEquals(1, feed.size());
|
||||
assertEquals("exception", feed.get(0).getType());
|
||||
assertEquals("异常待处置", feed.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("两类条目按各自业务发生时间倒序混排")
|
||||
void feedIsMergedByBusinessTime() {
|
||||
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(List.of(exceptionDisposal()));
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
|
||||
.thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突")));
|
||||
|
||||
List<AdminTodoItemVO> feed = service.buildAdminFeed();
|
||||
|
||||
assertEquals(2, feed.size());
|
||||
assertEquals(List.of("reassign", "exception"),
|
||||
feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()),
|
||||
"拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与")
|
||||
void reassignQueryExcludesTerminalStatus() {
|
||||
service.buildAdminFeed();
|
||||
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService).list(captor.capture());
|
||||
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) captor.getValue();
|
||||
String sql = wrapper.getTargetSql();
|
||||
|
||||
assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql);
|
||||
assertTrue(sql.contains("business_status"),
|
||||
"已取消/已完成的运单不该再催重新派单:" + sql);
|
||||
|
||||
Set<Object> values = new HashSet<>(wrapper.getParamNameValuePairs().values());
|
||||
assertTrue(values.contains("rejected"), "要带上已拒绝:" + values);
|
||||
assertTrue(values.contains("pending") && values.contains("running"),
|
||||
"业务状态只应放待执行与进行中:" + values);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Waybill rejectedWaybill(Long id, String driverName, String reason) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(REJECTED_WAYBILL_NO);
|
||||
waybill.setDriverName(driverName);
|
||||
waybill.setDriverAcceptStatus("rejected");
|
||||
waybill.setBusinessStatus("pending");
|
||||
waybill.setDriverRejectReason(reason);
|
||||
waybill.setDriverRejectTime(yesterdayAt22());
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private ExceptionDisposal exceptionDisposal() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(EXCEPTION_ID);
|
||||
disposal.setWaybillNo("YD-20261006-0001");
|
||||
disposal.setReporterName("黄宇富");
|
||||
disposal.setExceptionType("交通异常");
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setReportTime(LocalDateTime.now().minusHours(26));
|
||||
return disposal;
|
||||
}
|
||||
|
||||
private Date yesterdayAt22() {
|
||||
java.util.Calendar calendar = java.util.Calendar.getInstance();
|
||||
calendar.add(java.util.Calendar.DAY_OF_MONTH, -1);
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, 22);
|
||||
calendar.set(java.util.Calendar.MINUTE, 0);
|
||||
calendar.set(java.util.Calendar.SECOND, 0);
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0);
|
||||
return calendar.getTime();
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
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.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.mapper.TransportCertRecordMapper;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 个人认证的「提交 → 生效值不变 → 审核通过 → 生效值更新」闭环测试。
|
||||
* <p>
|
||||
* 这是司机认证最关键的一条业务闭环,也是单看提交侧或审核侧都验证不到的:
|
||||
* 提交之后司机侧查到的必须仍是**旧生效值**(否则等于提交即自动认证),
|
||||
* 审核通过之后才变成**新值**(否则认证永远不生效)。
|
||||
* <p>
|
||||
* 用一个内存版的记录存储把两侧接起来,避免两边各自 mock 成「恰好一致」。
|
||||
*/
|
||||
class PersonCertRoundTripTest {
|
||||
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
|
||||
private IDriverAppService driverAppService;
|
||||
private IDriverService driverService;
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private TransportCertRecordMapper certRecordMapper;
|
||||
private DriverCertServiceImpl driverCertService;
|
||||
private CertAuditServiceImpl certAuditService;
|
||||
|
||||
/**
|
||||
* 内存记录表:Id → 记录
|
||||
*/
|
||||
private final Map<Long, TransportCertRecord> recordStore = new HashMap<>();
|
||||
private Driver driver;
|
||||
private long idSequence = 100L;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
recordStore.clear();
|
||||
idSequence = 100L;
|
||||
|
||||
driverAppService = mock(IDriverAppService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
certRecordMapper = mock(TransportCertRecordMapper.class);
|
||||
ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class);
|
||||
|
||||
driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setDriverName("张三");
|
||||
when(driverAppService.currentDriverEntity()).thenAnswer(invocation -> driver);
|
||||
// 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走;
|
||||
// 证件字段也要跟着落地,否则「审核通过写回档案」这条链路在测试里验不到
|
||||
when(driverService.updateById(any(Driver.class))).thenAnswer(invocation -> {
|
||||
Driver patch = invocation.getArgument(0);
|
||||
if (patch.getEffectiveCertRecordId() != null) {
|
||||
driver.setEffectiveCertRecordId(patch.getEffectiveCertRecordId());
|
||||
}
|
||||
copyCertFields(patch, driver);
|
||||
return true;
|
||||
});
|
||||
|
||||
stubRecordStore();
|
||||
|
||||
driverCertService = new DriverCertServiceImpl(driverAppService, driverService,
|
||||
transportVehicleService, certRecordService);
|
||||
certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交后仍是旧生效值,审核通过后才变新值")
|
||||
void submitThenApprove() {
|
||||
// 1) 先走一遍完整流程,让该司机有一个生效值
|
||||
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(firstRecordId, true, null);
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"审核通过后应能查到生效值");
|
||||
|
||||
// 2) 提交新内容:生效值必须**不变**
|
||||
Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"提交只产生待审记录,不得改变生效值——否则等于提交即自动认证");
|
||||
assertEquals(firstRecordId, driver.getEffectiveCertRecordId(),
|
||||
"指针应仍指向原来那条已通过记录");
|
||||
|
||||
// 3) 审核通过:生效值才变成新值
|
||||
certAuditService.audit(secondRecordId, true, null);
|
||||
assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"审核通过后生效值应切到新记录");
|
||||
assertEquals(secondRecordId, driver.getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)")
|
||||
void rejectKeepsEffectiveValue() {
|
||||
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(firstRecordId, true, null);
|
||||
|
||||
Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
certAuditService.audit(secondRecordId, false, "证件照片模糊");
|
||||
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"驳回只终结该次提交,既得认证必须保持有效");
|
||||
assertEquals(firstRecordId, driver.getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("从未认证过时,审核通过前查不到生效值")
|
||||
void noEffectiveBeforeFirstApproval() {
|
||||
assertNull(driverCertService.getEffectivePersonCert());
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
assertNull(driverCertService.getEffectivePersonCert(),
|
||||
"待审期间不应有生效值");
|
||||
|
||||
certAuditService.audit(recordId, true, null);
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("连续提交两次,不存在两条待审,且旧待审被作废")
|
||||
void consecutiveSubmitsLeaveSinglePending() {
|
||||
Long first = driverCertService.submitPersonCert(personCert("第一次", "110101199001011234"));
|
||||
Long second = driverCertService.submitPersonCert(personCert("第二次", "110101199001011234"));
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, recordStore.get(first).getStatus(),
|
||||
"前一条待审应被作废");
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, recordStore.get(second).getStatus());
|
||||
|
||||
long pendingCount = recordStore.values().stream()
|
||||
.filter(record -> TransportCertRecord.STATUS_PENDING.equals(record.getStatus()))
|
||||
.count();
|
||||
assertEquals(1L, pendingCount, "同一司机至多一条待审");
|
||||
|
||||
// 被作废的那条不能再被审核(否则会把已作废的记录变成生效值)
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springblade.core.log.exception.ServiceException.class,
|
||||
() -> certAuditService.audit(first, true, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)")
|
||||
void approvedRecordCannotBeReaudited() {
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springblade.core.log.exception.ServiceException.class,
|
||||
() -> certAuditService.audit(recordId, false, "事后反悔"));
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"重复审核被拒后,生效值不受影响");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 内存记录存储
|
||||
|
||||
/**
|
||||
* 用内存 Map 承接记录读写,让两个服务真实地共享同一份状态
|
||||
*/
|
||||
private void stubRecordStore() {
|
||||
when(certRecordService.save(any(TransportCertRecord.class))).thenAnswer(invocation -> {
|
||||
TransportCertRecord record = invocation.getArgument(0);
|
||||
record.setId(++idSequence);
|
||||
recordStore.put(record.getId(), copyOf(record));
|
||||
return true;
|
||||
});
|
||||
when(certRecordService.updateById(any(TransportCertRecord.class))).thenAnswer(invocation -> {
|
||||
TransportCertRecord patch = invocation.getArgument(0);
|
||||
TransportCertRecord stored = recordStore.get(patch.getId());
|
||||
if (stored != null) {
|
||||
if (patch.getStatus() != null) {
|
||||
stored.setStatus(patch.getStatus());
|
||||
}
|
||||
stored.setRejectReason(patch.getRejectReason());
|
||||
stored.setAuditTime(patch.getAuditTime());
|
||||
stored.setAuditUser(patch.getAuditUser());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(certRecordService.getById(anyLong()))
|
||||
.thenAnswer(invocation -> recordStore.get(invocation.getArgument(0, Long.class)));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenAnswer(invocation -> {
|
||||
String bizType = invocation.getArgument(0);
|
||||
Long bizId = invocation.getArgument(1);
|
||||
return recordStore.values().stream()
|
||||
.filter(record -> bizType.equals(record.getBizType())
|
||||
&& bizId.equals(record.getBizId())
|
||||
&& TransportCertRecord.STATUS_PENDING.equals(record.getStatus()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
});
|
||||
}
|
||||
|
||||
private TransportCertRecord copyOf(TransportCertRecord source) {
|
||||
return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖
|
||||
* <p>
|
||||
* 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」
|
||||
* 这类只有真实更新策略才暴露的问题。
|
||||
*/
|
||||
private void copyCertFields(Driver patch, Driver target) {
|
||||
if (patch.getDriverName() != null) {
|
||||
target.setDriverName(patch.getDriverName());
|
||||
}
|
||||
if (patch.getIdCardNo() != null) {
|
||||
target.setIdCardNo(patch.getIdCardNo());
|
||||
}
|
||||
if (patch.getIdCardFront() != null) {
|
||||
target.setIdCardFront(patch.getIdCardFront());
|
||||
}
|
||||
if (patch.getIdCardBack() != null) {
|
||||
target.setIdCardBack(patch.getIdCardBack());
|
||||
}
|
||||
if (patch.getHeadPhoto() != null) {
|
||||
target.setHeadPhoto(patch.getHeadPhoto());
|
||||
}
|
||||
if (patch.getDrivingType() != null) {
|
||||
target.setDrivingType(patch.getDrivingType());
|
||||
}
|
||||
if (patch.getDrivingLicenseNo() != null) {
|
||||
target.setDrivingLicenseNo(patch.getDrivingLicenseNo());
|
||||
}
|
||||
if (patch.getDrivingLicenseFront() != null) {
|
||||
target.setDrivingLicenseFront(patch.getDrivingLicenseFront());
|
||||
}
|
||||
if (patch.getDrivingLicenseBack() != null) {
|
||||
target.setDrivingLicenseBack(patch.getDrivingLicenseBack());
|
||||
}
|
||||
if (patch.getQualificationType() != null) {
|
||||
target.setQualificationType(patch.getQualificationType());
|
||||
}
|
||||
if (patch.getQualificationNo() != null) {
|
||||
target.setQualificationNo(patch.getQualificationNo());
|
||||
}
|
||||
if (patch.getQualificationFront() != null) {
|
||||
target.setQualificationFront(patch.getQualificationFront());
|
||||
}
|
||||
if (patch.getQualificationBack() != null) {
|
||||
target.setQualificationBack(patch.getQualificationBack());
|
||||
}
|
||||
}
|
||||
|
||||
private PersonCertSubmitRequest personCert(String realName, String idCardNo) {
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName(realName);
|
||||
request.setIdCardNo(idCardNo);
|
||||
request.setIdCardFrontUrl("http://example.com/front.jpg");
|
||||
request.setIdCardBackUrl("http://example.com/back.jpg");
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致")
|
||||
void approveWritesCertContentBackToDriver() {
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setHeadPhoto("http://example.com/head.jpg");
|
||||
request.setDriverClass("A2");
|
||||
request.setDriverFileNo("450100769875");
|
||||
request.setDriverLicenseFrontUrl("http://example.com/dl-front.jpg");
|
||||
request.setDriverLicenseBackUrl("http://example.com/dl-back.jpg");
|
||||
request.setQualificationType("道路旅客运输");
|
||||
request.setQualificationNo("Q0001");
|
||||
request.setQualificationFront("http://example.com/q-front.jpg");
|
||||
request.setQualificationBack("http://example.com/q-back.jpg");
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(request);
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
|
||||
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
|
||||
assertEquals("http://example.com/head.jpg", driver.getHeadPhoto());
|
||||
assertEquals("A2", driver.getDrivingType());
|
||||
assertEquals("450100769875", driver.getDrivingLicenseNo());
|
||||
assertEquals("http://example.com/dl-front.jpg", driver.getDrivingLicenseFront());
|
||||
assertEquals("http://example.com/dl-back.jpg", driver.getDrivingLicenseBack());
|
||||
assertEquals("道路旅客运输", driver.getQualificationType());
|
||||
assertEquals("Q0001", driver.getQualificationNo());
|
||||
assertEquals("http://example.com/q-front.jpg", driver.getQualificationFront());
|
||||
assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话")
|
||||
void approveOverwritesIdentity() {
|
||||
driver.setDriverName("张三(档案名)");
|
||||
driver.setIdCardNo("110101199001011234");
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
// 审核通过即认可这次申报:档案里必须也是申报的姓名,
|
||||
// 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字
|
||||
assertEquals("张三改名", driver.getDriverName());
|
||||
assertEquals("110101199001011234", driver.getIdCardNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:申报的身份证号已属于另一名司机时阻断审核,不得产生两条同号档案")
|
||||
void approveRejectsIdCardTakenByAnotherDriver() {
|
||||
Driver other = new Driver();
|
||||
other.setId(8888L);
|
||||
other.setIdCardNo("110101199001011999");
|
||||
when(driverService.getOne(any())).thenReturn(other);
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> certAuditService.audit(recordId, true, null));
|
||||
assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage());
|
||||
// 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审);
|
||||
// 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来
|
||||
assertNull(driver.getIdCardNo(), "不得把属于别人的证件号写进当前档案");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:证件内容一个字都不写回档案")
|
||||
void rejectWritesNothingBack() {
|
||||
driver.setHeadPhoto("http://example.com/original-head.jpg");
|
||||
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setHeadPhoto("http://example.com/new-head.jpg");
|
||||
Long recordId = driverCertService.submitPersonCert(request);
|
||||
certAuditService.audit(recordId, false, "证件照片模糊");
|
||||
|
||||
assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(),
|
||||
"驳回只终结该次提交,档案内容不得被改动");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过")
|
||||
void legacySnapshotSkipsWriteBackWithoutFailingAudit() {
|
||||
// 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
|
||||
// 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
TransportCertRecord record = recordStore.get(recordId);
|
||||
record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}");
|
||||
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(),
|
||||
"快照读不懂不该阻断审核");
|
||||
assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换");
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
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.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 管理端打卡时间轴的接单节点判定测试。
|
||||
* <p>
|
||||
* 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡;
|
||||
* 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。
|
||||
* 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。
|
||||
*/
|
||||
class WaybillPunchRecordListTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 6101L;
|
||||
private static final String WAYBILL_NO = "YD202610060002";
|
||||
|
||||
/** 需确认接单:接单节点 + 一个普通打卡节点 */
|
||||
private static final String CONFIRM_ACCEPT_JSON =
|
||||
"[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true,"
|
||||
+ "\"confirmMode\":\"yes\"},"
|
||||
+ "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]";
|
||||
|
||||
/** 无需确认接单 */
|
||||
private static final String NO_CONFIRM_ACCEPT_JSON =
|
||||
"[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true,"
|
||||
+ "\"confirmMode\":\"no_confirm_accept\"},"
|
||||
+ "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]";
|
||||
|
||||
private WaybillMapper waybillMapper;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private WaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
service = new WaybillServiceImpl();
|
||||
inject("baseMapper", waybillMapper);
|
||||
inject("waybillNodePunchMapper", nodePunchMapper);
|
||||
inject("waybillEnroutePunchMapper", enroutePunchMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机确认接单:接单节点显示已打卡,时间取接单时间")
|
||||
void acceptNodePunchedWhenDriverAccepted() {
|
||||
Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted");
|
||||
waybill.setDriverAcceptTime(parseTime("2026-10-06 10:04:07"));
|
||||
stubWaybill(waybill);
|
||||
|
||||
WaybillPunchRecordItemVO accept = acceptRecord();
|
||||
|
||||
assertTrue(accept.getPunched());
|
||||
assertEquals("已打卡", accept.getStatusName());
|
||||
assertEquals("2026-10-06 10:04:07", accept.getPunchTime());
|
||||
assertNull(accept.getId(), "派生记录没有打卡流水,不能带流水 id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待接单:接单节点仍是未打卡")
|
||||
void acceptNodeUnpunchedWhenPending() {
|
||||
stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "pending"));
|
||||
|
||||
WaybillPunchRecordItemVO accept = acceptRecord();
|
||||
|
||||
assertFalse(accept.getPunched());
|
||||
assertEquals("未打卡", accept.getStatusName());
|
||||
assertEquals("", accept.getPunchTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单:接单节点仍是未打卡")
|
||||
void acceptNodeUnpunchedWhenRejected() {
|
||||
stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "rejected"));
|
||||
|
||||
assertFalse(acceptRecord().getPunched());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无需确认接单且已进入进行中:接单节点已打卡,但没有完成时刻")
|
||||
void acceptNodePunchedWithoutTimeWhenNoConfirm() {
|
||||
stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "running", null));
|
||||
|
||||
WaybillPunchRecordItemVO accept = acceptRecord();
|
||||
|
||||
assertTrue(accept.getPunched());
|
||||
assertEquals("已打卡", accept.getStatusName());
|
||||
assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无需确认接单但仍是草稿:接单节点未打卡")
|
||||
void acceptNodeUnpunchedWhenDraft() {
|
||||
stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "draft", null));
|
||||
|
||||
assertFalse(acceptRecord().getPunched());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无需确认接单但停在待执行:接单节点未打卡")
|
||||
void acceptNodeUnpunchedWhenPendingWithoutConfirm() {
|
||||
stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "pending", null));
|
||||
|
||||
assertFalse(acceptRecord().getPunched());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已取消的无需确认接单运单:判为已打卡——状态本身不带是否曾进入进行中的历史")
|
||||
void acceptNodePunchedWhenCancelled() {
|
||||
stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "cancelled", null));
|
||||
|
||||
assertTrue(acceptRecord().getPunched());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("接单节点的派生不影响普通节点:有流水的普通节点照旧已打卡并带流水 id")
|
||||
void otherNodesStillReadPunchTable() {
|
||||
stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"));
|
||||
WaybillNodePunch loadPunch = new WaybillNodePunch();
|
||||
loadPunch.setId(7001L);
|
||||
loadPunch.setWaybillId(WAYBILL_ID);
|
||||
loadPunch.setNodeCode("load");
|
||||
loadPunch.setNodeName("装货");
|
||||
loadPunch.setPunchTime(parseTime("2026-10-06 09:30:00"));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(loadPunch));
|
||||
|
||||
WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream()
|
||||
.filter(record -> "load".equals(record.getNodeCode()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("时间轴里没有装货节点"));
|
||||
|
||||
assertTrue(load.getPunched());
|
||||
assertEquals("已打卡", load.getStatusName());
|
||||
assertEquals("2026-10-06 09:30:00", load.getPunchTime());
|
||||
assertEquals(7001L, load.getId());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubWaybill(Waybill waybill) {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private WaybillPunchRecordItemVO acceptRecord() {
|
||||
return service.listPunchRecords(WAYBILL_ID).getRecords().stream()
|
||||
.filter(record -> "accept".equals(record.getNodeCode()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("时间轴里没有接单节点"));
|
||||
}
|
||||
|
||||
private Waybill waybill(String processJson, String businessStatus, String acceptStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo(WAYBILL_NO);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setDriverAcceptStatus(acceptStatus);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private static Date parseTime(String text) {
|
||||
try {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
|
||||
} catch (ParseException exception) {
|
||||
throw new IllegalArgumentException(text, exception);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
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.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
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.assertNotNull;
|
||||
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.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 管理端打卡记录编辑测试。
|
||||
* <p>
|
||||
* 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来
|
||||
* 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段;
|
||||
* 订正的照片只限「每类各自最多 9 张」、不校验必填——订正的是已经发生的事实,不是重新走一遍流程。
|
||||
*/
|
||||
class WaybillPunchRecordSaveTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 6001L;
|
||||
private static final String WAYBILL_NO = "YD202610060001";
|
||||
private static final String PROCESS_JSON = "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,"
|
||||
+ "\"punch\":true,\"location\":true,\"uploadCargo\":true,\"cargoTypes\":[\"重量\",\"数量\"],"
|
||||
+ "\"uploadVoucher\":true,\"voucherTypes\":[\"委托单\"]}]";
|
||||
|
||||
private WaybillMapper waybillMapper;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private WaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
service = new WaybillServiceImpl();
|
||||
inject("baseMapper", waybillMapper);
|
||||
inject("waybillNodePunchMapper", nodePunchMapper);
|
||||
inject("waybillEnroutePunchMapper", enroutePunchMapper);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 过程节点打卡
|
||||
|
||||
@Test
|
||||
@DisplayName("补录未打卡节点:新建流水,节点取过程配置,不记司机、异常标记为否")
|
||||
void insertNodePunchWhenNoneExists() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "LOAD", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPunchTime("2026-10-06 09:30:00");
|
||||
request.setWeight("12.5");
|
||||
request.setRemark("补录");
|
||||
request.setPhotos(List.of(photo("委托单", "https://oss/a.jpg")));
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<WaybillNodePunch> captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
|
||||
verify(nodePunchMapper).insert(captor.capture());
|
||||
WaybillNodePunch saved = captor.getValue();
|
||||
// 大小写不一致也应按过程配置回写节点 key,否则司机端按 key 查不到这条打卡
|
||||
assertEquals("load", saved.getNodeCode());
|
||||
assertEquals("装货", saved.getNodeName());
|
||||
assertEquals(WAYBILL_ID, saved.getWaybillId());
|
||||
assertEquals(WAYBILL_NO, saved.getWaybillNo());
|
||||
assertNotNull(saved.getPunchTime());
|
||||
assertEquals("12.5", saved.getWeight());
|
||||
assertEquals(0, saved.getExceptionFlag());
|
||||
assertNull(saved.getDriverId(), "管理端补录不能记到司机头上");
|
||||
assertNotNull(saved.getPhotos());
|
||||
assertTrue(saved.getPhotos().contains("https://oss/a.jpg"), "照片URL应写入 photos");
|
||||
assertTrue(saved.getPhotos().contains("委托单"), "凭证类型应随照片一起写入");
|
||||
verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑已打卡节点:改最新一条,不新建,也不改写异常标记")
|
||||
@SuppressWarnings("unchecked")
|
||||
void updateLatestNodePunch() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
WaybillNodePunch existed = nodePunch(7001L, "load", "装货");
|
||||
existed.setExceptionFlag(1);
|
||||
existed.setWeight("10");
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(existed));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPunchTime("2026-10-06 11:00:00");
|
||||
request.setWeight("20");
|
||||
|
||||
service.savePunchRecord(request);
|
||||
|
||||
ArgumentCaptor<Wrapper<WaybillNodePunch>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(nodePunchMapper).update(isNull(), captor.capture());
|
||||
LambdaUpdateWrapper<WaybillNodePunch> wrapper =
|
||||
(LambdaUpdateWrapper<WaybillNodePunch>) captor.getValue();
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains("20"), "重量应写进更新语句");
|
||||
// updateById 会跳过 null 字段:清空地址/货量/照片必须由显式 set 写 null 才生效
|
||||
assertTrue(values.contains(null), "被清空的字段必须显式写 null");
|
||||
assertFalse(wrapper.getSqlSet().contains("exception_flag"),
|
||||
"异常标记与异常处置表绑定,编辑打卡不得动它");
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取消打卡:清空该节点全部流水,历史流水不得顶上来")
|
||||
@SuppressWarnings("unchecked")
|
||||
void deleteAllNodePunchesWhenUnpunched() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any()))
|
||||
.thenReturn(List.of(nodePunch(7001L, "load", "装货"), nodePunch(7002L, "load", "装货")));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(false);
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<Collection<Long>> captor = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(nodePunchMapper).deleteBatchIds(captor.capture());
|
||||
assertEquals(Set.of(7001L, 7002L), new HashSet<>(captor.getValue()));
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定")
|
||||
void rejectAcceptNodePunchEdit() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
|
||||
WaybillPunchRecordSaveRequest punched = request("node", "accept", "接单");
|
||||
punched.setPunched(true);
|
||||
assertThrows(ServiceException.class, () -> service.savePunchRecord(punched));
|
||||
|
||||
WaybillPunchRecordSaveRequest unpunched = request("node", "accept", "接单");
|
||||
unpunched.setPunched(false);
|
||||
assertThrows(ServiceException.class, () -> service.savePunchRecord(unpunched));
|
||||
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
verify(nodePunchMapper, never()).deleteBatchIds(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 打卡照片张数
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 9 张可保存:上限按类型各自计数")
|
||||
void acceptNinthPhotoOfSameType() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(sameTypePhotos("委托单", 9));
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 10 张被拒且不落库")
|
||||
void rejectTenthPhotoOfSameType() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(sameTypePhotos("委托单", 10));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.savePunchRecord(request));
|
||||
|
||||
assertTrue(exception.getMessage().contains("委托单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("订正不校验照片必填:一张不传也能保存")
|
||||
void missingPhotosDoNotBlockPunchRecordEdit() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(List.of());
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<WaybillNodePunch> captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
|
||||
verify(nodePunchMapper).insert(captor.capture());
|
||||
assertNull(captor.getValue().getPhotos());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 在途打卡
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑在途打卡:改最新一条,单张照片字段只留第一张")
|
||||
@SuppressWarnings("unchecked")
|
||||
void updateEnroutePunchKeepsFirstPhoto() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
WaybillEnroutePunch existed = new WaybillEnroutePunch();
|
||||
existed.setId(8001L);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途");
|
||||
request.setPunched(true);
|
||||
request.setPunchTime("2026-10-06 12:00:00");
|
||||
request.setAddress("沪昆高速");
|
||||
request.setPhotos(List.of(
|
||||
photo("货物照片", "https://oss/a.jpg"),
|
||||
photo("货物照片", "https://oss/b.jpg")));
|
||||
|
||||
service.savePunchRecord(request);
|
||||
|
||||
ArgumentCaptor<Wrapper<WaybillEnroutePunch>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(enroutePunchMapper).update(isNull(), captor.capture());
|
||||
LambdaUpdateWrapper<WaybillEnroutePunch> wrapper =
|
||||
(LambdaUpdateWrapper<WaybillEnroutePunch>) captor.getValue();
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains("https://oss/a.jpg"), "只保留第一张照片");
|
||||
assertFalse(values.contains("https://oss/b.jpg"), "第二张不应写入");
|
||||
assertTrue(values.contains("沪昆高速"));
|
||||
assertTrue(wrapper.getSqlSet().contains("photo"), "照片字段必须显式更新");
|
||||
verify(enroutePunchMapper, never()).insert(any(WaybillEnroutePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取消在途打卡:清空该运单全部在途流水")
|
||||
@SuppressWarnings("unchecked")
|
||||
void deleteAllEnroutePunchesWhenUnpunched() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
WaybillEnroutePunch first = new WaybillEnroutePunch();
|
||||
first.setId(8001L);
|
||||
WaybillEnroutePunch second = new WaybillEnroutePunch();
|
||||
second.setId(8002L);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途");
|
||||
request.setPunched(false);
|
||||
|
||||
service.savePunchRecord(request);
|
||||
|
||||
ArgumentCaptor<Collection<Long>> captor = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(enroutePunchMapper).deleteBatchIds(captor.capture());
|
||||
assertEquals(Set.of(8001L, 8002L), new HashSet<>(captor.getValue()));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 入参校验
|
||||
|
||||
@Test
|
||||
@DisplayName("运单不存在:拒绝保存")
|
||||
void rejectMissingWaybill() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(null);
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("打卡时间格式非法:报错而不是静默改成当前时间")
|
||||
void rejectInvalidPunchTime() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPunchTime("2026/10/06 09:30");
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("经纬度非法:报错且不写库")
|
||||
void rejectInvalidCoordinate() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setLongitude("东经120度");
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo(WAYBILL_NO);
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private WaybillNodePunch nodePunch(Long id, String nodeCode, String nodeName) {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setId(id);
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode(nodeCode);
|
||||
punch.setNodeName(nodeName);
|
||||
return punch;
|
||||
}
|
||||
|
||||
private WaybillPunchRecordSaveRequest request(String type, String nodeCode, String nodeName) {
|
||||
WaybillPunchRecordSaveRequest request = new WaybillPunchRecordSaveRequest();
|
||||
request.setWaybillId(WAYBILL_ID);
|
||||
request.setType(type);
|
||||
request.setNodeCode(nodeCode);
|
||||
request.setNodeName(nodeName);
|
||||
return request;
|
||||
}
|
||||
|
||||
private WaybillPunchRecordSaveRequest.Photo photo(String type, String url) {
|
||||
WaybillPunchRecordSaveRequest.Photo photo = new WaybillPunchRecordSaveRequest.Photo();
|
||||
photo.setType(type);
|
||||
photo.setUrl(url);
|
||||
return photo;
|
||||
}
|
||||
|
||||
private List<WaybillPunchRecordSaveRequest.Photo> sameTypePhotos(String type, int count) {
|
||||
List<WaybillPunchRecordSaveRequest.Photo> photos = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
photos.add(photo(type, "https://oss/" + type + i + ".jpg"));
|
||||
}
|
||||
return photos;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 审核列表自定义 SQL 的语句级验证。
|
||||
* <p>
|
||||
* 这条 SQL 有两个容易写坏、写坏了又不会报错的地方:
|
||||
* <ul>
|
||||
* <li><b>车型侧要按提交司机定位司机档案</b>。车辆表没有司机列,
|
||||
* 定位靠 {@code dr.user_id = r.submit_user}。少了这个条件,
|
||||
* 车辆记录的司机列会全部取不到值,而页面只是「空着」——不像故障。</li>
|
||||
* <li><b>司机侧不能跟着走 submit_user</b>。存量订正记录没有提交人,
|
||||
* 若司机侧也用 submit_user 定位,23 条有司机的记录反而会全部变空。</li>
|
||||
* </ul>
|
||||
* 这里直接编译 Mapper XML 并检查生成出来的语句,把「靠约定」变成「靠语句」。
|
||||
*/
|
||||
class CertAuditPageSqlTest {
|
||||
|
||||
private static Configuration configuration;
|
||||
|
||||
@BeforeAll
|
||||
static void loadMapperXml() {
|
||||
configuration = new Configuration();
|
||||
String resource = "org/springblade/transport/mapper/TransportCertRecordMapper.xml";
|
||||
try (InputStream input = CertAuditPageSqlTest.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
assertNotNull(input, "找不到 Mapper XML:" + resource + "(它必须与 Mapper 接口同包放在 src/main/java 下)");
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Mapper XML 解析失败:" + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无搜索条件:仍按状态与对象类型过滤,并按提交时间倒序")
|
||||
void baseConditions() {
|
||||
String sql = sql(Map.of());
|
||||
|
||||
assertTrue(sql.contains("r.status = ?"), "必须按审核状态过滤");
|
||||
assertTrue(sql.contains("r.biz_type = ?"), "必须按认证对象类型过滤");
|
||||
assertTrue(sql.contains("r.is_deleted = 0"), "逻辑删除的记录不得出现在审核列表");
|
||||
assertTrue(sql.contains("ORDER BY") && sql.contains("submit_time DESC"), "必须按提交时间倒序");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机档案定位:司机侧按对象主键,车辆侧按提交司机(user_id)")
|
||||
void driverResolution() {
|
||||
String sql = sql(Map.of());
|
||||
|
||||
assertTrue(sql.contains("WHEN r.biz_type = 'driver' THEN r.biz_id"),
|
||||
"司机认证记录的司机就是认证对象本身");
|
||||
assertTrue(sql.contains("dr.user_id = r.submit_user"),
|
||||
"车辆认证记录没有司机列,只能靠提交司机定位——少了它车辆页的司机列会整列取不到值");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机档案收敛为一条:子查询必须带 ORDER BY ... LIMIT 1")
|
||||
void driverResolutionCollapsesToOneRow() {
|
||||
String sql = sql(Map.of());
|
||||
|
||||
assertTrue(sql.contains("ORDER BY dr.update_time DESC") && sql.contains("LIMIT 1"),
|
||||
"user_id 上没有唯一索引,两条司机档案指向同一用户时会让一条记录在列表里出现多行、总数虚高");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空搜索条件不产生任何 LIKE(不能把「没填」当成筛选条件)")
|
||||
void blankConditionsAddNoFilter() {
|
||||
String sql = sql(Map.of());
|
||||
|
||||
assertFalse(sql.contains("LIKE"), "没传搜索项时不应出现 LIKE:空串会被拼成 LIKE '%%'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("姓名搜索:同时匹配档案当前姓名与提交时的姓名")
|
||||
void nameSearchCoversBothCurrentAndHistorical() {
|
||||
String sql = sql(Map.of("driverName", "黄"));
|
||||
|
||||
assertTrue(sql.contains("d.driver_name LIKE"), "必须能按档案当前姓名搜到");
|
||||
assertTrue(sql.contains("r.biz_key LIKE"), "改名后按旧名也要能搜到,否则审核员得两头试");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("手机号搜索:只按司机档案的手机号匹配(快照里从来没有手机号)")
|
||||
void mobileSearchUsesProfileOnly() {
|
||||
String sql = sql(Map.of("driverMobile", "1208"));
|
||||
|
||||
assertTrue(sql.contains("d.mobile LIKE"), "手机号只有司机档案一个来源");
|
||||
}
|
||||
|
||||
private String sql(Map<String, String> params) {
|
||||
MappedStatement statement = configuration.getMappedStatement(
|
||||
"org.springblade.transport.mapper.TransportCertRecordMapper.selectAuditPage");
|
||||
Map<String, Object> parameters = new HashMap<>(params);
|
||||
parameters.put("bizType", "driver");
|
||||
parameters.put("status", 0);
|
||||
BoundSql boundSql = statement.getBoundSql(parameters);
|
||||
return boundSql.getSql();
|
||||
}
|
||||
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.core.secure.auth.AuthFun;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 认证审核权限表达式的可求值性测试。
|
||||
* <p>
|
||||
* 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值,
|
||||
* root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露,
|
||||
* 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。
|
||||
* <p>
|
||||
* 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值,
|
||||
* 与框架 {@code AuthAspect} 的做法一致。
|
||||
*/
|
||||
class CertAuditPermissionsTest {
|
||||
|
||||
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:拥有司机审核菜单即可通过")
|
||||
void auditViewWithDriverMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun),
|
||||
"只授予司机审核菜单的用户也应能查看待审列表");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:拥有车辆审核菜单即可通过")
|
||||
void auditViewWithVehicleMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:两个菜单都没有则拒绝")
|
||||
void auditViewWithoutAnyMenu() {
|
||||
assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)),
|
||||
"无任何审核菜单的用户不得查看待审列表");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝")
|
||||
void auditActionRequiresButtonPermission() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
// 只给了列表菜单,没给「审核」按钮
|
||||
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||
|
||||
assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun),
|
||||
"能看待审列表不等于能执行审核");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核动作:任一审核按钮权限即可通过")
|
||||
void auditActionWithEitherButton() {
|
||||
AuthFun driverAuditor = mock(AuthFun.class);
|
||||
when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true);
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor));
|
||||
|
||||
AuthFun vehicleAuditor = mock(AuthFun.class);
|
||||
when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true);
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)")
|
||||
void expressionsReferenceRealMenuCodes() {
|
||||
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit"));
|
||||
// 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位
|
||||
assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole"));
|
||||
assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用与框架相同的方式求值:AuthFun 作为 root object
|
||||
*/
|
||||
private boolean evaluate(String expression, AuthFun authFun) {
|
||||
Expression parsed = parser.parseExpression(expression);
|
||||
StandardEvaluationContext context = new StandardEvaluationContext(authFun);
|
||||
return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class));
|
||||
}
|
||||
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 认证可用性门槛测试。
|
||||
* <p>
|
||||
* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照,
|
||||
* 而重新提交会把「最新提交状态」变成「审核中」。
|
||||
*/
|
||||
class CertificationGateTest {
|
||||
|
||||
private CertificationGate gate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
gate = new CertificationGate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 开关
|
||||
|
||||
@Test
|
||||
@DisplayName("门槛默认开启")
|
||||
void enabledByDefault() {
|
||||
assertTrue(gate.isVehicleGateEnabled());
|
||||
assertTrue(gate.isDriverGateEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关闭后不因未认证而拦截(便于快速回退)")
|
||||
void disabledLetsEveryoneThrough() {
|
||||
gate.setVehicleGateEnabled(false);
|
||||
gate.setDriverGateEnabled(false);
|
||||
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null));
|
||||
assertTrue(gate.isDriverAvailable(null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 车辆
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)")
|
||||
void vehicleWithEffectiveSnapshotIsAvailable() {
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用")
|
||||
void resubmittedVehicleStaysAvailable() {
|
||||
// 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L),
|
||||
"已认证的车重新提交后必须仍可接单,否则司机一提交就停工");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:从未认证(无生效快照)被拒绝")
|
||||
void vehicleWithoutEffectiveSnapshotIsRejected() {
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用")
|
||||
void disabledVehicleIsRejectedEvenIfCertified() {
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L),
|
||||
"启用/停用是运营状态,认证门槛只叠加不覆盖");
|
||||
assertFalse(gate.isVehicleAvailable(null, 7001L));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 司机
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:有生效快照即可用")
|
||||
void driverWithEffectiveSnapshotIsAvailable() {
|
||||
assertTrue(gate.isDriverAvailable(9001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:从未认证被拒绝")
|
||||
void driverWithoutEffectiveSnapshotIsRejected() {
|
||||
assertFalse(gate.isDriverAvailable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机门槛可单独关闭")
|
||||
void driverGateCanBeDisabledIndependently() {
|
||||
gate.setDriverGateEnabled(false);
|
||||
assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截");
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 提示文案
|
||||
|
||||
@Test
|
||||
@DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证")
|
||||
void unavailableReasonsDistinguishDisabledFromUncertified() {
|
||||
String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L);
|
||||
assertTrue(disabled.contains("停用"), disabled);
|
||||
assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过");
|
||||
|
||||
String uncertified = gate.vehicleUnavailableReason(Boolean.TRUE, null);
|
||||
assertTrue(uncertified.contains("认证"), uncertified);
|
||||
assertFalse(uncertified.contains("停用"), uncertified);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名")
|
||||
void availableHasNoReasonAndTextIsReadable() {
|
||||
assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L));
|
||||
assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id"));
|
||||
assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id"));
|
||||
assertTrue(gate.driverUnavailableReason().contains("认证"));
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 认证记录状态机规则测试。
|
||||
* <p>
|
||||
* 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」
|
||||
* 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。
|
||||
*/
|
||||
class CertificationRuleSupportTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:前一条待审必须转已作废,而不是被删除")
|
||||
void supersedePending() {
|
||||
TransportCertRecord previousPending = record(TransportCertRecord.STATUS_PENDING);
|
||||
|
||||
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(previousPending);
|
||||
|
||||
// 返回的正是那条旧记录,且只改状态
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, superseded.getStatus());
|
||||
assertEquals(previousPending.getId(), superseded.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:前一条不是待审时,不得被改动(已通过/已驳回都是终态)")
|
||||
void doesNotTouchNonPending() {
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(null));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_APPROVED)));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_REJECTED)));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_SUPERSEDED)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:新记录一律是待审,且清空驳回原因(避免继承上一条的驳回文案)")
|
||||
void newRecordIsPending() {
|
||||
TransportCertRecord incoming = record(null);
|
||||
incoming.setStatus(TransportCertRecord.STATUS_REJECTED);
|
||||
incoming.setRejectReason("上一次的驳回原因");
|
||||
|
||||
CertificationRuleSupport.prepareForSubmit(incoming);
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, incoming.getStatus());
|
||||
assertNull(incoming.getRejectReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效快照判定:只看指针,与记录表里任何一条的状态都无关")
|
||||
void effectiveIsDecidedByPointerOnly() {
|
||||
assertFalse(CertificationRuleSupport.isCertified(null));
|
||||
assertTrue(CertificationRuleSupport.isCertified(123456789L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("可审核判定:只有待审记录可被审核,终态一律拒绝(防止把生效快照改坏)")
|
||||
void onlyPendingIsAuditable() {
|
||||
assertTrue(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_PENDING)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_APPROVED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_REJECTED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_SUPERSEDED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核入参:驳回必须给原因,通过时原因无意义需清空")
|
||||
void auditPayload() {
|
||||
// 驳回无原因 -> 拒绝
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, null));
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, " "));
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "证件照片模糊"));
|
||||
|
||||
// 通过时不需要原因
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(true, null));
|
||||
|
||||
// 通过必须把原因清空,否则会残留上一次驳回文案
|
||||
assertNull(CertificationRuleSupport.reasonForApprove());
|
||||
assertEquals("证件照片模糊", CertificationRuleSupport.reasonForReject(" 证件照片模糊 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回原因长度:超过 200 字拒绝(与列宽一致)")
|
||||
void rejectReasonLength() {
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "短".repeat(200)));
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, "长".repeat(201)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态文案:四种状态各一个中文说法,未知状态不静默给空")
|
||||
void statusText() {
|
||||
assertEquals("审核中", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_PENDING));
|
||||
assertEquals("已通过", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_APPROVED));
|
||||
assertEquals("已驳回", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_REJECTED));
|
||||
assertEquals("已作废", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_SUPERSEDED));
|
||||
assertEquals("未知状态", CertificationRuleSupport.statusText(99));
|
||||
assertEquals("未知状态", CertificationRuleSupport.statusText(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("认证对象类型:只认 driver / vehicle,其它一律拒绝")
|
||||
void bizTypeValidation() {
|
||||
assertTrue(CertificationRuleSupport.isValidBizType("driver"));
|
||||
assertTrue(CertificationRuleSupport.isValidBizType("vehicle"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType("DRIVER"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType("truck"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType(null));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType(""));
|
||||
}
|
||||
|
||||
private TransportCertRecord record(Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(1000L);
|
||||
record.setStatus(status);
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 生效快照指针的 UPDATE 语句级验证。
|
||||
* <p>
|
||||
* 「驳回不得改变生效快照」这条不变式最终取决于:驳回时那次 UPDATE 会不会带上
|
||||
* {@code effective_cert_record_id} 列。审核服务传的是 null,因此只要该列被
|
||||
* 「值非 null 才写入」的 if 包住,驳回就物理上碰不到指针。
|
||||
* <p>
|
||||
* 这里直接检查 MyBatis-Plus 生成的 SET 片段,把「靠约定」变成「靠语句」——
|
||||
* 若哪天有人给该字段加了 {@code updateStrategy = ALWAYS},这个测试会立刻变红。
|
||||
*/
|
||||
class EffectivePointerUpdateSqlTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:指针列进入 UPDATE 片段,但被 null 判断包住(通过能写、驳回不碰)")
|
||||
void vehiclePointerGuardedByNullCheck() {
|
||||
assertPointerGuarded(TransportVehicle.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:同上")
|
||||
void driverPointerGuardedByNullCheck() {
|
||||
assertPointerGuarded(Driver.class);
|
||||
}
|
||||
|
||||
private void assertPointerGuarded(Class<?> clazz) {
|
||||
String updateSql = tableInfo(clazz).getAllSqlSet(true, "et");
|
||||
|
||||
assertTrue(updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId}"),
|
||||
clazz.getSimpleName() + ":通过审核必须能写指针,否则生效快照永远建立不起来");
|
||||
|
||||
// 必须是「被 <if> 包住」的形式;若变成无条件的 SET,
|
||||
// 驳回时(传 null)就会把已认证对象的指针清空,等于把车/司机无辜下线。
|
||||
assertTrue(
|
||||
updateSql.contains("<if test=\"e['effectiveCertRecordId'] != null\">")
|
||||
&& updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId},</if>"),
|
||||
clazz.getSimpleName() + ":指针列必须是「值非 null 才写」;"
|
||||
+ "无条件 SET 会让驳回清空已认证对象的指针");
|
||||
}
|
||||
|
||||
private TableInfo tableInfo(Class<?> clazz) {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
return TableInfoHelper.initTableInfo(assistant, clazz);
|
||||
}
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.core.secure.auth.AuthFun;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常处置权限表达式的可求值性测试。
|
||||
* <p>
|
||||
* 表达式写在 {@code @PreAuth} 注解里,由框架在请求时交给 SpEL 求值,
|
||||
* root object 是 {@code AuthFun}。写成裸字符串(如 {@code "exception_disposal_edit"})
|
||||
* 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
class ExceptionDisposalPermissionsTest {
|
||||
|
||||
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:拥有编辑菜单即通过")
|
||||
void editWithMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun),
|
||||
"授予编辑菜单的用户应能编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:没有编辑菜单则拒绝")
|
||||
void editWithoutMenu() {
|
||||
assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)),
|
||||
"未授予编辑菜单的用户不得编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑权限:只有查看/跟进菜单不能编辑")
|
||||
void editRequiresDedicatedMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("exception_disposal_view")).thenReturn(true);
|
||||
when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true);
|
||||
when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true);
|
||||
|
||||
assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun),
|
||||
"能跟进、能完成不等于能编辑");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("表达式必须引用真实存在的菜单码")
|
||||
void expressionReferencesRealMenuCode() {
|
||||
assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit"));
|
||||
assertFalse(ExceptionDisposalPermissions.EDIT.contains("hasAnyRole"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用与框架相同的方式求值:AuthFun 作为 root object
|
||||
*/
|
||||
private boolean evaluate(String expression, AuthFun authFun) {
|
||||
Expression parsed = parser.parseExpression(expression);
|
||||
StandardEvaluationContext context = new StandardEvaluationContext(authFun);
|
||||
return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class));
|
||||
}
|
||||
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
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.assertTrue;
|
||||
|
||||
/**
|
||||
* 百度 OCR 结果归一化测试。
|
||||
* <p>
|
||||
* 这一层挡的是「识别成功但什么都没填」——百度返回的是中文键,写死键名的代码在键名
|
||||
* 对不上的那天会静默失效:不抛异常、不报错,用户只看到识别完表单还是空的。
|
||||
* 故这里用真实形状的返回把别名容错与值归一化都钉住。
|
||||
*/
|
||||
class OcrFieldSupportTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
@DisplayName("行驶证主页:官方字段名逐个归一化,外廓尺寸拆成三个")
|
||||
void vehicleLicenseFront() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result_num": 12,
|
||||
"words_result": {
|
||||
"号牌号码": {"words": "桂A32523"},
|
||||
"车辆类型": {"words": "重型厢式货车"},
|
||||
"所有人": {"words": "桂物物流集团"},
|
||||
"车辆识别代码": {"words": "LGAX2A130J1001234"},
|
||||
"档案编号": {"words": "450100123456"},
|
||||
"核定载质量": {"words": "18000kg"},
|
||||
"准牵引总质量": {"words": "35000"},
|
||||
"外廓尺寸": {"words": "5990×2300×3100"},
|
||||
"强制报废期止": {"words": "2030-05-20"},
|
||||
"能源种类": {"words": "柴油"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("桂A32523", fields.get("plateNo"));
|
||||
assertEquals("重型厢式货车", fields.get("vehicleType"));
|
||||
assertEquals("桂物物流集团", fields.get("ownerName"));
|
||||
assertEquals("LGAX2A130J1001234", fields.get("vin"));
|
||||
assertEquals("450100123456", fields.get("drivingLicenseNo"));
|
||||
assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字");
|
||||
assertEquals(35000, fields.get("tractionMassKg"));
|
||||
assertEquals(5990, fields.get("outerLength"));
|
||||
assertEquals(2300, fields.get("outerWidth"));
|
||||
assertEquals(3100, fields.get("outerHeight"));
|
||||
assertEquals("2030-05-20", fields.get("compulsoryScrapDate"));
|
||||
assertEquals("柴油", fields.get("energyType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("键名写法差异:车牌号码 / 车辆识别代号 / 带单位后缀的键都要认")
|
||||
void aliasTolerance() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"车牌号码": {"words": "桂B11111"},
|
||||
"车辆识别代号": {"words": "LVSHCAMB7HF123456"},
|
||||
"核定载质量(kg)": {"words": "12000"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("桂B11111", fields.get("plateNo"));
|
||||
assertEquals("LVSHCAMB7HF123456", fields.get("vin"));
|
||||
assertEquals(12000, fields.get("approvedLoadKg"), "带后缀的键名要靠「包含」兜底命中");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("日期写法:年月日 / 点分 / 纯数字都归一化成 yyyy-MM-dd")
|
||||
void dateNormalization() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"强制报废期止": {"words": "2030年05月20日"},
|
||||
"注册日期": {"words": "2018.3.9"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("2030-05-20", fields.get("compulsoryScrapDate"));
|
||||
assertEquals("2018-03-09", fields.get("drivingLicenseStartDate"), "月日个位数要补零");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("「长期」不产出日期,而是把对应的长期有效标志置 1")
|
||||
void longTermBecomesFlag() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"强制报废期止": {"words": "长期"},
|
||||
"检验有效期止": {"words": "长期有效"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("compulsoryScrapDate"), "长期不能当成日期塞进日期框");
|
||||
assertEquals(1, fields.get("compulsoryScrapLongTerm"));
|
||||
assertEquals(1, fields.get("drivingLicenseLongTerm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("身份证与驾驶证:键名归一化到个人认证的字段名")
|
||||
void idCardAndDrivingLicense() throws Exception {
|
||||
JsonNode idCard = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"姓名": {"words": "黄宇富"},
|
||||
"公民身份号码": {"words": "452730197911172318"},
|
||||
"签发日期": {"words": "2015-03-12"},
|
||||
"失效日期": {"words": "2035-03-12"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
Map<String, Object> person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard);
|
||||
assertEquals("黄宇富", person.get("realName"));
|
||||
assertEquals("452730197911172318", person.get("idCardNo"));
|
||||
assertEquals("2015-03-12", person.get("idCardValidStart"));
|
||||
assertEquals("2035-03-12", person.get("idCardValidEnd"));
|
||||
|
||||
JsonNode driving = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"档案编号": {"words": "451200976793"},
|
||||
"准驾车型": {"words": "B2D"},
|
||||
"有效起始日期": {"words": "2016-08-01"},
|
||||
"有效期限": {"words": "2026-08-01"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
Map<String, Object> license = OcrFieldSupport.extract(BaiduOcrType.DRIVING_LICENSE, "front", driving);
|
||||
assertEquals("451200976793", license.get("driverFileNo"));
|
||||
assertEquals("B2D", license.get("driverClass"));
|
||||
assertEquals("2016-08-01", license.get("driverValidStart"));
|
||||
assertEquals("2026-08-01", license.get("driverValidEnd"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("道路运输证:证号与业户名称")
|
||||
void roadTransport() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"道路运输证号": {"words": "桂交运管桂字450100123456号"},
|
||||
"业户名称": {"words": "桂物物流集团"},
|
||||
"有效期": {"words": "2020-01-01 至 2030-01-01"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result);
|
||||
|
||||
assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo"));
|
||||
assertEquals("桂物物流集团", fields.get("ownerName"));
|
||||
assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止");
|
||||
assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("识别不到任何字段时返回空 Map,而不是一堆空串")
|
||||
void emptyResult() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{"words_result_num": 0, "words_result": {}}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertTrue(fields.isEmpty(), "空结果必须是空 Map:给空串会把用户已填内容覆盖掉");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("数值荒谬时不填:位数过多的串不可能是载质量,宁可留空也不填错")
|
||||
void absurdNumberIsSkipped() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"核定载质量": {"words": "识别串行123456789012"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("approvedLoadKg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("外廓尺寸只识别到两段时不写回:硬填会让宽高错位")
|
||||
void outerSizeNeedsThreeParts() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"外廓尺寸": {"words": "5990×2300"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("outerLength"));
|
||||
assertFalse(fields.containsKey("outerWidth"));
|
||||
assertFalse(fields.containsKey("outerHeight"));
|
||||
}
|
||||
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 小程序证件类型 → 百度 OCR 证件类型的映射测试。
|
||||
* <p>
|
||||
* 两侧用的是两套命名,且**都不太直观**:
|
||||
* 小程序把「行驶证」叫 {@code vehicle}、把「驾驶证」叫 {@code driver}
|
||||
* (按业务直觉命名),而后端 OCR 沿用百度接口的 {@code vehicle_license}
|
||||
* (行驶证)与 {@code driving_license}(驾驶证)。
|
||||
* 这两个词一一对应关系相反,映射写错会让「行驶证」被当成「驾驶证」识别,
|
||||
* 结果是司机传了行驶证却拿到一堆驾驶证字段——而且不会报错。
|
||||
* 故此处逐一锁住。
|
||||
*/
|
||||
class OcrTypeMappingTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("身份证:小程序 idcard → 后端 ID_CARD")
|
||||
void idCard() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("idcard"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驾驶证:小程序 driver → 后端 DRIVING_LICENSE(不是 vehicle_license)")
|
||||
void drivingLicense() {
|
||||
assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driver"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("行驶证:小程序 vehicle → 后端 VEHICLE_LICENSE(不是 driving_license)")
|
||||
void vehicleLicense() {
|
||||
assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("道路运输证:小程序 road_transport → 后端 ROAD_TRANSPORT_CERTIFICATE")
|
||||
void roadTransport() {
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("road_transport"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("从业资格证:后端无对应 OCR 能力,必须明确报错而不是退化成通用识别")
|
||||
void qualificationIsNotSilentlyDowngraded() {
|
||||
// 百度没有「从业资格证」专用接口。若这里悄悄退化成 GENERAL,
|
||||
// 前端会拿到一堆通用文本却以为识别成功了,故要求明确失败。
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> OcrTypeMapping.toBaiduType("qualification"));
|
||||
assertTrue(ex.getMessage().contains("从业资格证"),
|
||||
"错误信息要指明是哪个证件类型不支持,便于前端提示用户手动填写");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("后端既有命名也接受(前端或其它调用方可能直接传后端名)")
|
||||
void acceptsBackendNames() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id_card"));
|
||||
assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driving_license"));
|
||||
assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle_license"));
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("road_transport_certificate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("大小写与分隔符不敏感")
|
||||
void caseAndSeparatorInsensitive() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("IDCard"));
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType(" ID_CARD "));
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id-card"));
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("Road-Transport"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未知类型:明确报错,不静默降级为通用识别")
|
||||
void unknownTypeFails() {
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("truck"));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("general"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空值:明确报错")
|
||||
void emptyTypeFails() {
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(""));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("正反面:小程序不传 side 时返回 null,交由知晓证件类型的一侧决定默认值")
|
||||
void unsetSideStaysNull() {
|
||||
// 这里恒返回 front 会让道路运输证恒失败:它没有正副面参数,
|
||||
// 上游先填成 front,下游就变成「传了 side 但该证件不支持」——
|
||||
// 小程序不传 side 也会中招(实测三种传法全部报错)。
|
||||
assertNull(OcrTypeMapping.normalizeSide(null));
|
||||
assertNull(OcrTypeMapping.normalizeSide(""));
|
||||
assertNull(OcrTypeMapping.normalizeSide(" "));
|
||||
assertEquals("front", OcrTypeMapping.normalizeSide("front"));
|
||||
assertEquals("back", OcrTypeMapping.normalizeSide("back"));
|
||||
assertEquals("back", OcrTypeMapping.normalizeSide("BACK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("正反面:道路运输证不带正副面参数,其余证件默认正面")
|
||||
void roadTransportHasNoSide() {
|
||||
// 下游据此区分「默认取正面」与「该证件不该带这个参数」,故这条契约必须有测试守
|
||||
assertFalse(OcrTypeMapping.toBaiduType("road_transport").supportsSide(),
|
||||
"道路运输证没有正副面参数,上游不得替它补 front");
|
||||
assertTrue(OcrTypeMapping.toBaiduType("vehicle").supportsSide());
|
||||
assertTrue(OcrTypeMapping.toBaiduType("driver").supportsSide());
|
||||
assertTrue(OcrTypeMapping.toBaiduType("idcard").supportsSide());
|
||||
}
|
||||
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 车牌匹配逻辑测试。
|
||||
* <p>
|
||||
* 司机档案的 {@code driving_vehicle} 与车辆表的 {@code plate_no} 是**文本软关联**,
|
||||
* 无外键,且库里已知存在间隔符/横线/全角等写法差异(例如「沪AE23411」在车辆表中不存在,
|
||||
* 而「桂A·32523」这类带间隔符的写法要靠规范化才能匹配上)。
|
||||
* 认证提交的越权校验依赖这套匹配,匹配漏判会让司机操作不了自己的车,
|
||||
* 误判则会让司机改到别人的车,故用测试锁住行为。
|
||||
*/
|
||||
class VehiclePlateSupportTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单个车牌:原样返回规范化结果")
|
||||
void singlePlate() {
|
||||
assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多个车牌:中英文逗号、顿号、分号、斜杠、竖线都算分隔符")
|
||||
void multipleSeparators() {
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111", "桂C22222"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523,桂B11111、桂C22222"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523;桂B11111"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523/桂B11111"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523|桂B11111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("去重:同一车牌写两次只保留一条")
|
||||
void dedupe() {
|
||||
assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523,桂A32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("规范化:去空格/横线/间隔符并转大写")
|
||||
void normalize() {
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize(" 桂a32523 "));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A-32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A·32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A•32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A.32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A 32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空值与脏数据:不抛异常,返回空串或空列表")
|
||||
void emptyAndDirty() {
|
||||
assertEquals("", VehiclePlateSupport.normalize(null));
|
||||
assertEquals("", VehiclePlateSupport.normalize(" "));
|
||||
assertTrue(VehiclePlateSupport.splitPlates(null).isEmpty());
|
||||
assertTrue(VehiclePlateSupport.splitPlates("").isEmpty());
|
||||
assertTrue(VehiclePlateSupport.splitPlates(",,、;;").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("绑定判定:写法差异不影响匹配(越权校验的关键)")
|
||||
void isBound() {
|
||||
// 司机档案里带间隔符,车辆表里是干净写法
|
||||
assertTrue(VehiclePlateSupport.isBound("桂A·32523", "桂A32523"));
|
||||
// 大小写差异
|
||||
assertTrue(VehiclePlateSupport.isBound("桂a32523", "桂A32523"));
|
||||
// 多车:命中其中一辆即可
|
||||
assertTrue(VehiclePlateSupport.isBound("桂A32523,桂B11111", "桂B11111"));
|
||||
// 未绑定
|
||||
assertFalse(VehiclePlateSupport.isBound("桂A32523", "桂B11111"));
|
||||
// 空值一律不绑定(不能因为双方都为空就放行——那等于越权)
|
||||
assertFalse(VehiclePlateSupport.isBound(null, "桂A32523"));
|
||||
assertFalse(VehiclePlateSupport.isBound("桂A32523", null));
|
||||
assertFalse(VehiclePlateSupport.isBound(null, null));
|
||||
assertFalse(VehiclePlateSupport.isBound("", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SQL 规范化表达式必须覆盖 Java 侧剔除的全部字符(否则两边会悄悄分叉)")
|
||||
void sqlNormalizationMirrorsJava() {
|
||||
String sql = VehiclePlateSupport.NORMALIZED_PLATE_SQL;
|
||||
// Java 的 normalize 会剔除这些排版字符并转大写,SQL 必须一一对应
|
||||
for (String noise : List.of("·", "•", ".", ".", "-", " ")) {
|
||||
assertTrue(sql.contains("'" + noise + "',''"),
|
||||
"SQL 规范化漏掉了字符 [" + noise + "],会导致该写法差异的车牌匹配不到");
|
||||
}
|
||||
assertTrue(sql.contains("UPPER(plate_no)"), "SQL 必须先转大写再比对");
|
||||
assertTrue(sql.contains("plate_no"), "表达式作用的列应为 plate_no");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车牌匹配条件是「精确 IN 或 规范化相等」,不能只有精确 IN")
|
||||
void plateMatchIncludesNormalizedFallback() {
|
||||
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(wrapper, List.of("桂A32523"));
|
||||
|
||||
String sql = wrapper.getTargetSql();
|
||||
assertTrue(sql.contains("plate_no IN"), "必须保留精确匹配,避免全表函数扫描");
|
||||
assertTrue(sql.contains("REPLACE"), "必须带规范化兜底,否则脏车牌匹配不到");
|
||||
assertTrue(sql.contains("OR"), "两者应是 OR 关系");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("绑定数上限:5 辆放行,第 6 辆拒绝")
|
||||
void boundVehicleCountLimit() {
|
||||
String five = "桂A32523,桂B11111,桂C22222,桂D33333,桂E44444";
|
||||
VehiclePlateSupport.validateBoundVehicles(five);
|
||||
assertEquals(5, VehiclePlateSupport.splitPlates(five).size());
|
||||
|
||||
// 不抛异常就是通过;第 6 辆必须拦下,否则字段放不下会被静默截断
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> VehiclePlateSupport.validateBoundVehicles(five + ",桂F55555"));
|
||||
assertTrue(exception.getMessage().contains("最多绑定5辆车"), exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("绑定校验:空值与单辆车都放行")
|
||||
void boundVehicleEmptyAndSingle() {
|
||||
VehiclePlateSupport.validateBoundVehicles(null);
|
||||
VehiclePlateSupport.validateBoundVehicles("");
|
||||
VehiclePlateSupport.validateBoundVehicles("桂A32523");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("绑定校验:车牌格式不合法时拒绝,且报出是哪个车牌")
|
||||
void boundVehicleRejectsBadPlate() {
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> VehiclePlateSupport.validateBoundVehicles("桂A32523,乱写的车牌"));
|
||||
assertTrue(exception.getMessage().contains("车牌号格式不正确"), exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车牌格式:普通、新能源(8位)、挂车/学车/警车都合法")
|
||||
void validPlateFormats() {
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A32523"));
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("沪AE31312"));
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234挂"));
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("京A1234学"));
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234警"));
|
||||
// 写法差异不影响判定:小写与带间隔符的车牌先规范化再比对
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("桂a32523"));
|
||||
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A·32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车牌格式:空值、乱码、位数不对一律不合法")
|
||||
void invalidPlateFormats() {
|
||||
assertFalse(VehiclePlateSupport.isValidPlateNo(null));
|
||||
assertFalse(VehiclePlateSupport.isValidPlateNo(""));
|
||||
assertFalse(VehiclePlateSupport.isValidPlateNo("乱写的车牌"));
|
||||
assertFalse(VehiclePlateSupport.isValidPlateNo("A32523"));
|
||||
assertFalse(VehiclePlateSupport.isValidPlateNo("桂A325"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)")
|
||||
void emptyPlateListAddsNoCondition() {
|
||||
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(wrapper, List.of());
|
||||
assertEquals("", wrapper.getTargetSql().trim());
|
||||
VehiclePlateSupport.applyPlateMatch(wrapper, null);
|
||||
assertEquals("", wrapper.getTargetSql().trim());
|
||||
}
|
||||
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
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.assertTrue;
|
||||
|
||||
/**
|
||||
* 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。
|
||||
* <p>
|
||||
* 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验,
|
||||
* 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与
|
||||
* {@link WaybillProcessSupport#unfinishedPrerequisiteKeys},
|
||||
* 所以断言下在它们身上就覆盖了两条链路。
|
||||
* <p>
|
||||
* 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。
|
||||
*/
|
||||
class WaybillProcessSupportTest {
|
||||
|
||||
/** 与线上「石灰石包头运输项目」实际下发的过程配置同序:在途排在发货之后、到货之前 */
|
||||
private static final String TRANSPORT_JSON = """
|
||||
[
|
||||
{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"arrive_scene","name":"到场","type":"punch","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
|
||||
{"key":"arrival","name":"到货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"unload","name":"卸货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}
|
||||
]
|
||||
""";
|
||||
|
||||
private static Set<String> keys(String... values) {
|
||||
return new LinkedHashSet<>(Arrays.asList(values));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 异常门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("无未了结异常时谁都不阻断")
|
||||
void withoutUnresolvedExceptionNothingIsBlocked() {
|
||||
assertTrue(WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Collections.emptyList()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("普通节点:异常所在节点及其后节点阻断,之前的不受影响")
|
||||
void unresolvedExceptionBlocksNodeAndItsSuccessors() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("arrival"));
|
||||
|
||||
assertTrue(blocked.containsAll(keys("arrival", "unload", "sign")), "异常所在节点及其后节点都要锁");
|
||||
assertFalse(blocked.contains("arrive_scene"), "异常之前的节点不该被锁");
|
||||
assertFalse(blocked.contains("load"));
|
||||
assertFalse(blocked.contains("dispatch"));
|
||||
// 在途是运单级的例外:异常落在它之后也锁,见 transitIsBlockedByExceptionOnLaterNode
|
||||
assertTrue(blocked.contains("transit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途:异常落在它之后的「卸货」时同样阻断——门禁是运单级,不看异常在哪个节点")
|
||||
void transitIsBlockedByExceptionOnLaterNode() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload"));
|
||||
|
||||
assertTrue(blocked.contains("transit"),
|
||||
"运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形");
|
||||
assertEquals(keys("transit", "unload", "sign"), blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途:异常落在它之前的「装货」时也阻断")
|
||||
void transitIsBlockedByExceptionOnEarlierNode() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load"));
|
||||
|
||||
assertTrue(blocked.contains("transit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未指明节点的异常(全局入口上报)阻断全部打卡节点,含在途")
|
||||
void exceptionWithoutNodeBlocksEverything() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList(""));
|
||||
|
||||
assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断")
|
||||
void earliestExceptionDecidesTheRange() {
|
||||
Set<String> blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload", "arrive_scene"));
|
||||
|
||||
assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("配置里没有在途节点时不凭空造一个 key")
|
||||
void withoutTransitNodeNothingIsInvented() {
|
||||
String noTransit = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"unload","name":"卸货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
assertEquals(keys("load", "unload"), WaybillProcessSupport.blockedNodeKeys(noTransit, List.of("load")));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 顺序门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("顺序门禁:前面还有打卡节点没打时返回它们,在途不作为前置")
|
||||
void unfinishedPrerequisitesIgnoreTransit() {
|
||||
Set<String> punched = keys("arrive_scene", "load", "dispatch");
|
||||
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload");
|
||||
|
||||
assertEquals(keys("arrival"), unfinished,
|
||||
"「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集")
|
||||
void transitIsExemptFromOrderGate() {
|
||||
assertTrue(
|
||||
WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(),
|
||||
"在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 异常标签与相关异常
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点")
|
||||
void relatesToNodeCoversWaybillLevelForEveryNode() {
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", null),
|
||||
"整单异常不归属任何节点,对每个节点都算相关");
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", ""));
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", "load"));
|
||||
assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"),
|
||||
"装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码忽略大小写,两侧空白不影响判定")
|
||||
void relatesToNodeIgnoresCaseAndBlank() {
|
||||
assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD"));
|
||||
assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD "));
|
||||
assertFalse(WaybillProcessSupport.relatesToNode("load", "unload"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整单异常的判定只看节点编码是否为空")
|
||||
void waybillLevelExceptionFlag() {
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(null));
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(""));
|
||||
assertTrue(WaybillProcessSupport.isWaybillLevelException(" "));
|
||||
assertFalse(WaybillProcessSupport.isWaybillLevelException("load"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等")
|
||||
void sameNodeCodeIgnoresCaseAndBlank() {
|
||||
assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD"));
|
||||
assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load"));
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload"));
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点");
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等");
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode(null, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 到货/卸货/签收
|
||||
|
||||
@Test
|
||||
@DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算")
|
||||
void deliveryNodeMatchesKeyOrName() {
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件");
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码");
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("", ""));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode(null, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 末位司机打卡节点
|
||||
|
||||
/** 末位是「到货」,其后没有打卡节点 */
|
||||
private static final String ARRIVAL_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"arrival","name":"到货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「卸货」 */
|
||||
private static final String UNLOAD_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"unload","name":"卸货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「在途」 */
|
||||
private static final String TRANSIT_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「发货」 */
|
||||
private static final String DISPATCH_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"dispatch","name":"发货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
@Test
|
||||
@DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算")
|
||||
void lastDriverPunchNodeSkipsAcceptAndReturn() {
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"),
|
||||
"回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收");
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者")
|
||||
void lastNodeIsDeliveryOnlyForDeliveryTypes() {
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign")
|
||||
&& WaybillProcessSupport.isDeliveryNode("sign", "签收"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival")
|
||||
&& WaybillProcessSupport.isDeliveryNode("arrival", "到货"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload")
|
||||
&& WaybillProcessSupport.isDeliveryNode("unload", "卸货"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"),
|
||||
"在途排在末位时它确实是末位打卡节点");
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点");
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点");
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"),
|
||||
"该配置里没有卸货节点,不能因为编码眼熟就认下");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位")
|
||||
void lastDriverPunchNodeWithoutPunchNodes() {
|
||||
String onlyAcceptAndReturn = """
|
||||
[{"key":"accept","name":"接单","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, ""));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 回单是否无需确认完成
|
||||
|
||||
/** 回单启用且配置为「无需确认完成」 */
|
||||
private static final String RETURN_NO_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
|
||||
""";
|
||||
|
||||
/** 回单启用且配置为「需确认完成」 */
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 回单未启用,但 confirmMode 写着「需确认完成」 */
|
||||
private static final String RETURN_DISABLED_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 配置里根本没有回单节点 */
|
||||
private static final String NO_RETURN_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立")
|
||||
void enabledReturnNeedsConfirmMode() {
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON));
|
||||
assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON),
|
||||
"回单需要确认完成时,打完签收不能算整单完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成")
|
||||
void absentOrDisabledReturnNeedsNoConfirmation() {
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON),
|
||||
"回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认");
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON));
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(""));
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算")
|
||||
void enabledReturnWithoutConfirmModeNeedsConfirmation() {
|
||||
assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON),
|
||||
"TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user