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:
@@ -3,6 +3,7 @@ package org.springblade.file.config;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.obs.services.ObsClient;
|
||||
import io.minio.MinioClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.file.service.IFileService;
|
||||
@@ -10,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl;
|
||||
import org.springblade.file.service.impl.MinioFileServiceImpl;
|
||||
import org.springblade.file.service.impl.OssFileServiceImpl;
|
||||
import org.springblade.file.service.impl.ObsFileServiceImpl;
|
||||
import org.springblade.file.util.MinioClientFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -20,6 +22,7 @@ import org.springframework.core.env.Environment;
|
||||
* @author bfhuange
|
||||
* @date 2024/10/21
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class FileConfig {
|
||||
|
||||
@@ -63,10 +66,22 @@ public class FileConfig {
|
||||
|
||||
private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) {
|
||||
validateStoreProperties(STORAGE_MINIO, properties);
|
||||
MinioClient minioClient = MinioClient.builder()
|
||||
.endpoint(properties.getEndpoint())
|
||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||
.build();
|
||||
String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint());
|
||||
String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint());
|
||||
if (StringUtil.isNotBlank(endpointPathPrefix)) {
|
||||
log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS",
|
||||
endpointOrigin, endpointPathPrefix);
|
||||
}
|
||||
if (StringUtil.isBlank(properties.getTransformEndpoint())
|
||||
&& properties.getEndpoint().trim().toLowerCase().startsWith("http://")) {
|
||||
log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传",
|
||||
properties.getEndpoint());
|
||||
} else {
|
||||
log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}",
|
||||
endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint());
|
||||
}
|
||||
MinioClient minioClient = MinioClientFactory.create(
|
||||
properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret());
|
||||
return new MinioFileServiceImpl(properties, minioClient);
|
||||
}
|
||||
|
||||
@@ -95,7 +110,16 @@ public class FileConfig {
|
||||
FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties();
|
||||
String fileStoragePrefix = "file.storage." + prefix;
|
||||
mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint")));
|
||||
mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint")));
|
||||
// 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint
|
||||
mergedProperties.setTransformEndpoint(firstNotBlank(
|
||||
properties.getTransformEndpoint(),
|
||||
environment.getProperty(fileStoragePrefix + ".transform-endpoint"),
|
||||
environment.getProperty(fileStoragePrefix + ".transformEndpoint"),
|
||||
environment.getProperty(prefix + ".transform-endpoint"),
|
||||
environment.getProperty(prefix + ".transformEndpoint"),
|
||||
environment.getProperty("oss.transform-endpoint"),
|
||||
environment.getProperty("oss.transformEndpoint")
|
||||
));
|
||||
mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key")));
|
||||
mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key")));
|
||||
mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name")));
|
||||
@@ -107,7 +131,7 @@ public class FileConfig {
|
||||
private String firstNotBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (StringUtil.isNotBlank(value)) {
|
||||
return value;
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
+22
@@ -111,4 +111,26 @@ public class FileTaskController extends BladeController {
|
||||
String uploadUrl = fileTaskService.getUploadUrl(id, partNumber);
|
||||
return R.data(uploadUrl);
|
||||
}
|
||||
|
||||
@ApiLog("上传任务-服务端代传分片")
|
||||
@PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Parameters({
|
||||
@Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int"))
|
||||
})
|
||||
@Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储")
|
||||
public R<String> uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return R.fail("分片内容不能为空");
|
||||
}
|
||||
java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin");
|
||||
try {
|
||||
file.transferTo(tempFile);
|
||||
return R.data(fileTaskService.uploadPart(id, partNumber, tempFile));
|
||||
} finally {
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
tempFile.deleteOnExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -74,6 +74,16 @@ public interface IFileTaskService extends IService<FileTask> {
|
||||
*/
|
||||
String getUploadUrl(Long id, Integer partNumber);
|
||||
|
||||
/**
|
||||
* 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用)
|
||||
*
|
||||
* @param id 文件任务id
|
||||
* @param partNumber 分段编号
|
||||
* @param partFile 分片临时文件
|
||||
* @return ETag
|
||||
*/
|
||||
String uploadPart(Long id, Integer partNumber, java.io.File partFile);
|
||||
|
||||
/**
|
||||
* 取消文件任务
|
||||
* @param taskIds
|
||||
|
||||
+27
@@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl<FileTaskMapper, FileTask> i
|
||||
return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String uploadPart(Long id, Integer partNumber, java.io.File partFile) {
|
||||
FileTask fileTask = this.getById(id);
|
||||
Func.requireNotNull(fileTask, "文件任务不存在");
|
||||
if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) {
|
||||
throw new ServiceException("文件任务已完成");
|
||||
}
|
||||
if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) {
|
||||
throw new ServiceException("文件任务已暂停");
|
||||
}
|
||||
if (partFile == null || !partFile.exists()) {
|
||||
throw new ServiceException("分片文件不存在");
|
||||
}
|
||||
org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart(
|
||||
fileTask.getObjectKey(),
|
||||
fileTask.getUploadId(),
|
||||
partFile,
|
||||
partNumber,
|
||||
0L,
|
||||
partFile.length()
|
||||
);
|
||||
if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) {
|
||||
throw new ServiceException("分片上传未返回 ETag");
|
||||
}
|
||||
return partEtag.getEtag().replace("\"", "");
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public void cancelBatchByIds(List<Long> taskIds) {
|
||||
|
||||
+106
-6
@@ -25,11 +25,12 @@
|
||||
*/
|
||||
package org.springblade.file.service.impl;
|
||||
|
||||
import io.minio.ComposeObjectArgs;
|
||||
import io.minio.ComposeSource;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetPresignedObjectUrlArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.ComposeObjectArgs;
|
||||
import io.minio.ComposeSource;
|
||||
import io.minio.ObjectWriteResponse;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.UploadObjectArgs;
|
||||
@@ -39,9 +40,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.file.config.FileStorageProperties;
|
||||
import org.springblade.file.util.MinioClientFactory;
|
||||
import org.springblade.file.pojo.vo.PartEtagVO;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
@@ -61,9 +66,11 @@ import java.util.UUID;
|
||||
public class MinioFileServiceImpl extends AbstractFileService {
|
||||
|
||||
private static final long PART_SIZE = 10 * 1024 * 1024L;
|
||||
private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION;
|
||||
|
||||
private final FileStorageProperties.StoreProperties properties;
|
||||
private final MinioClient minioClient;
|
||||
private volatile MinioClient transformPresignClient;
|
||||
|
||||
@Override
|
||||
public String initiateMultipartUpload(String objectKey) {
|
||||
@@ -73,15 +80,50 @@ public class MinioFileServiceImpl extends AbstractFileService {
|
||||
@Override
|
||||
public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) {
|
||||
try {
|
||||
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber))
|
||||
.expiry(Math.toIntExact(resolveExpires(expires))).build());
|
||||
String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.PUT)
|
||||
.bucket(properties.getBucketName())
|
||||
.object(partObjectKey(objectKey, uploadId, partNumber))
|
||||
.region(DEFAULT_REGION)
|
||||
.expiry(Math.toIntExact(resolveExpires(expires)))
|
||||
.build());
|
||||
return applyTransformPathPrefix(presignedUrl);
|
||||
} catch (Exception exception) {
|
||||
log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception);
|
||||
throw new ServiceException("获取分片上传链接异常");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) {
|
||||
long start = offset == null ? 0L : offset;
|
||||
long size = partSize == null ? Math.max(0L, file.length() - start) : partSize;
|
||||
try (FileInputStream inputStream = new FileInputStream(file)) {
|
||||
long skipped = inputStream.skip(start);
|
||||
if (skipped < start) {
|
||||
throw new ServiceException("MinIO分段上传读取文件偏移失败");
|
||||
}
|
||||
ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucketName())
|
||||
.object(partObjectKey(objectKey, uploadId, partNumber))
|
||||
.stream(inputStream, size, PART_SIZE)
|
||||
.build());
|
||||
String etag = response.etag() == null ? "" : response.etag().replace("\"", "");
|
||||
return new PartEtagVO(etag, partNumber);
|
||||
} catch (ServiceException serviceException) {
|
||||
throw serviceException;
|
||||
} catch (Exception exception) {
|
||||
log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception);
|
||||
throw new ServiceException("MinIO分段上传异常");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelMultipartUpload(String objectKey, String uploadId) {
|
||||
// MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可
|
||||
log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void completeMultipartUpload(String objectKey, String uploadId, List<org.springblade.file.pojo.vo.PartEtagVO> partEtags) {
|
||||
try {
|
||||
@@ -143,12 +185,14 @@ public class MinioFileServiceImpl extends AbstractFileService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.GET)
|
||||
.bucket(properties.getBucketName())
|
||||
.object(resolveObjectKey(objectKey))
|
||||
.region(DEFAULT_REGION)
|
||||
.expiry(Math.toIntExact(resolveExpires(expires)))
|
||||
.build());
|
||||
return applyTransformPathPrefix(presignedUrl);
|
||||
} catch (Exception exception) {
|
||||
log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception);
|
||||
throw new ServiceException("MinIO获取文件链接异常");
|
||||
@@ -170,6 +214,62 @@ public class MinioFileServiceImpl extends AbstractFileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。
|
||||
* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。
|
||||
*/
|
||||
private MinioClient resolvePresignClient() {
|
||||
String transformEndpoint = properties.getTransformEndpoint();
|
||||
if (StringUtil.isBlank(transformEndpoint)) {
|
||||
return minioClient;
|
||||
}
|
||||
MinioClient cachedClient = transformPresignClient;
|
||||
if (cachedClient != null) {
|
||||
return cachedClient;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (transformPresignClient == null) {
|
||||
transformPresignClient = MinioClientFactory.create(
|
||||
transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret());
|
||||
}
|
||||
return transformPresignClient;
|
||||
}
|
||||
}
|
||||
|
||||
private String applyTransformPathPrefix(String presignedUrl) {
|
||||
String transformEndpoint = properties.getTransformEndpoint();
|
||||
if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) {
|
||||
return presignedUrl;
|
||||
}
|
||||
String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath());
|
||||
if (StringUtil.isBlank(pathPrefix)) {
|
||||
return presignedUrl;
|
||||
}
|
||||
URI url = 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 (StringUtil.isNotBlank(url.getRawQuery())) {
|
||||
result.append('?').append(url.getRawQuery());
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private String normalizePathPrefix(String path) {
|
||||
if (StringUtil.isBlank(path) || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
|
||||
return normalized.startsWith("/") ? normalized : "/" + normalized;
|
||||
}
|
||||
|
||||
private String resolveObjectKey(String objectKey) {
|
||||
if (StringUtil.isBlank(properties.getRootDirectory())) {
|
||||
return objectKey;
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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.file.util;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* MinIO 客户端构建。
|
||||
* <p>
|
||||
* SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则:
|
||||
* <ul>
|
||||
* <li>endpoint 使用 origin</li>
|
||||
* <li>通过 OkHttp 拦截器把 /oss 拼回每个 API 请求</li>
|
||||
* <li>固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class MinioClientFactory {
|
||||
|
||||
public static final String DEFAULT_REGION = "us-east-1";
|
||||
|
||||
private MinioClientFactory() {
|
||||
}
|
||||
|
||||
public static MinioClient create(String endpoint, String accessKey, String secretKey) {
|
||||
EndpointParts parts = parseEndpoint(endpoint);
|
||||
MinioClient.Builder builder = MinioClient.builder()
|
||||
.endpoint(parts.origin())
|
||||
.credentials(accessKey, secretKey)
|
||||
.region(DEFAULT_REGION);
|
||||
if (StringUtil.isNotBlank(parts.pathPrefix())) {
|
||||
builder.httpClient(buildHttpClient(parts.pathPrefix()), true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static String resolveOrigin(String endpoint) {
|
||||
return parseEndpoint(endpoint).origin();
|
||||
}
|
||||
|
||||
public static String resolvePathPrefix(String endpoint) {
|
||||
return parseEndpoint(endpoint).pathPrefix();
|
||||
}
|
||||
|
||||
private static EndpointParts parseEndpoint(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 new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath()));
|
||||
}
|
||||
|
||||
private static String normalizePathPrefix(String path) {
|
||||
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();
|
||||
}
|
||||
|
||||
private record EndpointParts(String origin, String pathPrefix) {
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -93,7 +93,8 @@ public class DeptController extends BladeController {
|
||||
@PreAuth(menu = "dept")
|
||||
@GetMapping("/list")
|
||||
@Parameters({
|
||||
@Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
})
|
||||
@ApiOperationSupport(order = 2)
|
||||
@@ -108,7 +109,8 @@ public class DeptController extends BladeController {
|
||||
@PreAuth(menu = "dept")
|
||||
@GetMapping("/lazy-list")
|
||||
@Parameters({
|
||||
@Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
})
|
||||
@ApiOperationSupport(order = 3)
|
||||
@@ -195,8 +197,10 @@ public class DeptController extends BladeController {
|
||||
@Operation(summary = "同步OA公司")
|
||||
public R<OaOrgSyncPageVO> syncOaCompany(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size) {
|
||||
return R.data(oaSyncService.syncCompanyPage(current, size));
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(defaultValue = "false") Boolean incremental,
|
||||
@RequestParam(required = false) String modifiedSince) {
|
||||
return R.data(oaSyncService.syncCompanyPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,8 +212,10 @@ public class DeptController extends BladeController {
|
||||
@Operation(summary = "同步OA部门")
|
||||
public R<OaOrgSyncPageVO> syncOaDepartment(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size));
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(defaultValue = "false") Boolean incremental,
|
||||
@RequestParam(required = false) String modifiedSince) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.vo.MpUserVO;
|
||||
import org.springblade.system.service.IMpUserService;
|
||||
import org.springblade.system.wrapper.MpUserWrapper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小程序用户管理 控制器
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@NonDS
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@PreAuth(menu = "mp_user")
|
||||
@RequestMapping("mp-user")
|
||||
@Tag(name = "小程序用户", description = "小程序用户管理")
|
||||
public class MpUserController extends BladeController {
|
||||
|
||||
private final IMpUserService mpUserService;
|
||||
|
||||
/**
|
||||
* 详情
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "详情", description = "传入id")
|
||||
public R<MpUserVO> detail(MpUser mpUser) {
|
||||
MpUser detail = mpUserService.getOne(Condition.getQueryWrapper(mpUser));
|
||||
if (detail == null) {
|
||||
return R.fail("小程序用户不存在");
|
||||
}
|
||||
return R.data(MpUserWrapper.build().entityVO(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "分页", description = "传入查询条件")
|
||||
public R<IPage<MpUserVO>> list(MpUser mpUser, Query query) {
|
||||
IPage<MpUser> pages = mpUserService.selectMpUserPage(Condition.getPage(query), mpUser);
|
||||
return R.data(MpUserWrapper.build().pageVO(pages));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或修改
|
||||
*/
|
||||
@PostMapping("/submit")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "新增或修改", description = "传入MpUser")
|
||||
public R submit(@Valid @RequestBody MpUser mpUser) {
|
||||
return R.status(mpUserService.submitOrUpdate(mpUser));
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "逻辑删除", description = "传入ids")
|
||||
public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) {
|
||||
return R.status(mpUserService.removeMpUser(Func.toLongList(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用 / 停用
|
||||
*/
|
||||
@PostMapping("/change-status")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "修改状态", description = "status:1启用,0停用")
|
||||
public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) {
|
||||
return R.status(mpUserService.changeStatus(id, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
@PostMapping("/reset-password")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "重置密码", description = "传入userId集合,返回账号与明文密码")
|
||||
public R<Map<String, String>> resetPassword(
|
||||
@Parameter(description = "userId集合", required = true) @RequestParam String userIds) {
|
||||
return R.data(mpUserService.resetPassword(Func.toLongList(userIds)));
|
||||
}
|
||||
|
||||
}
|
||||
+66
-8
@@ -53,11 +53,14 @@ import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.StringPool;
|
||||
import org.springblade.system.excel.UserExcel;
|
||||
import org.springblade.system.excel.UserImporter;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
|
||||
import org.springblade.system.pojo.vo.UserVO;
|
||||
import org.springblade.system.service.IOASyncService;
|
||||
import org.springblade.system.service.IMpUserService;
|
||||
import org.springblade.system.service.IUserService;
|
||||
import org.springblade.system.utils.MpUserAuth;
|
||||
import org.springblade.system.wrapper.UserWrapper;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -79,6 +82,7 @@ import java.util.Map;
|
||||
public class UserController {
|
||||
|
||||
private final IUserService userService;
|
||||
private final IMpUserService mpUserService;
|
||||
private final IOASyncService oaSyncService;
|
||||
|
||||
/**
|
||||
@@ -101,6 +105,10 @@ public class UserController {
|
||||
@Operation(summary = "查看详情", description = "传入id")
|
||||
@GetMapping("/info")
|
||||
public R<UserVO> info(BladeUser user) {
|
||||
if (MpUserAuth.isMpUser(user)) {
|
||||
MpUser mpUser = mpUserService.getById(user.getUserId());
|
||||
return R.data(toMpUserVO(mpUser));
|
||||
}
|
||||
User detail = userService.getById(user.getUserId());
|
||||
return R.data(UserWrapper.build().entityVO(detail));
|
||||
}
|
||||
@@ -112,11 +120,12 @@ public class UserController {
|
||||
@GetMapping("/list")
|
||||
@BladeView(Views.Admin.class)
|
||||
@Parameters({
|
||||
@Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
@Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
})
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "列表", description = "传入account和realName")
|
||||
@Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索")
|
||||
public R<IPage<UserVO>> list(@Parameter(hidden = true) @RequestParam Map<String, Object> user, Query query) {
|
||||
return R.data(userService.selectPage(user, query));
|
||||
}
|
||||
@@ -128,11 +137,12 @@ public class UserController {
|
||||
@GetMapping("/page")
|
||||
@BladeView(Views.Admin.class)
|
||||
@Parameters({
|
||||
@Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
@Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
})
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "列表", description = "传入account和realName")
|
||||
@Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索")
|
||||
public R<IPage<UserVO>> page(@Parameter(hidden = true) User user, Query query, Long deptId, BladeUser bladeUser) {
|
||||
IPage<User> pages = userService.selectUserPage(Condition.getPage(query), user, deptId, (AuthUtil.isAdministrator() ? StringPool.EMPTY : bladeUser.getTenantId()));
|
||||
return R.data(UserWrapper.build().pageVO(pages));
|
||||
@@ -169,8 +179,10 @@ public class UserController {
|
||||
@Operation(summary = "同步OA人员")
|
||||
public R<OaPersonSyncPageVO> syncIamAccounts(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size) {
|
||||
return R.data(oaSyncService.syncPersonFromUserList(current, size));
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(defaultValue = "false") Boolean incremental,
|
||||
@RequestParam(required = false) String modifiedSince) {
|
||||
return R.data(oaSyncService.syncPersonFromUserList(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +196,18 @@ public class UserController {
|
||||
return R.status(userService.updateUser(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或停用
|
||||
*/
|
||||
@IsAdmin
|
||||
@PostMapping("/change-status")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "启用停用", description = "传入id和status")
|
||||
public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) {
|
||||
return R.status(userService.changeUserStatus(id, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
@@ -236,6 +260,7 @@ public class UserController {
|
||||
* 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密)
|
||||
* <p>
|
||||
* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。
|
||||
* 小程序用户写入 blade_mp_user,系统用户仍写入 blade_user。
|
||||
*/
|
||||
@PostMapping("/password")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@@ -243,6 +268,9 @@ public class UserController {
|
||||
public R password(BladeUser user,
|
||||
@Parameter(description = "新密码", required = true) @RequestParam String password,
|
||||
@Parameter(description = "确认密码", required = true) @RequestParam String password2) {
|
||||
if (MpUserAuth.isMpUser(user)) {
|
||||
return R.status(mpUserService.setPassword(user.getUserId(), password, password2));
|
||||
}
|
||||
return R.status(userService.setPassword(user.getUserId(), password, password2));
|
||||
}
|
||||
|
||||
@@ -422,4 +450,34 @@ public class UserController {
|
||||
return R.data(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户转 UserVO(兼容原 /user/info 响应结构)
|
||||
*/
|
||||
private UserVO toMpUserVO(MpUser mpUser) {
|
||||
if (mpUser == null) {
|
||||
return null;
|
||||
}
|
||||
UserVO userVO = new UserVO();
|
||||
userVO.setId(mpUser.getId());
|
||||
userVO.setTenantId(mpUser.getTenantId());
|
||||
userVO.setCode(mpUser.getCode());
|
||||
userVO.setAccount(mpUser.getAccount());
|
||||
userVO.setName(mpUser.getName());
|
||||
userVO.setRealName(mpUser.getRealName());
|
||||
userVO.setAvatar(mpUser.getAvatar());
|
||||
userVO.setEmail(mpUser.getEmail());
|
||||
userVO.setPhone(mpUser.getPhone());
|
||||
userVO.setBirthday(mpUser.getBirthday());
|
||||
userVO.setSex(mpUser.getSex());
|
||||
userVO.setDeptId(mpUser.getDeptId());
|
||||
userVO.setPostId(mpUser.getPostId());
|
||||
userVO.setRemark(mpUser.getRemark());
|
||||
userVO.setStatus(mpUser.getStatus());
|
||||
userVO.setCreateTime(mpUser.getCreateTime());
|
||||
userVO.setUpdateTime(mpUser.getUpdateTime());
|
||||
// 身份放入 roleName,便于前端兼容展示
|
||||
userVO.setRoleName(mpUser.getIdentities());
|
||||
return userVO;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 用户手机号变更(小程序「修改手机号」)
|
||||
* 小程序用户手机号变更(写入 blade_mp_user)
|
||||
* <p>
|
||||
* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。
|
||||
*
|
||||
|
||||
+31
-44
@@ -44,92 +44,79 @@ public interface DeptConvert {
|
||||
|
||||
@Mapping(source = "subcompanyname", target = "deptName")
|
||||
@Mapping(source = "subcompanydesc", target = "fullName")
|
||||
// 父公司id转为父部门编码,要拼接前缀
|
||||
@Mapping(source = "supsubcomid", target = "parentCode")
|
||||
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
Dept baseConvert(OACompanyResponse company);
|
||||
|
||||
@Mapping(source = "departmentname", target = "deptName")
|
||||
@Mapping(source = "departmentmark", target = "fullName")
|
||||
// 父部门id转为父部门编码,要拼接前缀
|
||||
@Mapping(source = "supdepid", target = "parentCode")
|
||||
// 所属子公司id转为所属公司编码,要拼接前缀
|
||||
@Mapping(source = "subcompanyid1", target = "belongCompanyCode")
|
||||
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
Dept baseConvert(OADepartmentResponse department);
|
||||
|
||||
/**
|
||||
* oa公司转本系统部门
|
||||
* @param company
|
||||
* @return
|
||||
* oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。
|
||||
*/
|
||||
default Dept company2dept(OACompanyResponse company) {
|
||||
Dept dept = this.baseConvert(company);
|
||||
// 部门编码添加前缀
|
||||
dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId());
|
||||
// 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级
|
||||
dept.setDeptCode(null);
|
||||
String parentCode = dept.getParentCode();
|
||||
if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) {
|
||||
// 父部门编码不是0,拼接前缀
|
||||
dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode);
|
||||
// 父部门id后续处理
|
||||
// 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码
|
||||
dept.setParentCode(parentCode);
|
||||
dept.setParentId(null);
|
||||
} else {
|
||||
// 父部门编码是0,父id也设置为0
|
||||
dept.setParentId(OAConvertConstant.ROOT_PARENT_ID);
|
||||
dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
}
|
||||
// 公司所属公司编码就是他自己的部门编码
|
||||
dept.setBelongCompanyCode(dept.getDeptCode());
|
||||
// OA主键与上级OA主键,分批同步结束后用于重建层级
|
||||
dept.setBelongCompanyCode(null);
|
||||
dept.setOaId(company.getId());
|
||||
dept.setOaSupSubComId(company.getSupsubcomid());
|
||||
// 显示顺序处理
|
||||
dept.setSort(OAUtils.parseInt(company.getShoworder()));
|
||||
// 部门类型,公司
|
||||
dept.setDeptCategory(DeptCategory.COMPANY.getCode());
|
||||
dept.setSyncTime(new Date());
|
||||
// 是oa的
|
||||
dept.setIsOa(CommonConstant.YES);
|
||||
return dept;
|
||||
}
|
||||
|
||||
/**
|
||||
* oa部门转本系统部门
|
||||
* @param oaDept
|
||||
* @param companyDeptMap 所有公司的部门编码与id map
|
||||
* @return
|
||||
* oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。
|
||||
*
|
||||
* @param oaDept OA部门
|
||||
* @param companyByOaId 已同步公司(oaId → 本地公司)
|
||||
*/
|
||||
default Dept dept2dept(OADepartmentResponse oaDept, Map<String, Long> companyDeptMap) {
|
||||
default Dept dept2dept(OADepartmentResponse oaDept, Map<String, Dept> companyByOaId) {
|
||||
Dept dept = this.baseConvert(oaDept);
|
||||
// 部门编码添加前缀
|
||||
dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId());
|
||||
// OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键
|
||||
dept.setDeptCode(null);
|
||||
dept.setOaId(oaDept.getId());
|
||||
if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) {
|
||||
dept.setOaSupSubComId(oaDept.getSubcompanyid1());
|
||||
} else {
|
||||
dept.setOaSupSubComId(oaDept.getSupdepid());
|
||||
}
|
||||
// 部门所属公司编码就是他所属公司编码加前缀
|
||||
dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode());
|
||||
String parentCode = dept.getParentCode();
|
||||
if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) {
|
||||
// 父部门编码不是0,拼接前缀
|
||||
dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode);
|
||||
// 父部门id后续处理
|
||||
Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1());
|
||||
if (belongCompany != null) {
|
||||
dept.setBelongCompanyCode(belongCompany.getDeptCode());
|
||||
dept.setParentId(null);
|
||||
if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) {
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
dept.setParentId(belongCompany.getId());
|
||||
} else {
|
||||
// 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析
|
||||
dept.setParentCode(oaDept.getSupdepid());
|
||||
}
|
||||
} else {
|
||||
// 父部门编码是0,设置为公司编码
|
||||
dept.setParentCode(dept.getBelongCompanyCode());
|
||||
// 根据父部门编码获取对应的id
|
||||
dept.setParentId(companyDeptMap.get(dept.getParentCode()));
|
||||
// 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码
|
||||
dept.setBelongCompanyCode(oaDept.getSubcompanyid1());
|
||||
dept.setParentCode(oaDept.getSupdepid());
|
||||
}
|
||||
|
||||
// 显示顺序处理
|
||||
dept.setSort(OAUtils.parseInt(oaDept.getShoworder()));
|
||||
// 部门类型,部门
|
||||
dept.setDeptCategory(DeptCategory.DEPT.getCode());
|
||||
dept.setSyncTime(new Date());
|
||||
// 是oa的
|
||||
dept.setIsOa(CommonConstant.YES);
|
||||
return dept;
|
||||
}
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ public interface UserConvert {
|
||||
@Mapping(source = "lastname", target = "realName")
|
||||
@Mapping(source = "mobile", target = "phone")
|
||||
@Mapping(source = "email", target = "email")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
User baseConvert(OAPersonResponse person);
|
||||
|
||||
@Mapping(source = "subcompanyname", target = "companyName")
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ public class RailwayStationExcel implements Serializable {
|
||||
@ExcelProperty("TMIS国标编码*")
|
||||
private String tmisCode;
|
||||
|
||||
@ExcelProperty("电报略码*")
|
||||
@ExcelProperty("电报略码")
|
||||
private String telegraphCode;
|
||||
|
||||
@ExcelProperty("车站名称*")
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ public class RailwayStationExportExcel implements Serializable {
|
||||
@ExcelProperty("TMIS国标编码*")
|
||||
private String tmisCode;
|
||||
|
||||
@ExcelProperty("电报略码*")
|
||||
@ExcelProperty("电报略码")
|
||||
private String telegraphCode;
|
||||
|
||||
@ExcelProperty("车站名称*")
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.system.feign;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.service.IMpUserService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 小程序用户 Feign 实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@NonDS
|
||||
@Hidden
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
public class MpUserClient implements IMpUserClient {
|
||||
|
||||
private final IMpUserService mpUserService;
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_BY_ID)
|
||||
public R<MpUser> userById(Long userId) {
|
||||
return R.data(mpUserService.getById(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_BY_PHONE)
|
||||
public R<MpUser> userByPhone(String tenantId, String phone) {
|
||||
return R.data(mpUserService.getByPhone(tenantId, phone));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_BY_ACCOUNT)
|
||||
public R<MpUser> userByAccount(String tenantId, String account) {
|
||||
return R.data(mpUserService.getByAccount(tenantId, account));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_INFO_BY_ID)
|
||||
public R<MpUserInfo> userInfoById(Long userId) {
|
||||
return R.data(mpUserService.userInfo(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_INFO_BY_PHONE)
|
||||
public R<MpUserInfo> userInfoByPhone(String tenantId, String phone) {
|
||||
return R.data(mpUserService.userInfoByPhone(tenantId, phone));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_INFO_BY_ACCOUNT)
|
||||
public R<MpUserInfo> userInfoByAccount(String tenantId, String account) {
|
||||
return R.data(mpUserService.userInfoByAccount(tenantId, account));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(SAVE_USER)
|
||||
public R<Boolean> saveUser(@RequestBody MpUser user) {
|
||||
return R.data(mpUserService.submit(user));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(UPDATE_USER)
|
||||
public R<Boolean> updateUser(@RequestBody MpUser user) {
|
||||
return R.data(mpUserService.updateUser(user));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(BIND_OPENID)
|
||||
public R<Boolean> bindOpenId(String tenantId, Long userId, String openid) {
|
||||
return R.data(mpUserService.bindOpenId(tenantId, userId, openid));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(SET_PASSWORD)
|
||||
public R<Boolean> setPassword(Long userId, String password, String password2) {
|
||||
return R.data(mpUserService.setPassword(userId, password, password2));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<result column="dept_category" property="deptCategory"/>
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
@@ -34,6 +35,7 @@
|
||||
<result column="dept_category" property="deptCategory"/>
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
@@ -78,6 +80,9 @@
|
||||
<if test="param3.deptName!=null and param3.deptName!=''">
|
||||
and dept.dept_name like concat(concat('%', #{param3.deptName}),'%')
|
||||
</if>
|
||||
<if test="param3.shortName!=null and param3.shortName!=''">
|
||||
and dept.short_name like concat(concat('%', #{param3.shortName}),'%')
|
||||
</if>
|
||||
<if test="param3.fullName!=null and param3.fullName!=''">
|
||||
and dept.full_name like concat(concat('%', #{param3.fullName}),'%')
|
||||
</if>
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
|
||||
/**
|
||||
* 小程序用户 Mapper
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface MpUserMapper extends BaseMapper<MpUser> {
|
||||
}
|
||||
+5
@@ -45,6 +45,11 @@ public interface RailwayStationMapper extends BaseMapper<RailwayStation> {
|
||||
*/
|
||||
RailwayStation selectByCodeIncludingDeleted(@Param("code") String code);
|
||||
|
||||
/**
|
||||
* 按 TMIS 国标编码查询铁路车站(包含逻辑删除记录,用于导入恢复)。
|
||||
*/
|
||||
RailwayStation selectByTmisCodeIncludingDeleted(@Param("tmisCode") String tmisCode);
|
||||
|
||||
/**
|
||||
* 恢复逻辑删除铁路车站。
|
||||
*/
|
||||
|
||||
+8
@@ -36,6 +36,14 @@
|
||||
SELECT *
|
||||
FROM blade_railway_station
|
||||
WHERE code = #{code}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectByTmisCodeIncludingDeleted" resultType="org.springblade.system.pojo.entity.RailwayStation">
|
||||
SELECT *
|
||||
FROM blade_railway_station
|
||||
WHERE tmis_code = #{tmisCode}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="restoreById">
|
||||
|
||||
+6
-3
@@ -44,13 +44,16 @@
|
||||
and bu.tenant_id = #{user.tenantId}
|
||||
</if>
|
||||
<if test="user.account!=null and user.account != ''">
|
||||
and bu.account = #{user.account}
|
||||
and bu.account like concat('%', #{user.account}, '%')
|
||||
</if>
|
||||
<if test="user.realName!=null and user.realName != ''">
|
||||
and bu.real_name = #{user.realName}
|
||||
and (
|
||||
bu.real_name like concat('%', #{user.realName}, '%')
|
||||
or bu.name like concat('%', #{user.realName}, '%')
|
||||
)
|
||||
</if>
|
||||
<if test="user.phone!=null and user.phone != ''">
|
||||
and bu.phone = #{user.phone}
|
||||
and bu.phone like concat('%', #{user.phone}, '%')
|
||||
</if>
|
||||
<if test="user.userType!=null and user.userType != ''">
|
||||
and bu.user_type = #{user.userType}
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ import org.springblade.core.literule.core.RuleComponent;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.entity.Tenant;
|
||||
import org.springblade.system.pojo.enums.DeptCategory;
|
||||
import org.springblade.system.rule.context.TenantContext;
|
||||
|
||||
import static org.springblade.system.rule.constant.TenantRuleConstant.TENANT_DEPT_RULE;
|
||||
@@ -54,7 +55,7 @@ public class TenantDeptRule extends RuleComponent {
|
||||
dept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
dept.setDeptName(tenant.getTenantName());
|
||||
dept.setFullName(tenant.getTenantName());
|
||||
dept.setDeptCategory(1);
|
||||
dept.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
dept.setSort(2);
|
||||
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
|
||||
|
||||
+8
-1
@@ -149,12 +149,19 @@ public interface IDeptService extends IService<Dept> {
|
||||
boolean removeDept(String ids);
|
||||
|
||||
/**
|
||||
* 删除非顶级组织,顶级组织(父级为 0)保留
|
||||
* 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级
|
||||
*
|
||||
* @return 删除数量
|
||||
*/
|
||||
int clearNonTopDepts();
|
||||
|
||||
/**
|
||||
* 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级
|
||||
*
|
||||
* @return 集团顶级组织(id=135)
|
||||
*/
|
||||
Dept ensureOaSyncRootDept();
|
||||
|
||||
/**
|
||||
* 新增或修改部门
|
||||
* <p>
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小程序用户 服务类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IMpUserService extends BaseService<MpUser> {
|
||||
|
||||
/**
|
||||
* 按租户 + 手机号查询
|
||||
*/
|
||||
MpUser getByPhone(String tenantId, String phone);
|
||||
|
||||
/**
|
||||
* 按租户 + 账号查询
|
||||
*/
|
||||
MpUser getByAccount(String tenantId, String account);
|
||||
|
||||
/**
|
||||
* 按主键组装登录信息
|
||||
*/
|
||||
MpUserInfo userInfo(Long userId);
|
||||
|
||||
/**
|
||||
* 按租户 + 手机号组装登录信息
|
||||
*/
|
||||
MpUserInfo userInfoByPhone(String tenantId, String phone);
|
||||
|
||||
/**
|
||||
* 按租户 + 账号组装登录信息
|
||||
*/
|
||||
MpUserInfo userInfoByAccount(String tenantId, String account);
|
||||
|
||||
/**
|
||||
* 后台分页查询
|
||||
*/
|
||||
IPage<MpUser> selectMpUserPage(IPage<MpUser> page, MpUser query);
|
||||
|
||||
/**
|
||||
* 保存小程序用户
|
||||
*/
|
||||
boolean submit(MpUser user);
|
||||
|
||||
/**
|
||||
* 更新小程序用户
|
||||
*/
|
||||
boolean updateUser(MpUser user);
|
||||
|
||||
/**
|
||||
* 新增或修改(后台管理)
|
||||
*/
|
||||
boolean submitOrUpdate(MpUser user);
|
||||
|
||||
/**
|
||||
* 逻辑删除
|
||||
*/
|
||||
boolean removeMpUser(List<Long> ids);
|
||||
|
||||
/**
|
||||
* 启用 / 停用
|
||||
*/
|
||||
boolean changeStatus(Long id, Integer status);
|
||||
|
||||
/**
|
||||
* 重置密码为随机密码,返回 account -> 明文密码
|
||||
*/
|
||||
Map<String, String> resetPassword(List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 绑定/更新 openid
|
||||
*/
|
||||
boolean bindOpenId(String tenantId, Long userId, String openid);
|
||||
|
||||
/**
|
||||
* 当前小程序用户设置密码
|
||||
*/
|
||||
boolean setPassword(Long userId, String password, String password2);
|
||||
|
||||
}
|
||||
+15
-9
@@ -32,27 +32,33 @@ public interface IOASyncService {
|
||||
/**
|
||||
* 按页从 OA 人员接口同步组织与人员
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
|
||||
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
OaPersonSyncPageVO syncPersonFromUserList(int current, int size);
|
||||
OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince);
|
||||
|
||||
/**
|
||||
* 按页从 OA 公司接口同步公司
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
|
||||
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
OaOrgSyncPageVO syncCompanyPage(int current, int size);
|
||||
OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
|
||||
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size);
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
}
|
||||
|
||||
+9
@@ -93,6 +93,15 @@ public interface IUserService extends BaseService<User> {
|
||||
*/
|
||||
boolean updateUser(User user);
|
||||
|
||||
/**
|
||||
* 启用或停用用户(租户守卫校验用户归属,仅更新状态)
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @param status 状态:1 启用,0 停用
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean changeUserStatus(Long userId, Integer status);
|
||||
|
||||
/**
|
||||
* 修改用户基本信息(仅限当前登录用户自身,忽略角色 / 部门 / 岗位 / 账号等敏感字段)
|
||||
*
|
||||
|
||||
+286
-48
@@ -27,6 +27,7 @@ package org.springblade.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -389,16 +390,193 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int clearNonTopDepts() {
|
||||
Long nonTopCount = this.count(Wrappers.<Dept>lambdaQuery()
|
||||
.isNotNull(Dept::getParentId)
|
||||
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
|
||||
if (nonTopCount == null || nonTopCount == 0L) {
|
||||
// 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除
|
||||
Dept groupRoot = this.ensureOaSyncRootDept();
|
||||
Dept externalRoot = this.ensureExternalOrgTopDept();
|
||||
Set<Long> keepIds = new HashSet<>();
|
||||
if (groupRoot != null && groupRoot.getId() != null) {
|
||||
keepIds.add(groupRoot.getId());
|
||||
}
|
||||
if (externalRoot != null && externalRoot.getId() != null) {
|
||||
keepIds.add(externalRoot.getId());
|
||||
}
|
||||
if (keepIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
this.remove(Wrappers.<Dept>lambdaQuery()
|
||||
.isNotNull(Dept::getParentId)
|
||||
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
|
||||
return nonTopCount.intValue();
|
||||
Long clearCount = this.count(Wrappers.<Dept>lambdaQuery().notIn(Dept::getId, keepIds));
|
||||
if (clearCount == null || clearCount == 0L) {
|
||||
return 0;
|
||||
}
|
||||
this.remove(Wrappers.<Dept>lambdaQuery().notIn(Dept::getId, keepIds));
|
||||
return clearCount.intValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Dept ensureOaSyncRootDept() {
|
||||
this.migrateLegacyOaDeptCategory();
|
||||
this.removeLegacyOaSyncRoots();
|
||||
Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtil.isBlank(tenantId)) {
|
||||
tenantId = BladeConstant.ADMIN_TENANT_ID;
|
||||
}
|
||||
if (rootDept == null) {
|
||||
rootDept = new Dept();
|
||||
rootDept.setId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
rootDept.setTenantId(tenantId);
|
||||
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
rootDept.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
rootDept.setSort(0);
|
||||
rootDept.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
rootDept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
rootDept.setIsOa(0);
|
||||
rootDept.setIsPlatformCompany(0);
|
||||
rootDept.setSyncTime(new Date());
|
||||
this.save(rootDept);
|
||||
log.info("已创建 OA 同步集团顶级 id={} name={} code={}",
|
||||
OAConvertConstant.OA_SYNC_ROOT_DEPT_ID,
|
||||
OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME,
|
||||
OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
} else {
|
||||
boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName());
|
||||
boolean needUpdate = wronglyNamedAsExternal
|
||||
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode())
|
||||
|| !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory())
|
||||
|| !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId());
|
||||
if (needUpdate) {
|
||||
Dept updateParam = new Dept();
|
||||
updateParam.setId(rootDept.getId());
|
||||
if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) {
|
||||
updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
}
|
||||
updateParam.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
updateParam.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
updateParam.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
updateParam.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
this.updateById(updateParam);
|
||||
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
rootDept.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}",
|
||||
rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory());
|
||||
}
|
||||
}
|
||||
this.ensureExternalOrgTopDept();
|
||||
return rootDept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级)
|
||||
*/
|
||||
private Dept ensureExternalOrgTopDept() {
|
||||
List<Dept> externalTops = this.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptName, OAConvertConstant.EXTERNAL_ORG_NAME)
|
||||
.and(wrapper -> wrapper
|
||||
.isNull(Dept::getParentId)
|
||||
.or()
|
||||
.eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID))
|
||||
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
|
||||
.orderByAsc(Dept::getId));
|
||||
if (CollectionUtil.isNotEmpty(externalTops)) {
|
||||
Dept keep = externalTops.get(0);
|
||||
if (externalTops.size() > 1) {
|
||||
List<Long> duplicateIds = externalTops.stream()
|
||||
.skip(1)
|
||||
.map(Dept::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(duplicateIds)) {
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getParentId, keep.getId())
|
||||
.in(Dept::getParentId, duplicateIds));
|
||||
this.removeByIds(duplicateIds);
|
||||
log.info("已合并重复的外部组织顶级,保留 id={},删除{}", keep.getId(), duplicateIds);
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtil.isBlank(tenantId)) {
|
||||
tenantId = BladeConstant.ADMIN_TENANT_ID;
|
||||
}
|
||||
Dept externalDept = new Dept();
|
||||
externalDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
externalDept.setTenantId(tenantId);
|
||||
externalDept.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
externalDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
externalDept.setDeptName(OAConvertConstant.EXTERNAL_ORG_NAME);
|
||||
externalDept.setFullName(OAConvertConstant.EXTERNAL_ORG_NAME);
|
||||
externalDept.setShortName(OAConvertConstant.EXTERNAL_ORG_NAME);
|
||||
externalDept.setDeptCode(null);
|
||||
externalDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
externalDept.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
externalDept.setSort(0);
|
||||
externalDept.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
externalDept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
externalDept.setIsOa(0);
|
||||
externalDept.setIsPlatformCompany(0);
|
||||
externalDept.setSyncTime(new Date());
|
||||
this.save(externalDept);
|
||||
log.info("已创建外部组织顶级 id={}", externalDept.getId());
|
||||
return externalDept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级
|
||||
*/
|
||||
private void removeLegacyOaSyncRoots() {
|
||||
List<Dept> legacyRoots = this.list(Wrappers.<Dept>lambdaQuery()
|
||||
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
|
||||
.or(nameWrapper -> nameWrapper
|
||||
.eq(Dept::getDeptName, OAConvertConstant.LEGACY_ROOT_DEPT_NAME)
|
||||
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
|
||||
.and(parentWrapper -> parentWrapper
|
||||
.isNull(Dept::getParentId)
|
||||
.or()
|
||||
.eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID)))));
|
||||
if (CollectionUtil.isEmpty(legacyRoots)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> legacyIds = legacyRoots.stream().map(Dept::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getParentId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
|
||||
.set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
|
||||
.in(Dept::getParentId, legacyIds));
|
||||
this.removeByIds(legacyIds);
|
||||
log.info("已清理历史伪顶级组织{}条:{}", legacyIds.size(), legacyIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码前缀对齐组织类型:OAC→公司、OAD→部门(覆盖历史 1/2 映射)
|
||||
*/
|
||||
private void migrateLegacyOaDeptCategory() {
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
.likeRight(Dept::getDeptCode, OAConvertConstant.DEPARTMENT_OA_PREFIX));
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.likeRight(Dept::getDeptCode, OAConvertConstant.COMPANY_OA_PREFIX)
|
||||
.ne(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -434,10 +612,14 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (dept.getIsPlatformCompany() == null) {
|
||||
dept.setIsPlatformCompany(0);
|
||||
}
|
||||
// 排序未填时默认 1
|
||||
if (dept.getSort() == null) {
|
||||
dept.setSort(1);
|
||||
}
|
||||
if (Func.isEmpty(dept.getTenantId())) {
|
||||
throw new ServiceException("租户ID不能为空");
|
||||
}
|
||||
validateDeptCategory(dept, parent);
|
||||
validateDeptCategory(dept);
|
||||
validateDeptCode(dept, parent);
|
||||
return saveOrUpdate(dept);
|
||||
}
|
||||
@@ -619,27 +801,8 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
return "Basic " + value;
|
||||
}
|
||||
|
||||
private void validateDeptCategory(Dept dept, Dept parent) {
|
||||
// 顶级组织不要求选择上级组织
|
||||
if (parent == null) {
|
||||
if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) {
|
||||
throw new ServiceException("请选择承运商");
|
||||
}
|
||||
return;
|
||||
}
|
||||
List<Integer> allowedCategories;
|
||||
if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) {
|
||||
allowedCategories = "外部组织".equals(parent.getDeptName()) ? List.of(6) : List.of(1);
|
||||
} else if (Integer.valueOf(1).equals(parent.getDeptCategory())) {
|
||||
allowedCategories = List.of(2, 3);
|
||||
} else if (List.of(2, 3).contains(parent.getDeptCategory())) {
|
||||
allowedCategories = List.of(4, 5, 6);
|
||||
} else {
|
||||
throw new ServiceException("上级组织层级不支持新增下级组织");
|
||||
}
|
||||
if (!allowedCategories.contains(dept.getDeptCategory())) {
|
||||
throw new ServiceException("组织类型不符合上级组织层级规则");
|
||||
}
|
||||
private void validateDeptCategory(Dept dept) {
|
||||
// 组织树层级仅由 parentId / ancestors 决定,组织类型不再做上下级约束
|
||||
if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) {
|
||||
throw new ServiceException("请选择承运商");
|
||||
}
|
||||
@@ -665,9 +828,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (StringUtil.isBlank(parent.getDeptCode())) {
|
||||
throw new ServiceException("请选择已配置组织编码的上级组织");
|
||||
}
|
||||
String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+";
|
||||
if (!deptCode.matches(pattern)) {
|
||||
throw new ServiceException("组织编码格式应为:上级编码-分段数字");
|
||||
// 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字
|
||||
String expectedParentCode = parent.getDeptCode();
|
||||
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
|
||||
expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE;
|
||||
} else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())
|
||||
&& StringUtil.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
expectedParentCode = dept.getBelongCompanyCode();
|
||||
}
|
||||
if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) {
|
||||
throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)");
|
||||
}
|
||||
this.ensureDeptCodeUnique(dept, deptCode);
|
||||
dept.setDeptCode(deptCode);
|
||||
@@ -739,8 +909,8 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
log.info("没有记录OA主键的组织,跳过层级重建");
|
||||
return;
|
||||
}
|
||||
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
|
||||
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
|
||||
Map<String, Dept> companyByOaId = this.indexOaCompanies(oaDeptList);
|
||||
Map<String, Dept> departmentByOaId = this.indexOaDepartments(oaDeptList);
|
||||
Map<Long, Long> desiredParentIdMap = new LinkedHashMap<>();
|
||||
Map<Long, String> desiredParentCodeMap = new HashMap<>();
|
||||
for (Dept dept : oaDeptList) {
|
||||
@@ -793,21 +963,20 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (CollectionUtil.isEmpty(oaDeptList)) {
|
||||
return 0;
|
||||
}
|
||||
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
|
||||
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
|
||||
Map<String, Dept> companyByOaId = this.indexOaCompanies(oaDeptList);
|
||||
Map<String, Dept> departmentByOaId = this.indexOaDepartments(oaDeptList);
|
||||
Set<Long> deleteIds = new HashSet<>();
|
||||
for (Dept dept : oaDeptList) {
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
// 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除
|
||||
// 上级为空或为 0:OA 顶级公司挂本地锚点,不作为孤儿
|
||||
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
|
||||
continue;
|
||||
}
|
||||
boolean parentExists;
|
||||
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
|
||||
parentExists = companyByOaId.containsKey(parentOaId);
|
||||
} else {
|
||||
parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
|
||||
}
|
||||
boolean isCompany = this.isOaCompany(dept);
|
||||
boolean parentExists = isCompany
|
||||
? companyByOaId.containsKey(parentOaId)
|
||||
: departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
|
||||
// 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留
|
||||
if (!parentExists) {
|
||||
deleteIds.add(dept.getId());
|
||||
}
|
||||
@@ -828,7 +997,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
|
||||
continue;
|
||||
}
|
||||
Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())
|
||||
Dept parent = this.isOaCompany(dept)
|
||||
? companyByOaId.get(parentOaId)
|
||||
: Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId));
|
||||
if (parent != null && deleteIds.contains(parent.getId())) {
|
||||
@@ -857,6 +1026,72 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
return deleteIds.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引 OA 公司:同时兼容类型迁移前后的公司记录
|
||||
*/
|
||||
private Map<String, Dept> indexOaCompanies(List<Dept> oaDeptList) {
|
||||
Map<String, Dept> oaIdMap = new HashMap<>();
|
||||
for (Dept dept : oaDeptList) {
|
||||
if (!this.isOaCompany(dept) || StringUtil.isBlank(dept.getOaId())) {
|
||||
continue;
|
||||
}
|
||||
Dept previous = oaIdMap.put(dept.getOaId(), dept);
|
||||
if (previous != null) {
|
||||
log.warn("OA公司主键重复,层级重建使用后一条 oaId={} id={} previousId={}",
|
||||
dept.getOaId(), dept.getId(), previous.getId());
|
||||
}
|
||||
}
|
||||
return oaIdMap;
|
||||
}
|
||||
|
||||
private boolean isOaCompany(Dept dept) {
|
||||
if (dept == null) {
|
||||
return false;
|
||||
}
|
||||
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
|
||||
return true;
|
||||
}
|
||||
if (org.springblade.system.util.DeptCodeUtil.isCompanyCode(dept.getDeptCode())) {
|
||||
return true;
|
||||
}
|
||||
return StringUtil.isNotBlank(dept.getDeptCode())
|
||||
&& dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX)
|
||||
&& !OAConvertConstant.LEGACY_ROOT_DEPT_CODE.equals(dept.getDeptCode());
|
||||
}
|
||||
|
||||
private boolean isOaDepartment(Dept dept) {
|
||||
if (dept == null) {
|
||||
return false;
|
||||
}
|
||||
if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) {
|
||||
return true;
|
||||
}
|
||||
if (StringUtil.isNotBlank(dept.getBelongCompanyCode())
|
||||
&& org.springblade.system.util.DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())) {
|
||||
return true;
|
||||
}
|
||||
return StringUtil.isNotBlank(dept.getDeptCode())
|
||||
&& dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引 OA 部门:兼容类型迁移前后
|
||||
*/
|
||||
private Map<String, Dept> indexOaDepartments(List<Dept> oaDeptList) {
|
||||
Map<String, Dept> oaIdMap = new HashMap<>();
|
||||
for (Dept dept : oaDeptList) {
|
||||
if (!this.isOaDepartment(dept) || StringUtil.isBlank(dept.getOaId())) {
|
||||
continue;
|
||||
}
|
||||
Dept previous = oaIdMap.put(dept.getOaId(), dept);
|
||||
if (previous != null) {
|
||||
log.warn("OA部门主键重复,层级重建使用后一条 oaId={} id={} previousId={}",
|
||||
dept.getOaId(), dept.getId(), previous.getId());
|
||||
}
|
||||
}
|
||||
return oaIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按机构类型建立 OA 主键索引
|
||||
*/
|
||||
@@ -881,19 +1116,22 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
private OaParentLink resolveOaParentLink(Dept dept, Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId);
|
||||
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
|
||||
if (this.isOaCompany(dept)) {
|
||||
if (rootParent) {
|
||||
return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID);
|
||||
// OA 顶级公司统一挂到本地顶级锚点下
|
||||
return new OaParentLink(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
}
|
||||
Dept parent = companyByOaId.get(parentOaId);
|
||||
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
|
||||
// 全量整理后仍无上级:跳过重建,交由孤儿清理直接删除
|
||||
log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
|
||||
dept.getOaId(), parentOaId, dept.getId());
|
||||
return null;
|
||||
}
|
||||
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId));
|
||||
}
|
||||
if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) {
|
||||
if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())
|
||||
&& !(StringUtil.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX))) {
|
||||
return null;
|
||||
}
|
||||
if (rootParent) {
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl<MeasurementUnitM
|
||||
private static final int UNIT_NAME_MAX_LENGTH = 50;
|
||||
private static final int DIMENSION_MAX_LENGTH = 20;
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
private static final Set<String> DIMENSIONS = Set.of("重量", "体积", "数量");
|
||||
private static final Set<String> DIMENSIONS = Set.of("重量", "体积", "数量", "整车", "里程", "吨·公里");
|
||||
|
||||
@Override
|
||||
public IPage<MeasurementUnitVO> selectMeasurementUnitPage(IPage<MeasurementUnitVO> page,
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.common.utils.PasswordRuleUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.support.Kv;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.DigestUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.system.cache.MpUserCache;
|
||||
import org.springblade.system.mapper.MpUserMapper;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.system.service.IMpUserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 小程序用户 服务实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> implements IMpUserService {
|
||||
|
||||
@Override
|
||||
public MpUser getByPhone(String tenantId, String phone) {
|
||||
if (Func.isBlank(tenantId) || Func.isBlank(phone)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getPhone, phone)
|
||||
.orderByDesc(MpUser::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUser getByAccount(String tenantId, String account) {
|
||||
if (Func.isBlank(tenantId) || Func.isBlank(account)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getAccount, account)
|
||||
.orderByDesc(MpUser::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUserInfo userInfo(Long userId) {
|
||||
if (Func.isEmpty(userId)) {
|
||||
return null;
|
||||
}
|
||||
return buildUserInfo(getById(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUserInfo userInfoByPhone(String tenantId, String phone) {
|
||||
return buildUserInfo(getByPhone(tenantId, phone));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUserInfo userInfoByAccount(String tenantId, String account) {
|
||||
return buildUserInfo(getByAccount(tenantId, account));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<MpUser> selectMpUserPage(IPage<MpUser> page, MpUser query) {
|
||||
MpUser condition = query == null ? new MpUser() : query;
|
||||
return page(page, Wrappers.<MpUser>lambdaQuery()
|
||||
.like(Func.isNotBlank(condition.getPhone()), MpUser::getPhone, condition.getPhone())
|
||||
.like(Func.isNotBlank(condition.getAccount()), MpUser::getAccount, condition.getAccount())
|
||||
.like(Func.isNotBlank(condition.getRealName()), MpUser::getRealName, condition.getRealName())
|
||||
.like(Func.isNotBlank(condition.getName()), MpUser::getName, condition.getName())
|
||||
.like(Func.isNotBlank(condition.getIdentities()), MpUser::getIdentities, condition.getIdentities())
|
||||
.eq(condition.getStatus() != null, MpUser::getStatus, condition.getStatus())
|
||||
.orderByDesc(MpUser::getCreateTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submit(MpUser user) {
|
||||
prepare(user);
|
||||
validate(user);
|
||||
MpUser existed = getByPhone(user.getTenantId(), user.getPhone());
|
||||
if (existed != null && Func.isNotEmpty(existed.getId())) {
|
||||
throw new ServiceException("该手机号已存在小程序用户");
|
||||
}
|
||||
encryptPasswordIfPresent(user);
|
||||
boolean saved = save(user);
|
||||
if (saved) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean updateUser(MpUser user) {
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("小程序用户主键不能为空");
|
||||
}
|
||||
prepare(user);
|
||||
validate(user);
|
||||
MpUser existed = getByPhone(user.getTenantId(), user.getPhone());
|
||||
if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) {
|
||||
throw new ServiceException("该手机号已存在小程序用户");
|
||||
}
|
||||
// 后台编辑不改密码,避免空串覆盖
|
||||
user.setPassword(null);
|
||||
boolean updated = updateById(user);
|
||||
if (updated) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submitOrUpdate(MpUser user) {
|
||||
if (user != null && Func.isNotEmpty(user.getId())) {
|
||||
return updateUser(user);
|
||||
}
|
||||
return submit(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean removeMpUser(List<Long> ids) {
|
||||
if (Func.isEmpty(ids)) {
|
||||
throw new ServiceException("请选择要删除的小程序用户");
|
||||
}
|
||||
boolean removed = deleteLogic(ids);
|
||||
if (removed) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean changeStatus(Long id, Integer status) {
|
||||
if (Func.isEmpty(id)) {
|
||||
throw new ServiceException("小程序用户主键不能为空");
|
||||
}
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
throw new ServiceException("状态仅支持启用(1)或停用(0)");
|
||||
}
|
||||
MpUser user = getById(id);
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("小程序用户不存在");
|
||||
}
|
||||
MpUser patch = new MpUser();
|
||||
patch.setId(id);
|
||||
patch.setStatus(status);
|
||||
boolean updated = updateById(patch);
|
||||
if (updated) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, String> resetPassword(List<Long> userIds) {
|
||||
if (Func.isEmpty(userIds)) {
|
||||
throw new ServiceException("请选择要重置密码的小程序用户");
|
||||
}
|
||||
Map<String, String> passwordMap = new LinkedHashMap<>();
|
||||
for (Long userId : userIds) {
|
||||
MpUser user = getById(userId);
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
continue;
|
||||
}
|
||||
String password = PasswordRuleUtil.generate();
|
||||
MpUser patch = new MpUser();
|
||||
patch.setId(userId);
|
||||
patch.setPassword(DigestUtil.encrypt(password));
|
||||
patch.setUpdateTime(DateUtil.now());
|
||||
updateById(patch);
|
||||
String account = Func.toStr(user.getAccount(), user.getPhone());
|
||||
passwordMap.put(account, password);
|
||||
}
|
||||
if (passwordMap.isEmpty()) {
|
||||
throw new ServiceException("未找到可重置密码的小程序用户");
|
||||
}
|
||||
MpUserCache.clear();
|
||||
return passwordMap;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean bindOpenId(String tenantId, Long userId, String openid) {
|
||||
if (Func.isBlank(tenantId) || Func.isEmpty(userId) || Func.isBlank(openid)) {
|
||||
throw new ServiceException("绑定 openid 参数不完整");
|
||||
}
|
||||
MpUser user = getById(userId);
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("小程序用户不存在");
|
||||
}
|
||||
if (!tenantId.equals(user.getTenantId())) {
|
||||
throw new ServiceException("租户不匹配,无法绑定 openid");
|
||||
}
|
||||
MpUser byOpenId = getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getOpenid, openid)
|
||||
.last("LIMIT 1"));
|
||||
if (byOpenId != null && !byOpenId.getId().equals(userId)) {
|
||||
throw new ServiceException("该微信已绑定其他小程序用户");
|
||||
}
|
||||
MpUser patch = new MpUser();
|
||||
patch.setId(userId);
|
||||
patch.setOpenid(openid);
|
||||
boolean updated = updateById(patch);
|
||||
if (updated) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean setPassword(Long userId, String password, String password2) {
|
||||
if (!Objects.equals(password, password2)) {
|
||||
throw new ServiceException("两次输入密码不一致!");
|
||||
}
|
||||
if (!PasswordRuleUtil.isValid(password)) {
|
||||
throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
|
||||
}
|
||||
MpUser user = getById(userId);
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("小程序用户不存在");
|
||||
}
|
||||
MpUser patch = new MpUser();
|
||||
patch.setId(userId);
|
||||
patch.setPassword(DigestUtil.encrypt(password));
|
||||
patch.setUpdateTime(DateUtil.now());
|
||||
boolean updated = updateById(patch);
|
||||
if (updated) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private MpUserInfo buildUserInfo(MpUser user) {
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
return null;
|
||||
}
|
||||
List<String> identities = MpUserIdentity.split(user.getIdentities());
|
||||
if (identities.isEmpty()) {
|
||||
throw new ServiceException("小程序用户未配置身份,无法登录");
|
||||
}
|
||||
MpUserInfo userInfo = new MpUserInfo();
|
||||
userInfo.setUser(user);
|
||||
userInfo.setIdentities(identities);
|
||||
userInfo.setPermissions(MpUserIdentity.toPermissions(user.getIdentities()));
|
||||
userInfo.setDetail(Kv.create()
|
||||
.set("type", MpUserIdentity.DETAIL_TYPE)
|
||||
.set("identities", user.getIdentities())
|
||||
.set("openid", Func.toStr(user.getOpenid(), "")));
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private void prepare(MpUser user) {
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if (Func.isBlank(user.getTenantId())) {
|
||||
user.setTenantId(AuthUtil.getTenantId());
|
||||
}
|
||||
user.setAccount(trimToNull(user.getAccount()));
|
||||
user.setPhone(trimToNull(user.getPhone()));
|
||||
user.setName(trimToNull(user.getName()));
|
||||
user.setRealName(trimToNull(user.getRealName()));
|
||||
user.setAvatar(trimToNull(user.getAvatar()));
|
||||
user.setEmail(trimToNull(user.getEmail()));
|
||||
user.setOpenid(trimToNull(user.getOpenid()));
|
||||
user.setDeptId(trimToNull(user.getDeptId()));
|
||||
user.setPostId(trimToNull(user.getPostId()));
|
||||
user.setRemark(trimToNull(user.getRemark()));
|
||||
user.setIdentities(normalizeIdentities(user.getIdentities()));
|
||||
if (Func.isBlank(user.getAccount()) && Func.isNotBlank(user.getPhone())) {
|
||||
user.setAccount(user.getPhone());
|
||||
}
|
||||
if (Func.isBlank(user.getName()) && Func.isNotBlank(user.getRealName())) {
|
||||
user.setName(user.getRealName());
|
||||
}
|
||||
if (user.getStatus() == null) {
|
||||
user.setStatus(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void encryptPasswordIfPresent(MpUser user) {
|
||||
if (user == null || Func.isBlank(user.getPassword())) {
|
||||
user.setPassword(null);
|
||||
return;
|
||||
}
|
||||
String password = user.getPassword().trim();
|
||||
if (!PasswordRuleUtil.isValid(password)) {
|
||||
throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
|
||||
}
|
||||
user.setPassword(DigestUtil.encrypt(password));
|
||||
}
|
||||
|
||||
private void validate(MpUser user) {
|
||||
if (user == null) {
|
||||
throw new ServiceException("小程序用户不能为空");
|
||||
}
|
||||
if (Func.isBlank(user.getTenantId())) {
|
||||
throw new ServiceException("租户信息不能为空");
|
||||
}
|
||||
if (Func.isBlank(user.getPhone())) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
if (Func.isBlank(user.getIdentities())) {
|
||||
throw new ServiceException("小程序身份不能为空");
|
||||
}
|
||||
List<String> identities = MpUserIdentity.split(user.getIdentities());
|
||||
if (identities.isEmpty()) {
|
||||
throw new ServiceException("小程序身份不能为空");
|
||||
}
|
||||
for (String code : identities) {
|
||||
if (MpUserIdentity.of(code) == null) {
|
||||
throw new ServiceException("不支持的小程序身份:" + code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeIdentities(String identities) {
|
||||
List<String> identityList = MpUserIdentity.split(identities);
|
||||
if (identityList.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return StringUtil.join(identityList);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimValue = value.trim();
|
||||
return trimValue.isEmpty() ? null : trimValue;
|
||||
}
|
||||
|
||||
}
|
||||
+440
-90
@@ -22,6 +22,7 @@ import org.springblade.system.pojo.vo.OaOrgSyncPageVO;
|
||||
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
|
||||
import org.springblade.system.service.*;
|
||||
import org.springblade.system.util.DataSyncRecordUtils;
|
||||
import org.springblade.system.util.DeptCodeUtil;
|
||||
import org.springblade.thirdparty.oa.constant.OAConstant;
|
||||
import org.springblade.thirdparty.oa.constant.OAConvertConstant;
|
||||
import org.springblade.thirdparty.oa.feign.IOAClient;
|
||||
@@ -60,6 +61,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
private final IOAClient oaClient;
|
||||
private final DeptConvert deptConvert;
|
||||
private final IDeptService deptService;
|
||||
private final IUserService userService;
|
||||
private final IRoleService roleService;
|
||||
private final IMKPushService mkPushService;
|
||||
private final IDataSyncRecordService dataSyncRecordService;
|
||||
@@ -72,6 +74,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.addLog(log);
|
||||
// 1. 同步公司和部门并记录
|
||||
Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> {
|
||||
// 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
|
||||
deptService.ensureOaSyncRootDept();
|
||||
// 1. 同步公司
|
||||
this.syncCompany(queryStartTime);
|
||||
// 公司分批入库后,重建层级并清理无法挂载的公司
|
||||
@@ -124,10 +128,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public OaPersonSyncPageVO syncPersonFromUserList(int current, int size) {
|
||||
public OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince) {
|
||||
try {
|
||||
ComposeLogUtil.addLog(log);
|
||||
return this.syncPersonFromOaPage(current, size);
|
||||
return this.syncPersonFromOaPage(current, size, incremental, modifiedSince);
|
||||
} finally {
|
||||
ComposeLogUtil.removeLastLog();
|
||||
}
|
||||
@@ -135,10 +139,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public OaOrgSyncPageVO syncCompanyPage(int current, int size) {
|
||||
public OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
try {
|
||||
ComposeLogUtil.addLog(log);
|
||||
return this.syncCompanyFromOaPage(current, size);
|
||||
return this.syncCompanyFromOaPage(current, size, incremental, modifiedSince);
|
||||
} finally {
|
||||
ComposeLogUtil.removeLastLog();
|
||||
}
|
||||
@@ -146,10 +150,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size) {
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
try {
|
||||
ComposeLogUtil.addLog(log);
|
||||
return this.syncDepartmentFromOaPage(current, size);
|
||||
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince);
|
||||
} finally {
|
||||
ComposeLogUtil.removeLastLog();
|
||||
}
|
||||
@@ -280,14 +284,17 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
/**
|
||||
* 按页从 OA 人员列表同步组织与人员
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步
|
||||
* @param modifiedSince 增量起始时间(后续分页回传)
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size) {
|
||||
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
OAPersonSearch personSearch = buildPersonSearch(null);
|
||||
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
|
||||
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
|
||||
personSearch.setCurPage(pageNo);
|
||||
personSearch.setPageSize(pageSize);
|
||||
OAResponse<OAPersonResponse> oaResponse = oaClient.queryPersonPage(new OASearch<>(personSearch));
|
||||
@@ -311,26 +318,35 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
pageVO.setFetchedCount(oaPersons.size());
|
||||
pageVO.setSyncedCount(personSyncCount.getSyncedCount());
|
||||
pageVO.setSkippedCount(personSyncCount.getSkippedCount());
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
boolean finished = oaPersons.isEmpty()
|
||||
|| oaPersons.size() < pageSize
|
||||
|| (long) pageNo * pageSize >= totalSize;
|
||||
pageVO.setFinished(finished);
|
||||
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 {}/{},成功{},跳过{}",
|
||||
pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
|
||||
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 公司接口同步公司
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步
|
||||
* @param modifiedSince 增量起始时间(后续分页回传)
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size) {
|
||||
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
OACompanySearch companySearch = buildCompanySearch(null);
|
||||
// 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
|
||||
if (pageNo == 1) {
|
||||
deptService.ensureOaSyncRootDept();
|
||||
}
|
||||
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY);
|
||||
OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime());
|
||||
companySearch.setCurPage(pageNo);
|
||||
companySearch.setPageSize(pageSize);
|
||||
OAResponse<OACompanyResponse> oaResponse = oaClient.queryCompanyPage(new OASearch<>(companySearch));
|
||||
@@ -347,6 +363,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
}
|
||||
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
if (Boolean.TRUE.equals(pageVO.getFinished())) {
|
||||
// 公司全部分批入库后:重建层级,并删除上级不存在的数据
|
||||
deptService.rebuildOaHierarchy();
|
||||
@@ -354,19 +371,22 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}",
|
||||
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
ComposeLogUtil.getLastLog().info("OA公司分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步
|
||||
* @param modifiedSince 增量起始时间(后续分页回传)
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) {
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
String subCompanyIds = getOaSubCompanyIds(true);
|
||||
@@ -376,7 +396,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
emptyPageVO.setFinished(true);
|
||||
return emptyPageVO;
|
||||
}
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(null, subCompanyIds);
|
||||
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT);
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds);
|
||||
departmentSearch.setCurPage(pageNo);
|
||||
departmentSearch.setPageSize(pageSize);
|
||||
OAResponse<OADepartmentResponse> oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch));
|
||||
@@ -393,6 +414,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
}
|
||||
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
if (Boolean.TRUE.equals(pageVO.getFinished())) {
|
||||
// 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表
|
||||
deptService.rebuildOaHierarchy();
|
||||
@@ -401,11 +423,118 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}",
|
||||
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析增量同步起始时间:优先使用前端回传值,保证同阶段分页起点不变
|
||||
*/
|
||||
private IncrementalStart resolveIncrementalStart(boolean incremental, String modifiedSince, DeptCategory deptCategory) {
|
||||
if (!incremental) {
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
if (StringUtils.isNotBlank(modifiedSince)) {
|
||||
try {
|
||||
Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME);
|
||||
return new IncrementalStart(startTime, modifiedSince.trim());
|
||||
} catch (Exception exception) {
|
||||
ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince);
|
||||
}
|
||||
}
|
||||
String codePrefix = DeptCategory.COMPANY.equals(deptCategory)
|
||||
? OAConvertConstant.COMPANY_OA_PREFIX
|
||||
: OAConvertConstant.DEPARTMENT_OA_PREFIX;
|
||||
Dept latest = deptService.getOne(Wrappers.<Dept>lambdaQuery()
|
||||
.select(Dept::getOaModified)
|
||||
.eq(Dept::getIsOa, YES)
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Dept::getDeptCategory, deptCategory.getCode())
|
||||
.or()
|
||||
.likeRight(Dept::getDeptCode, codePrefix))
|
||||
.isNotNull(Dept::getOaModified)
|
||||
.ne(Dept::getOaModified, "")
|
||||
.orderByDesc(Dept::getOaModified)
|
||||
.last("limit 1"), false);
|
||||
if (latest == null || StringUtils.isBlank(latest.getOaModified())) {
|
||||
ComposeLogUtil.getLastLog().info("本地无 {} oaModified 记录,增量同步按全量拉取后按字段比对跳过",
|
||||
deptCategory.getName());
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
try {
|
||||
Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME);
|
||||
ComposeLogUtil.getLastLog().info("增量同步{} OA modified 起始时间:{}",
|
||||
deptCategory.getName(), latest.getOaModified());
|
||||
return new IncrementalStart(startTime, latest.getOaModified());
|
||||
} catch (Exception exception) {
|
||||
ComposeLogUtil.getLastLog().warn("解析本地最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified());
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员增量同步起始时间:优先回传值,否则取本地 OA 用户最大 oaModified
|
||||
*/
|
||||
private IncrementalStart resolvePersonIncrementalStart(boolean incremental, String modifiedSince) {
|
||||
if (!incremental) {
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
if (StringUtils.isNotBlank(modifiedSince)) {
|
||||
try {
|
||||
Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME);
|
||||
return new IncrementalStart(startTime, modifiedSince.trim());
|
||||
} catch (Exception exception) {
|
||||
ComposeLogUtil.getLastLog().warn("解析人员回传 modifiedSince 失败:{},重新计算", modifiedSince);
|
||||
}
|
||||
}
|
||||
User latest = userService.getOne(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getOaModified)
|
||||
.eq(User::getIsOa, YES)
|
||||
.isNotNull(User::getOaModified)
|
||||
.ne(User::getOaModified, "")
|
||||
.orderByDesc(User::getOaModified)
|
||||
.last("limit 1"), false);
|
||||
if (latest == null || StringUtils.isBlank(latest.getOaModified())) {
|
||||
ComposeLogUtil.getLastLog().info("本地无人员 oaModified 记录,增量同步按全量拉取后按字段比对跳过");
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
try {
|
||||
Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME);
|
||||
ComposeLogUtil.getLastLog().info("增量同步人员 OA modified 起始时间:{}", latest.getOaModified());
|
||||
return new IncrementalStart(startTime, latest.getOaModified());
|
||||
} catch (Exception exception) {
|
||||
ComposeLogUtil.getLastLog().warn("解析本地人员最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified());
|
||||
return IncrementalStart.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量同步起点
|
||||
*/
|
||||
private static class IncrementalStart {
|
||||
private final Date startTime;
|
||||
private final String modifiedSince;
|
||||
|
||||
private IncrementalStart(Date startTime, String modifiedSince) {
|
||||
this.startTime = startTime;
|
||||
this.modifiedSince = modifiedSince;
|
||||
}
|
||||
|
||||
private static IncrementalStart empty() {
|
||||
return new IncrementalStart(null, null);
|
||||
}
|
||||
|
||||
private Date getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
private String getModifiedSince() {
|
||||
return modifiedSince;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装组织分页同步结果
|
||||
*/
|
||||
@@ -492,13 +621,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
if (CollectionUtil.isEmpty(oaCompanies)) {
|
||||
return OrgSyncCount.empty();
|
||||
}
|
||||
// 获取需要的公司名称
|
||||
Set<String> companyNames = syncAll ? Collections.emptySet() : getCompanyNames();
|
||||
// 转换数据
|
||||
List<Dept> allParam = oaCompanies.stream()
|
||||
// 强制跳过上级公司为 0 的数据
|
||||
.filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid()))
|
||||
// 非全量同步时,筛选只要配置的公司
|
||||
.filter(company -> syncAll || companyNames.contains(company.getSubcompanyname()))
|
||||
.map(deptConvert::company2dept)
|
||||
.toList();
|
||||
@@ -507,14 +631,41 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList());
|
||||
}
|
||||
|
||||
// 查询数据库的部门,转换成map
|
||||
Map<String, Long> deptMap = getAllCompanyDeptMap();
|
||||
|
||||
// 处理部门
|
||||
List<Dept> notHandledList = handleDept(allParam, deptMap, DeptCategory.COMPANY);
|
||||
int syncedCount = allParam.size() - notHandledList.size();
|
||||
int skippedCount = filteredSkipCount + notHandledList.size();
|
||||
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
|
||||
Map<String, Dept> existingByOaId = loadExistingByOaId(DeptCategory.COMPANY);
|
||||
Map<String, Dept> codeOwnerMap = loadCompanyCodeOwnerMap();
|
||||
Set<String> usedCodes = new HashSet<>(codeOwnerMap.keySet());
|
||||
Map<String, Dept> batchByOaId = new LinkedHashMap<>();
|
||||
for (Dept dept : allParam) {
|
||||
this.assignCompanyCode(dept, existingByOaId, usedCodes);
|
||||
batchByOaId.put(dept.getOaId(), dept);
|
||||
}
|
||||
// 上级公司编码:OA 上级为 0 → GWWL;否则取上级公司已分配编码
|
||||
for (Dept dept : allParam) {
|
||||
if (OAConstant.ROOT_COMPANY_ID.equals(dept.getOaSupSubComId()) || StringUtils.isBlank(dept.getOaSupSubComId())) {
|
||||
dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
continue;
|
||||
}
|
||||
Dept parent = batchByOaId.get(dept.getOaSupSubComId());
|
||||
if (parent == null) {
|
||||
parent = existingByOaId.get(dept.getOaSupSubComId());
|
||||
}
|
||||
if (parent != null && StringUtils.isNotBlank(parent.getDeptCode())) {
|
||||
dept.setParentCode(parent.getDeptCode());
|
||||
dept.setParentId(parent.getId());
|
||||
} else {
|
||||
dept.setParentCode(null);
|
||||
dept.setParentId(null);
|
||||
}
|
||||
}
|
||||
Map<String, Long> deptIdByCode = new HashMap<>();
|
||||
existingByOaId.values().forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) {
|
||||
deptIdByCode.put(dept.getDeptCode(), dept.getId());
|
||||
}
|
||||
});
|
||||
deptIdByCode.put(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -527,80 +678,239 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
if (CollectionUtil.isEmpty(oaDepts)) {
|
||||
return OrgSyncCount.empty();
|
||||
}
|
||||
// 查询所有公司的编码和id的map
|
||||
Map<String, Long> companyDeptMap = getAllCompanyDeptMap();
|
||||
// 根公司id
|
||||
Map<String, Dept> companyByOaId = loadCompanyByOaId();
|
||||
String rootCompanyId = getRootCompanyId();
|
||||
if (!syncAll && rootCompanyId == null) {
|
||||
return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList());
|
||||
}
|
||||
// 根公司下要同步的部门名称
|
||||
Set<String> rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames();
|
||||
// 转换数据
|
||||
List<Dept> allParam = oaDepts.stream()
|
||||
// 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门
|
||||
.filter(oaDept -> syncAll
|
||||
|| rootCompanyId == null
|
||||
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|
||||
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
|
||||
.map(dept -> deptConvert.dept2dept(dept, companyDeptMap))
|
||||
.map(dept -> deptConvert.dept2dept(dept, companyByOaId))
|
||||
.toList();
|
||||
int filteredSkipCount = oaDepts.size() - allParam.size();
|
||||
if (CollectionUtil.isEmpty(allParam)) {
|
||||
return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList());
|
||||
}
|
||||
Set<String> deptCodes = new HashSet<>();
|
||||
allParam.forEach(dept -> {
|
||||
if (StringUtils.isNotEmpty(dept.getDeptCode())) {
|
||||
deptCodes.add(dept.getDeptCode());
|
||||
|
||||
Map<String, Dept> existingByOaId = loadExistingByOaId(DeptCategory.DEPT);
|
||||
Map<String, Integer> nextSerialByCompany = new HashMap<>();
|
||||
Map<String, Set<String>> usedCodesByCompany = new HashMap<>();
|
||||
// 预载各公司下已有部门编码,保证分段连续
|
||||
for (Dept existing : existingByOaId.values()) {
|
||||
if (StringUtils.isBlank(existing.getBelongCompanyCode()) || StringUtils.isBlank(existing.getDeptCode())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotEmpty(dept.getParentCode())) {
|
||||
deptCodes.add(dept.getParentCode());
|
||||
usedCodesByCompany
|
||||
.computeIfAbsent(existing.getBelongCompanyCode(), key -> new HashSet<>())
|
||||
.add(existing.getDeptCode());
|
||||
}
|
||||
Map<String, Dept> batchByOaId = new LinkedHashMap<>();
|
||||
for (Dept dept : allParam) {
|
||||
this.assignDepartmentCode(dept, existingByOaId, companyByOaId, usedCodesByCompany, nextSerialByCompany);
|
||||
batchByOaId.put(dept.getOaId(), dept);
|
||||
}
|
||||
// 解析上级:根部门挂公司;子部门挂上级部门(本批或已存在)
|
||||
Map<String, Long> deptIdByCode = new HashMap<>();
|
||||
companyByOaId.values().forEach(company -> {
|
||||
if (StringUtils.isNotBlank(company.getDeptCode()) && company.getId() != null) {
|
||||
deptIdByCode.put(company.getDeptCode(), company.getId());
|
||||
}
|
||||
});
|
||||
// 查询数据库的部门,转换成map。包含本页及上级编码,便于命中已入库的上级
|
||||
Map<String, Long> deptMap = deptCodes.isEmpty() ? new HashMap<>() : deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
.in(Dept::getDeptCode, deptCodes)
|
||||
).stream()
|
||||
.filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode()))
|
||||
.collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b));
|
||||
// 公司编码也放入 map,根部门挂到公司时才能解析父级
|
||||
companyDeptMap.forEach(deptMap::putIfAbsent);
|
||||
// 处理部门
|
||||
List<Dept> notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT);
|
||||
int syncedCount = allParam.size() - notHandledList.size();
|
||||
int skippedCount = filteredSkipCount + notHandledList.size();
|
||||
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
|
||||
existingByOaId.values().forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) {
|
||||
deptIdByCode.put(dept.getDeptCode(), dept.getId());
|
||||
}
|
||||
});
|
||||
for (Dept dept : allParam) {
|
||||
if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getParentCode())
|
||||
&& DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())
|
||||
&& dept.getBelongCompanyCode().equals(dept.getParentCode())) {
|
||||
continue;
|
||||
}
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
Dept belongCompany = companyByOaId.values().stream()
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId)
|
||||
|| StringUtils.equals(parentOaId, belongCompany.getOaId()))) {
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
dept.setParentId(belongCompany.getId());
|
||||
continue;
|
||||
}
|
||||
Dept parentDept = batchByOaId.get(parentOaId);
|
||||
if (parentDept == null) {
|
||||
parentDept = existingByOaId.get(parentOaId);
|
||||
}
|
||||
if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) {
|
||||
dept.setParentCode(parentDept.getDeptCode());
|
||||
dept.setParentId(parentDept.getId());
|
||||
} else if (belongCompany != null) {
|
||||
// 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
dept.setParentId(belongCompany.getId());
|
||||
} else {
|
||||
dept.setParentCode(null);
|
||||
dept.setParentId(null);
|
||||
}
|
||||
}
|
||||
return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.DEPT, filteredSkipCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理部门
|
||||
* @param allParam 待处理的部门
|
||||
* @param deptMap 数据库所有部门编码和id map
|
||||
* @return 未处理的数据
|
||||
* 公司编码:GWWL+三位数字;已有合规编码则复用
|
||||
*/
|
||||
private List<Dept> handleDept(List<Dept> allParam, Map<String, Long> deptMap, DeptCategory deptCategory) {
|
||||
// 数据库存在的所有部门id
|
||||
Set<Long> existsDeptIds = new HashSet<>(deptMap.values());
|
||||
private void assignCompanyCode(Dept dept, Map<String, Dept> existingByOaId, Set<String> usedCodes) {
|
||||
Dept existing = existingByOaId.get(dept.getOaId());
|
||||
if (existing != null && DeptCodeUtil.isCompanyCode(existing.getDeptCode())) {
|
||||
dept.setDeptCode(existing.getDeptCode());
|
||||
dept.setBelongCompanyCode(existing.getDeptCode());
|
||||
usedCodes.add(existing.getDeptCode());
|
||||
return;
|
||||
}
|
||||
int serial = DeptCodeUtil.nextSerial(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, usedCodes);
|
||||
String deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial);
|
||||
while (usedCodes.contains(deptCode)) {
|
||||
serial++;
|
||||
deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial);
|
||||
}
|
||||
usedCodes.add(deptCode);
|
||||
dept.setDeptCode(deptCode);
|
||||
dept.setBelongCompanyCode(deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门编码:所属公司编码+三位数字;已有合规编码则复用
|
||||
*/
|
||||
private void assignDepartmentCode(Dept dept, Map<String, Dept> existingByOaId, Map<String, Dept> companyByOaId,
|
||||
Map<String, Set<String>> usedCodesByCompany, Map<String, Integer> nextSerialByCompany) {
|
||||
Dept existing = existingByOaId.get(dept.getOaId());
|
||||
Dept belongCompany = null;
|
||||
if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
belongCompany = companyByOaId.values().stream()
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())
|
||||
|| StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
if (belongCompany == null && existing != null && StringUtils.isNotBlank(existing.getBelongCompanyCode())) {
|
||||
String belongCode = existing.getBelongCompanyCode();
|
||||
belongCompany = companyByOaId.values().stream()
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), belongCode))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
if (belongCompany == null) {
|
||||
return;
|
||||
}
|
||||
final Dept resolvedCompany = belongCompany;
|
||||
final String companyCode = resolvedCompany.getDeptCode();
|
||||
dept.setBelongCompanyCode(companyCode);
|
||||
if (existing != null && DeptCodeUtil.matchesChildCode(companyCode, existing.getDeptCode())) {
|
||||
dept.setDeptCode(existing.getDeptCode());
|
||||
usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()).add(existing.getDeptCode());
|
||||
return;
|
||||
}
|
||||
Set<String> usedCodes = usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>());
|
||||
int serial = nextSerialByCompany.computeIfAbsent(companyCode,
|
||||
key -> DeptCodeUtil.nextSerial(companyCode, usedCodes));
|
||||
String deptCode = DeptCodeUtil.formatChildCode(companyCode, serial);
|
||||
while (usedCodes.contains(deptCode)) {
|
||||
serial++;
|
||||
deptCode = DeptCodeUtil.formatChildCode(companyCode, serial);
|
||||
}
|
||||
nextSerialByCompany.put(companyCode, serial + 1);
|
||||
usedCodes.add(deptCode);
|
||||
dept.setDeptCode(deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 oaId 加载已存在组织
|
||||
*/
|
||||
private Map<String, Dept> loadExistingByOaId(DeptCategory deptCategory) {
|
||||
List<Dept> deptList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, deptCategory.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, "")
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId,
|
||||
Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId));
|
||||
Map<String, Dept> byOaId = new HashMap<>();
|
||||
for (Dept dept : deptList) {
|
||||
if (StringUtils.isNotBlank(dept.getOaId())) {
|
||||
byOaId.put(dept.getOaId(), dept);
|
||||
}
|
||||
}
|
||||
return byOaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已占用的公司编码(GWWLxxx)
|
||||
*/
|
||||
private Map<String, Dept> loadCompanyCodeOwnerMap() {
|
||||
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.likeRight(Dept::getDeptCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getOaId));
|
||||
Map<String, Dept> codeMap = new HashMap<>();
|
||||
for (Dept dept : companyList) {
|
||||
if (DeptCodeUtil.isCompanyCode(dept.getDeptCode())) {
|
||||
codeMap.put(dept.getDeptCode(), dept);
|
||||
}
|
||||
}
|
||||
return codeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已同步公司:oaId → 公司
|
||||
*/
|
||||
private Map<String, Dept> loadCompanyByOaId() {
|
||||
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, ""));
|
||||
Map<String, Dept> byOaId = new HashMap<>();
|
||||
for (Dept dept : companyList) {
|
||||
if (StringUtils.isNotBlank(dept.getOaId())) {
|
||||
byOaId.put(dept.getOaId(), dept);
|
||||
}
|
||||
}
|
||||
return byOaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化同步数据:按 oaId 匹配;不存在则新增;存在且 oaModified 不一致才修改
|
||||
*/
|
||||
private OrgSyncCount persistSyncedDepts(List<Dept> allParam, Map<String, Dept> existingByOaId,
|
||||
Map<String, Long> deptIdByCode, DeptCategory deptCategory,
|
||||
int filteredSkipCount) {
|
||||
Set<Long> existsDeptIds = existingByOaId.values().stream()
|
||||
.map(Dept::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
allParam.forEach(dept -> {
|
||||
if (deptMap.containsKey(dept.getDeptCode())) {
|
||||
// 根据部门编码获取对应的部门id
|
||||
dept.setId(deptMap.get(dept.getDeptCode()));
|
||||
Dept existing = existingByOaId.get(dept.getOaId());
|
||||
if (existing != null && existing.getId() != null) {
|
||||
dept.setId(existing.getId());
|
||||
} else if (StringUtils.isNotBlank(dept.getDeptCode()) && deptIdByCode.containsKey(dept.getDeptCode())) {
|
||||
dept.setId(deptIdByCode.get(dept.getDeptCode()));
|
||||
} else {
|
||||
// 没有就生成一个id
|
||||
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
deptMap.put(dept.getDeptCode(), dept.getId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode())) {
|
||||
deptIdByCode.put(dept.getDeptCode(), dept.getId());
|
||||
}
|
||||
});
|
||||
|
||||
// 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据
|
||||
int unresolvedParentCount = 0;
|
||||
for (Dept dept : allParam) {
|
||||
if (dept.getParentId() != null) {
|
||||
continue;
|
||||
}
|
||||
Long parentId = deptMap.get(dept.getParentCode());
|
||||
Long parentId = StringUtils.isNotBlank(dept.getParentCode()) ? deptIdByCode.get(dept.getParentCode()) : null;
|
||||
if (parentId != null) {
|
||||
dept.setParentId(parentId);
|
||||
continue;
|
||||
@@ -615,11 +925,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
deptCategory.getName(), unresolvedParentCount);
|
||||
}
|
||||
|
||||
// 父id不为空才新增;已存在的记录即使父id未解析也更新(空父id不会覆盖原值)
|
||||
List<Dept> list = allParam.stream()
|
||||
.filter(dept -> StringUtils.isNotBlank(dept.getDeptCode()))
|
||||
.filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId()))
|
||||
.toList();
|
||||
// 不存在的新增
|
||||
List<Dept> addList = list.stream()
|
||||
.filter(dept -> !existsDeptIds.contains(dept.getId()))
|
||||
.toList();
|
||||
@@ -627,18 +936,48 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size());
|
||||
deptService.saveBatch(addList);
|
||||
}
|
||||
// 存在的修改
|
||||
List<Dept> updateList = list.stream()
|
||||
.filter(dept -> existsDeptIds.contains(dept.getId()))
|
||||
.filter(dept -> !shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept))
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(updateList)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size());
|
||||
deptService.updateBatchById(updateList);
|
||||
}
|
||||
// 返回仍未保存的数据
|
||||
return allParam.stream()
|
||||
.filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))
|
||||
int unchangedCount = (int) list.stream()
|
||||
.filter(dept -> existsDeptIds.contains(dept.getId()))
|
||||
.filter(dept -> shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept))
|
||||
.count();
|
||||
List<Dept> notHandledList = allParam.stream()
|
||||
.filter(dept -> StringUtils.isBlank(dept.getDeptCode())
|
||||
|| (dept.getParentId() == null && !existsDeptIds.contains(dept.getId())))
|
||||
.toList();
|
||||
int syncedCount = addList.size() + updateList.size();
|
||||
int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size();
|
||||
if (unchangedCount > 0) {
|
||||
ComposeLogUtil.getLastLog().info("跳过未变更{}:{}", deptCategory.getName(), unchangedCount);
|
||||
}
|
||||
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
|
||||
}
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
|
||||
* 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
|
||||
if (existing == null) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && StringUtils.isNotBlank(incoming.getDeptCode())
|
||||
&& !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) {
|
||||
return false;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return localModified.equals(remoteModified);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -683,6 +1022,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
if (syncAll) {
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, "")
|
||||
);
|
||||
} else {
|
||||
Set<String> companyNames = this.getCompanyNames();
|
||||
@@ -692,6 +1033,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getDeptName, companyNames)
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, "")
|
||||
);
|
||||
}
|
||||
return companyList.stream()
|
||||
@@ -792,13 +1135,20 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
// 查询父部门编码是根公司的,部门类型的,根公司下部门名称的部门
|
||||
Dept rootCompany = deptService.getOne(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getOaId, rootCompanyId)
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.last("limit 1"), false);
|
||||
if (rootCompany == null || StringUtils.isBlank(rootCompany.getDeptCode())) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getDeptName, rootCompanyDeptNames)
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
.eq(Dept::getParentCode, OAConvertConstant.COMPANY_OA_PREFIX + rootCompanyId)
|
||||
.eq(Dept::getParentCode, rootCompany.getDeptCode())
|
||||
).stream()
|
||||
.filter(dept -> StringUtils.isNotBlank(dept.getDeptCode()))
|
||||
.map(dept -> dept.getDeptCode().replace(OAConvertConstant.DEPARTMENT_OA_PREFIX, ""))
|
||||
.map(Dept::getOaId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@@ -821,11 +1171,11 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.getLastLog().warn("未查询到根公司 名称:{}", companyName);
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isBlank(dept.getDeptCode())) {
|
||||
if (StringUtils.isBlank(dept.getOaId()) && StringUtils.isBlank(dept.getDeptCode())) {
|
||||
ComposeLogUtil.getLastLog().warn("根公司编码为空:{}", companyName);
|
||||
return null;
|
||||
}
|
||||
// 部门编码去掉前缀,就是oa的id
|
||||
return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
|
||||
return StringUtils.isNotBlank(dept.getOaId()) ? dept.getOaId()
|
||||
: dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
|
||||
}
|
||||
}
|
||||
|
||||
+36
-30
@@ -86,7 +86,7 @@ public class OaUserListSyncHelper {
|
||||
private final IRoleService roleService;
|
||||
|
||||
/**
|
||||
* 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下
|
||||
* 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 组织索引
|
||||
@@ -249,6 +249,7 @@ public class OaUserListSyncHelper {
|
||||
});
|
||||
Map<String, Long> userMap = new HashMap<>();
|
||||
Set<Long> existsUserIds = new HashSet<>();
|
||||
Set<Long> unchangedUserIds = new HashSet<>();
|
||||
users.forEach(user -> {
|
||||
User existingUser = existingByAccount.get(user.getAccount());
|
||||
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
|
||||
@@ -258,6 +259,9 @@ public class OaUserListSyncHelper {
|
||||
user.setId(existingUser.getId());
|
||||
user.setPassword(null);
|
||||
existsUserIds.add(existingUser.getId());
|
||||
if (shouldSkipOaUpdate(existingUser, user)) {
|
||||
unchangedUserIds.add(existingUser.getId());
|
||||
}
|
||||
} else {
|
||||
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
user.setPostId("-1");
|
||||
@@ -279,12 +283,18 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
List<User> updateUsers = users.stream()
|
||||
.filter(user -> existsUserIds.contains(user.getId()))
|
||||
.filter(user -> !unchangedUserIds.contains(user.getId()))
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(updateUsers)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
|
||||
userService.updateBatchById(updateUsers);
|
||||
}
|
||||
if (!unchangedUserIds.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size());
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
|
||||
int syncedCount = addUsers.size() + updateUsers.size();
|
||||
skippedCount += unchangedUserIds.size();
|
||||
if (userMap.isEmpty()) {
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
}
|
||||
@@ -295,7 +305,13 @@ public class OaUserListSyncHelper {
|
||||
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second));
|
||||
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
|
||||
List<UserDept> userDeptList = oaPersons.stream()
|
||||
.filter(oaPerson -> userMap.containsKey(userConvert.resolveAccount(oaPerson)))
|
||||
.filter(oaPerson -> {
|
||||
String account = userConvert.resolveAccount(oaPerson);
|
||||
if (!userMap.containsKey(account)) {
|
||||
return false;
|
||||
}
|
||||
return !unchangedUserIds.contains(userMap.get(account));
|
||||
})
|
||||
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
@@ -333,6 +349,7 @@ public class OaUserListSyncHelper {
|
||||
.collect(Collectors.toSet());
|
||||
String defaultRoleId = getDefaultRoleId();
|
||||
List<User> updateUserParams = list.stream()
|
||||
.filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId()))
|
||||
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH)
|
||||
.map(userDeptIds -> {
|
||||
User user = new User();
|
||||
@@ -347,37 +364,26 @@ public class OaUserListSyncHelper {
|
||||
if (CollectionUtil.isNotEmpty(updateUserParams)) {
|
||||
userService.updateBatchById(updateUserParams);
|
||||
}
|
||||
return new PersonSyncCount(users.size(), skippedCount);
|
||||
return new PersonSyncCount(syncedCount, skippedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(User existing, User incoming) {
|
||||
if (existing == null) {
|
||||
return false;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return localModified.equals(remoteModified);
|
||||
}
|
||||
|
||||
private Dept getOrCreateRootCompany() {
|
||||
Dept rootCompany = deptService.getOne(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptName, OAConvertConstant.ROOT_COMPANY_NAME)
|
||||
.last("limit 1"), false);
|
||||
if (rootCompany != null) {
|
||||
return rootCompany;
|
||||
}
|
||||
rootCompany = new Dept();
|
||||
rootCompany.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
rootCompany.setTenantId(resolveTenantId());
|
||||
rootCompany.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
rootCompany.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootCompany.setDeptName(OAConvertConstant.ROOT_COMPANY_NAME);
|
||||
rootCompany.setFullName(OAConvertConstant.ROOT_COMPANY_NAME);
|
||||
rootCompany.setShortName(OAConvertConstant.ROOT_COMPANY_NAME);
|
||||
rootCompany.setDeptCode("OACROOT");
|
||||
rootCompany.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootCompany.setBelongCompanyCode("OACROOT");
|
||||
rootCompany.setDeptCategory(DeptCategory.COMPANY.getCode());
|
||||
rootCompany.setSort(0);
|
||||
rootCompany.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
rootCompany.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
rootCompany.setIsOa(YES);
|
||||
rootCompany.setIsPlatformCompany(0);
|
||||
rootCompany.setSyncTime(new Date());
|
||||
deptService.save(rootCompany);
|
||||
ComposeLogUtil.getLastLog().info("已创建顶级组织:{}", OAConvertConstant.ROOT_COMPANY_NAME);
|
||||
return rootCompany;
|
||||
return deptService.ensureOaSyncRootDept();
|
||||
}
|
||||
|
||||
private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId,
|
||||
|
||||
+195
-52
@@ -29,7 +29,8 @@ 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.toolkit.Wrappers;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
@@ -44,6 +45,7 @@ import org.springblade.system.pojo.entity.Region;
|
||||
import org.springblade.system.pojo.vo.PortTerminalVO;
|
||||
import org.springblade.system.service.IPortTerminalService;
|
||||
import org.springblade.system.service.IRegionService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -66,8 +68,9 @@ import java.util.regex.Pattern;
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper, PortTerminal> implements IPortTerminalService {
|
||||
|
||||
private static final String CATEGORY_PORT = "港口";
|
||||
@@ -76,8 +79,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
private static final String SOURCE_INITIAL_OLD = "初始导入";
|
||||
private static final String SOURCE_BATCH = "批量导入";
|
||||
private static final String SOURCE_MANUAL = "手工导入";
|
||||
private static final String SOURCE_MANUAL_OLD = "手动录入";
|
||||
private static final int STATUS_ENABLED = 1;
|
||||
private static final int STATUS_DISABLED = 2;
|
||||
private static final int PROVINCE_REGION_LEVEL = 1;
|
||||
private static final int CITY_REGION_LEVEL = 2;
|
||||
private static final int DISTRICT_REGION_LEVEL = 3;
|
||||
private static final int CODE_MAX_LENGTH = 30;
|
||||
private static final int NAME_MAX_LENGTH = 100;
|
||||
private static final int REGION_MAX_LENGTH = 50;
|
||||
@@ -102,6 +109,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submit(PortTerminal portTerminal) {
|
||||
prepare(portTerminal, SOURCE_MANUAL);
|
||||
prepareSubmitTarget(portTerminal);
|
||||
validate(portTerminal);
|
||||
return saveOrUpdate(portTerminal);
|
||||
}
|
||||
@@ -206,7 +214,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
? PORT_CODE_PATTERN.matcher(code).matches()
|
||||
: TERMINAL_CODE_PATTERN.matcher(code).matches();
|
||||
if (!codeValid) {
|
||||
String message = CATEGORY_PORT.equals(category) ? "港口编码为5位大写字母" : "码头编码格式为港口编码-码头标识";
|
||||
String message = CATEGORY_PORT.equals(category)
|
||||
? "港口编码为5位大写字母,当前为「" + code + "」"
|
||||
: "码头编码格式为港口编码-码头标识,当前为「" + code + "」";
|
||||
failureList.add(buildFailure(data, index, message));
|
||||
continue;
|
||||
}
|
||||
@@ -239,8 +249,11 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
portTerminal.setDataSource(SOURCE_BATCH);
|
||||
portTerminal.setStatus(STATUS_ENABLED);
|
||||
prepare(portTerminal, SOURCE_BATCH);
|
||||
prepareSubmitTarget(portTerminal);
|
||||
validate(portTerminal);
|
||||
save(portTerminal);
|
||||
if (!saveOrUpdate(portTerminal)) {
|
||||
throw new ServiceException("港口码头保存失败");
|
||||
}
|
||||
batchPortMap.put(portTerminal.getCode(), portTerminal);
|
||||
} catch (Exception exception) {
|
||||
failureList.add(buildFailure(data, index, resolveMessage(exception)));
|
||||
@@ -265,8 +278,11 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
portTerminal.setDataSource(SOURCE_BATCH);
|
||||
portTerminal.setStatus(STATUS_ENABLED);
|
||||
prepareTerminal(portTerminal, batchPortMap);
|
||||
prepareSubmitTarget(portTerminal);
|
||||
validate(portTerminal);
|
||||
save(portTerminal);
|
||||
if (!saveOrUpdate(portTerminal)) {
|
||||
throw new ServiceException("港口码头保存失败");
|
||||
}
|
||||
} catch (ParentPortNotFoundException exception) {
|
||||
// 上级港口就声明在本文件里,只是那一行自己校验失败(已被标红)。
|
||||
// 此时码头行本身没有问题,不再连带报错,避免用户看到"两行都错"的假象。
|
||||
@@ -308,6 +324,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
if (Func.isNotEmpty(parent.getCountry())) {
|
||||
portTerminal.setCountry(parent.getCountry());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getProvinceCode())) {
|
||||
portTerminal.setProvinceCode(parent.getProvinceCode());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getProvinceName())) {
|
||||
portTerminal.setProvinceName(parent.getProvinceName());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getCity())) {
|
||||
portTerminal.setCity(parent.getCity());
|
||||
}
|
||||
@@ -341,10 +363,59 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析异常信息,非业务异常统一提示导入失败。
|
||||
* 解析异常信息:优先取业务异常,其次解包数据访问异常,避免失败明细只剩「导入失败」。
|
||||
*/
|
||||
private String resolveMessage(Exception exception) {
|
||||
return exception instanceof ServiceException ? exception.getMessage() : "导入失败";
|
||||
ServiceException serviceException = findCause(exception, ServiceException.class);
|
||||
if (serviceException != null && Func.isNotEmpty(serviceException.getMessage())
|
||||
&& !(serviceException instanceof ImportFailureException)) {
|
||||
return serviceException.getMessage();
|
||||
}
|
||||
DataAccessException dataAccessException = findCause(exception, DataAccessException.class);
|
||||
if (dataAccessException != null) {
|
||||
Throwable root = rootCause(dataAccessException);
|
||||
String rootMessage = root.getMessage();
|
||||
log.warn("港口码头导入数据访问异常", exception);
|
||||
if (Func.isNotEmpty(rootMessage)) {
|
||||
if (rootMessage.contains("Duplicate") || rootMessage.contains("duplicate") || rootMessage.contains("uk_port_terminal_code")) {
|
||||
return "该编码已存在";
|
||||
}
|
||||
return "数据保存失败:" + shortenMessage(rootMessage);
|
||||
}
|
||||
return "数据保存失败";
|
||||
}
|
||||
Throwable root = rootCause(exception);
|
||||
String rootMessage = root.getMessage();
|
||||
log.warn("港口码头导入非业务异常, type={}", root.getClass().getName(), exception);
|
||||
if (Func.isNotEmpty(rootMessage)) {
|
||||
return shortenMessage(rootMessage);
|
||||
}
|
||||
return "导入失败:" + root.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private <T extends Throwable> T findCause(Throwable throwable, Class<T> type) {
|
||||
for (Throwable current = throwable; current != null; current = current.getCause()) {
|
||||
if (type.isInstance(current)) {
|
||||
return type.cast(current);
|
||||
}
|
||||
if (current.getCause() == current) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Throwable rootCause(Throwable throwable) {
|
||||
Throwable root = throwable;
|
||||
while (root.getCause() != null && root.getCause() != root) {
|
||||
root = root.getCause();
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private String shortenMessage(String message) {
|
||||
String value = trimToEmpty(message);
|
||||
return value.length() > 200 ? value.substring(0, 200) + "..." : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -370,6 +441,26 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
return validRange(value, min, max) ? value.setScale(6, RoundingMode.HALF_UP) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
|
||||
* 编码存在且未删除时仍报「该编码已存在」。
|
||||
*/
|
||||
private void prepareSubmitTarget(PortTerminal portTerminal) {
|
||||
if (Func.isNotEmpty(portTerminal.getId())) {
|
||||
return;
|
||||
}
|
||||
PortTerminal existingPortTerminal = baseMapper.selectByCodeIncludingDeleted(portTerminal.getCode());
|
||||
if (existingPortTerminal == null) {
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(existingPortTerminal.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("该编码已存在");
|
||||
}
|
||||
baseMapper.restoreById(existingPortTerminal.getId());
|
||||
portTerminal.setId(existingPortTerminal.getId());
|
||||
portTerminal.setIsDeleted(0);
|
||||
}
|
||||
|
||||
private void prepare(PortTerminal portTerminal, String defaultDataSource) {
|
||||
prepare(portTerminal, defaultDataSource, false);
|
||||
}
|
||||
@@ -386,6 +477,8 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
portTerminal.setCategory(trimToEmpty(portTerminal.getCategory()));
|
||||
portTerminal.setName(trimToEmpty(portTerminal.getName()));
|
||||
portTerminal.setCountry(trimToEmpty(portTerminal.getCountry()));
|
||||
portTerminal.setProvinceCode(trimToNull(portTerminal.getProvinceCode()));
|
||||
portTerminal.setProvinceName(trimToNull(portTerminal.getProvinceName()));
|
||||
portTerminal.setCity(trimToEmpty(portTerminal.getCity()));
|
||||
portTerminal.setDistrictCode(trimToNull(portTerminal.getDistrictCode()));
|
||||
portTerminal.setDistrictName(trimToNull(portTerminal.getDistrictName()));
|
||||
@@ -431,68 +524,99 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
if (!CATEGORY_PORT.equals(parent.getCategory())) {
|
||||
throw new ServiceException("上级港口类型不正确");
|
||||
}
|
||||
portTerminal.setParentId(parent.getId());
|
||||
portTerminal.setParentCode(parent.getCode());
|
||||
portTerminal.setParentName(parent.getName());
|
||||
// 父港口区域信息为空时保留码头自身填写的值,避免历史数据(区县为空)导致码头无法导入。
|
||||
if (Func.isNotEmpty(parent.getCountry())) {
|
||||
portTerminal.setCountry(parent.getCountry());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getCity())) {
|
||||
portTerminal.setCity(parent.getCity());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getDistrictCode())) {
|
||||
portTerminal.setDistrictCode(parent.getDistrictCode());
|
||||
portTerminal.setRegionCode(parent.getDistrictCode());
|
||||
} else if (Func.isNotEmpty(portTerminal.getRegionCode())) {
|
||||
applyParent(portTerminal, parent);
|
||||
if (Func.isEmpty(portTerminal.getDistrictCode()) && Func.isNotEmpty(portTerminal.getRegionCode())) {
|
||||
portTerminal.setDistrictCode(portTerminal.getRegionCode());
|
||||
}
|
||||
if (Func.isNotEmpty(parent.getDistrictName())) {
|
||||
portTerminal.setDistrictName(parent.getDistrictName());
|
||||
}
|
||||
}
|
||||
|
||||
private void fillRegion(PortTerminal portTerminal) {
|
||||
Region province = null;
|
||||
if (Func.isNotEmpty(portTerminal.getProvinceCode())) {
|
||||
province = regionService.getById(portTerminal.getProvinceCode());
|
||||
}
|
||||
if (Func.isEmpty(province) && Func.isNotEmpty(portTerminal.getProvinceName())) {
|
||||
province = findRegionByName(portTerminal.getProvinceName(), PROVINCE_REGION_LEVEL, null);
|
||||
}
|
||||
if (Func.isEmpty(province)) {
|
||||
throw new ServiceException("请选择所属省份");
|
||||
}
|
||||
|
||||
Region city = null;
|
||||
if (Func.isNotEmpty(portTerminal.getCity())) {
|
||||
city = findRegionByName(portTerminal.getCity(), CITY_REGION_LEVEL, province.getCode());
|
||||
}
|
||||
if (Func.isEmpty(city)) {
|
||||
throw new ServiceException("请选择城市");
|
||||
}
|
||||
if (!Objects.equals(city.getParentCode(), province.getCode())) {
|
||||
throw new ServiceException("所属城市与所属省份不匹配");
|
||||
}
|
||||
|
||||
Region district = null;
|
||||
if (Func.isNotEmpty(portTerminal.getDistrictCode())) {
|
||||
district = regionService.getById(portTerminal.getDistrictCode());
|
||||
}
|
||||
if (Func.isEmpty(district) && Func.isNotEmpty(portTerminal.getDistrictName())) {
|
||||
if (Func.isNotEmpty(portTerminal.getCity())) {
|
||||
List<Region> cityList = regionService.list(Wrappers.<Region>lambdaQuery()
|
||||
.eq(Region::getName, portTerminal.getCity())
|
||||
.eq(Region::getRegionLevel, 2));
|
||||
for (Region city : cityList) {
|
||||
district = regionService.getOne(Wrappers.<Region>lambdaQuery()
|
||||
.eq(Region::getParentCode, city.getCode())
|
||||
.eq(Region::getName, portTerminal.getDistrictName()), false);
|
||||
if (Func.isNotEmpty(district)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Func.isEmpty(district)) {
|
||||
district = regionService.getOne(Wrappers.<Region>lambdaQuery()
|
||||
.eq(Region::getName, portTerminal.getDistrictName())
|
||||
.eq(Region::getRegionLevel, 3), false);
|
||||
}
|
||||
district = findRegionByName(portTerminal.getDistrictName(), DISTRICT_REGION_LEVEL, city.getCode());
|
||||
}
|
||||
if (Func.isEmpty(district)) {
|
||||
throw new ServiceException("请选择区县");
|
||||
}
|
||||
Region city = regionService.getById(district.getParentCode());
|
||||
if (Func.isEmpty(city)) {
|
||||
throw new ServiceException("区县所属城市不存在");
|
||||
}
|
||||
if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) {
|
||||
throw new ServiceException("区县与城市不匹配");
|
||||
if (!Objects.equals(district.getParentCode(), city.getCode())) {
|
||||
throw new ServiceException("所属区县与所属城市不匹配");
|
||||
}
|
||||
|
||||
portTerminal.setProvinceCode(province.getCode());
|
||||
portTerminal.setProvinceName(province.getName());
|
||||
portTerminal.setCity(city.getName());
|
||||
portTerminal.setDistrictCode(district.getCode());
|
||||
portTerminal.setDistrictName(district.getName());
|
||||
portTerminal.setRegionCode(district.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称查找区划,兼容「福建/福建省」「泉州/泉州市」等简写。
|
||||
*/
|
||||
private Region findRegionByName(String name, int regionLevel, String parentCode) {
|
||||
String normalized = trimToEmpty(name);
|
||||
if (Func.isEmpty(normalized)) {
|
||||
return null;
|
||||
}
|
||||
for (String candidate : regionNameCandidates(normalized)) {
|
||||
LambdaQueryWrapper<Region> queryWrapper = Wrappers.<Region>lambdaQuery()
|
||||
.eq(Region::getRegionLevel, regionLevel)
|
||||
.eq(Region::getName, candidate);
|
||||
if (Func.isNotEmpty(parentCode)) {
|
||||
queryWrapper.eq(Region::getParentCode, parentCode);
|
||||
}
|
||||
Region region = regionService.getOne(queryWrapper, false);
|
||||
if (Func.isNotEmpty(region)) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> regionNameCandidates(String name) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
candidates.add(name);
|
||||
if (name.endsWith("省") || name.endsWith("市") || name.endsWith("区") || name.endsWith("县") || name.endsWith("州")) {
|
||||
String shortName = name.substring(0, name.length() - 1);
|
||||
if (Func.isNotEmpty(shortName) && !candidates.contains(shortName)) {
|
||||
candidates.add(shortName);
|
||||
}
|
||||
} else {
|
||||
for (String suffix : List.of("省", "市", "区", "县", "州")) {
|
||||
String fullName = name + suffix;
|
||||
if (!candidates.contains(fullName)) {
|
||||
candidates.add(fullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private void validate(PortTerminal portTerminal) {
|
||||
if (!CATEGORY_PORT.equals(portTerminal.getCategory()) && !CATEGORY_TERMINAL.equals(portTerminal.getCategory())) {
|
||||
throw new ServiceException("类型只能为港口或码头");
|
||||
@@ -506,16 +630,17 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
validateLength(portTerminal.getCode(), CODE_MAX_LENGTH, "编码不能超过30字");
|
||||
validateLength(portTerminal.getName(), NAME_MAX_LENGTH, "港口/码头名称不能超过100字");
|
||||
validateLength(portTerminal.getCountry(), REGION_MAX_LENGTH, "国家不能超过50字");
|
||||
validateLength(portTerminal.getProvinceName(), REGION_MAX_LENGTH, "所属省份不能超过50字");
|
||||
validateLength(portTerminal.getCity(), REGION_MAX_LENGTH, "城市不能超过50字");
|
||||
validateLength(portTerminal.getDistrictName(), REGION_MAX_LENGTH, "区县不能超过50字");
|
||||
validateLength(portTerminal.getRegionCode(), REGION_CODE_MAX_LENGTH, "行政区划编码不能超过32字");
|
||||
validateLength(portTerminal.getDetailAddress(), DETAIL_ADDRESS_MAX_LENGTH, "详细地址不能超过255字");
|
||||
validateLength(portTerminal.getRemark(), REMARK_MAX_LENGTH, "备注不能超过200个字");
|
||||
if (CATEGORY_PORT.equals(portTerminal.getCategory()) && !PORT_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
|
||||
throw new ServiceException("港口编码为5位大写字母");
|
||||
throw new ServiceException("港口编码为5位大写字母,当前为「" + portTerminal.getCode() + "」");
|
||||
}
|
||||
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory()) && !TERMINAL_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
|
||||
throw new ServiceException("码头编码格式为港口编码-码头标识");
|
||||
throw new ServiceException("码头编码格式为港口编码-码头标识,当前为「" + portTerminal.getCode() + "」");
|
||||
}
|
||||
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory()) && !portTerminal.getCode().startsWith(portTerminal.getParentCode() + "-")) {
|
||||
throw new ServiceException("码头编码必须以所属港口编码开头");
|
||||
@@ -523,12 +648,24 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
if (Func.isEmpty(portTerminal.getCountry())) {
|
||||
throw new ServiceException("国家不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getProvinceCode()) && Func.isEmpty(portTerminal.getProvinceName())) {
|
||||
throw new ServiceException("所属省份不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getCity())) {
|
||||
throw new ServiceException("城市不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getDistrictCode())) {
|
||||
throw new ServiceException("区县不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getDetailAddress())) {
|
||||
throw new ServiceException("详细地址不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getLongitude())) {
|
||||
throw new ServiceException("经度不能为空");
|
||||
}
|
||||
if (Func.isEmpty(portTerminal.getLatitude())) {
|
||||
throw new ServiceException("纬度不能为空");
|
||||
}
|
||||
validateDataSource(portTerminal.getDataSource());
|
||||
validateStatus(portTerminal.getStatus());
|
||||
validateRange(portTerminal.getLongitude(), MIN_LONGITUDE, MAX_LONGITUDE, "经度范围为 -180 到 180");
|
||||
@@ -593,7 +730,13 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
|
||||
private String normalizeDataSource(String dataSource) {
|
||||
String value = trimToEmpty(dataSource);
|
||||
return SOURCE_INITIAL_OLD.equals(value) ? SOURCE_INITIAL : value;
|
||||
if (SOURCE_INITIAL_OLD.equals(value)) {
|
||||
return SOURCE_INITIAL;
|
||||
}
|
||||
if (SOURCE_MANUAL_OLD.equals(value)) {
|
||||
return SOURCE_MANUAL;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void validateEnabledTerminal(Long parentId) {
|
||||
@@ -611,7 +754,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
return value == null ? "" : value.replace("\uFEFF", "").strip();
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
|
||||
+24
-12
@@ -102,6 +102,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submit(RailwayStation railwayStation) {
|
||||
prepare(railwayStation, SOURCE_MANUAL);
|
||||
prepareSubmitTarget(railwayStation);
|
||||
validate(railwayStation);
|
||||
return saveOrUpdate(railwayStation);
|
||||
}
|
||||
@@ -170,7 +171,8 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
throw new org.springblade.common.excel.ImportFailureException(data);
|
||||
}
|
||||
for (RailwayStation railwayStation : railwayStationList) {
|
||||
prepareImportTarget(railwayStation);
|
||||
// 编码/TMIS 命中逻辑删除记录时复用原主键,恢复后更新,避免唯一索引冲突。
|
||||
prepareSubmitTarget(railwayStation);
|
||||
if (!saveOrUpdate(railwayStation)) {
|
||||
throw new ServiceException("铁路车站保存失败");
|
||||
}
|
||||
@@ -178,11 +180,18 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
return errorList;
|
||||
}
|
||||
|
||||
private void prepareImportTarget(RailwayStation railwayStation) {
|
||||
/**
|
||||
* 编码或 TMIS 已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
|
||||
* 存在且未删除时仍报「该编码已存在」。
|
||||
*/
|
||||
private void prepareSubmitTarget(RailwayStation railwayStation) {
|
||||
if (Func.isNotEmpty(railwayStation.getId())) {
|
||||
return;
|
||||
}
|
||||
RailwayStation existingRailwayStation = baseMapper.selectByCodeIncludingDeleted(railwayStation.getCode());
|
||||
if (existingRailwayStation == null && Func.isNotEmpty(railwayStation.getTmisCode())) {
|
||||
existingRailwayStation = baseMapper.selectByTmisCodeIncludingDeleted(railwayStation.getTmisCode());
|
||||
}
|
||||
if (existingRailwayStation == null) {
|
||||
return;
|
||||
}
|
||||
@@ -207,7 +216,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
private void normalizeImportRailwayStation(RailwayStation railwayStation) {
|
||||
railwayStation.setTmisCode(trimToEmpty(railwayStation.getTmisCode()));
|
||||
railwayStation.setCode(CODE_PREFIX + railwayStation.getTmisCode());
|
||||
railwayStation.setTelegraphCode(trimToEmpty(railwayStation.getTelegraphCode()).toUpperCase(Locale.ROOT));
|
||||
railwayStation.setTelegraphCode(normalizeTelegraphCode(railwayStation.getTelegraphCode()));
|
||||
railwayStation.setName(trimToEmpty(railwayStation.getName()));
|
||||
railwayStation.setProvinceCode(trimToNull(railwayStation.getProvinceCode()));
|
||||
railwayStation.setProvinceName(trimToNull(railwayStation.getProvinceName()));
|
||||
@@ -247,9 +256,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
validateImportUnique(RailwayStation::getTmisCode, railwayStation.getTmisCode(), "该TMIS国标编码已存在", validationErrors);
|
||||
validateImportUnique(RailwayStation::getCode, railwayStation.getCode(), "该编码已存在", validationErrors);
|
||||
}
|
||||
if (Func.isEmpty(railwayStation.getTelegraphCode())) {
|
||||
addValidationError(validationErrors, "电报略码不能为空");
|
||||
} else {
|
||||
if (Func.isNotEmpty(railwayStation.getTelegraphCode())) {
|
||||
if (!TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
|
||||
addValidationError(validationErrors, "电报略码为3位大写字母");
|
||||
}
|
||||
@@ -377,7 +384,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
private void prepare(RailwayStation railwayStation, String defaultDataSource) {
|
||||
railwayStation.setTmisCode(trimToEmpty(railwayStation.getTmisCode()));
|
||||
railwayStation.setCode(CODE_PREFIX + railwayStation.getTmisCode());
|
||||
railwayStation.setTelegraphCode(trimToEmpty(railwayStation.getTelegraphCode()).toUpperCase(Locale.ROOT));
|
||||
railwayStation.setTelegraphCode(normalizeTelegraphCode(railwayStation.getTelegraphCode()));
|
||||
railwayStation.setName(trimToEmpty(railwayStation.getName()));
|
||||
railwayStation.setRailwayLine(trimToNull(railwayStation.getRailwayLine()));
|
||||
railwayStation.setProvinceCode(trimToNull(railwayStation.getProvinceCode()));
|
||||
@@ -410,10 +417,8 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
if (!TMIS_CODE_PATTERN.matcher(railwayStation.getTmisCode()).matches()) {
|
||||
throw new ServiceException("TMIS国标编码为5位数字");
|
||||
}
|
||||
if (Func.isEmpty(railwayStation.getTelegraphCode())) {
|
||||
throw new ServiceException("电报略码格式不正确或已存在");
|
||||
}
|
||||
if (!TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
|
||||
if (Func.isNotEmpty(railwayStation.getTelegraphCode())
|
||||
&& !TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
|
||||
throw new ServiceException("电报略码格式不正确或已存在");
|
||||
}
|
||||
if (Func.isEmpty(railwayStation.getName())) {
|
||||
@@ -446,7 +451,9 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
validateDataSource(railwayStation.getDataSource());
|
||||
validateStatus(railwayStation.getStatus());
|
||||
validateUnique(railwayStation, RailwayStation::getTmisCode, railwayStation.getTmisCode(), "该TMIS编码已存在");
|
||||
validateUnique(railwayStation, RailwayStation::getTelegraphCode, railwayStation.getTelegraphCode(), "电报略码格式不正确或已存在");
|
||||
if (Func.isNotEmpty(railwayStation.getTelegraphCode())) {
|
||||
validateUnique(railwayStation, RailwayStation::getTelegraphCode, railwayStation.getTelegraphCode(), "电报略码格式不正确或已存在");
|
||||
}
|
||||
validateUnique(railwayStation, RailwayStation::getCode, railwayStation.getCode(), "该编码已存在");
|
||||
}
|
||||
|
||||
@@ -562,6 +569,11 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
|
||||
return trimValue.isEmpty() ? null : trimValue;
|
||||
}
|
||||
|
||||
private String normalizeTelegraphCode(String value) {
|
||||
String telegraphCode = trimToNull(value);
|
||||
return telegraphCode == null ? null : telegraphCode.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeDataSource(String dataSource) {
|
||||
String value = trimToEmpty(dataSource);
|
||||
if (SOURCE_INITIAL_OLD.equals(value)) {
|
||||
|
||||
+51
-39
@@ -18,9 +18,6 @@
|
||||
* 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.system.service.impl;
|
||||
@@ -28,7 +25,6 @@ package org.springblade.system.service.impl;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.cache.utils.CacheUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.redis.cache.BladeRedis;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
@@ -37,21 +33,21 @@ import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.resource.feign.ISmsClient;
|
||||
import org.springblade.resource.utils.SmsUtil;
|
||||
import org.springblade.system.cache.MpUserCache;
|
||||
import org.springblade.system.pojo.dto.PhoneChangeDTO;
|
||||
import org.springblade.system.pojo.dto.PhoneVerifyDTO;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.service.IMpUserService;
|
||||
import org.springblade.system.service.IUserPhoneService;
|
||||
import org.springblade.system.service.IUserService;
|
||||
import org.springblade.system.utils.MpUserAuth;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE;
|
||||
|
||||
/**
|
||||
* 用户手机号变更服务实现
|
||||
* 小程序用户手机号变更服务实现(写入 blade_mp_user)
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -68,24 +64,22 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
/**
|
||||
* 原手机号已校验凭证(Redis)
|
||||
*/
|
||||
private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:user:phone:change:verified:";
|
||||
private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:mp-user:phone:change:verified:";
|
||||
|
||||
private static final Duration PHONE_CHANGE_VERIFIED_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
|
||||
|
||||
private final IUserService userService;
|
||||
private final IMpUserService mpUserService;
|
||||
private final ISmsClient smsClient;
|
||||
private final BladeRedis bladeRedis;
|
||||
|
||||
@Override
|
||||
public R sendCode(String phone) {
|
||||
assertMpLogin();
|
||||
String normalizedPhone = normalizePhone(phone);
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId)) {
|
||||
throw new ServiceException("请先登录");
|
||||
}
|
||||
User currentUser = requireCurrentUser(userId);
|
||||
MpUser currentUser = requireCurrentUser(userId);
|
||||
String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId());
|
||||
boolean isCurrentPhone = StringUtil.equals(normalizedPhone, Func.toStr(currentUser.getPhone()));
|
||||
if (!isCurrentPhone) {
|
||||
@@ -100,16 +94,14 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
|
||||
@Override
|
||||
public boolean verifyOldPhone(PhoneVerifyDTO phoneVerify) {
|
||||
assertMpLogin();
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId)) {
|
||||
throw new ServiceException("请先登录");
|
||||
}
|
||||
String id = Func.toStr(phoneVerify.getId()).trim();
|
||||
String code = Func.toStr(phoneVerify.getCode()).trim();
|
||||
if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) {
|
||||
throw new ServiceException("请先获取并填写验证码");
|
||||
}
|
||||
User currentUser = requireCurrentUser(userId);
|
||||
MpUser currentUser = requireCurrentUser(userId);
|
||||
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
|
||||
if (StringUtil.isBlank(oldPhone)) {
|
||||
throw new ServiceException("当前账号未绑定手机号");
|
||||
@@ -122,10 +114,8 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean changePhone(PhoneChangeDTO phoneChange) {
|
||||
assertMpLogin();
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId)) {
|
||||
throw new ServiceException("请先登录");
|
||||
}
|
||||
String verified = Func.toStr(bladeRedis.get(PHONE_CHANGE_VERIFIED_KEY + userId));
|
||||
if (!StringUtil.equals(verified, "1")) {
|
||||
throw new ServiceException("请先完成原手机号验证");
|
||||
@@ -136,7 +126,7 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) {
|
||||
throw new ServiceException("请先获取并填写验证码");
|
||||
}
|
||||
User currentUser = requireCurrentUser(userId);
|
||||
MpUser currentUser = requireCurrentUser(userId);
|
||||
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
|
||||
if (StringUtil.equals(newPhone, oldPhone)) {
|
||||
throw new ServiceException("新手机号不可与当前手机号相同");
|
||||
@@ -145,27 +135,49 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
assertPhoneAvailable(tenantId, newPhone, userId);
|
||||
validateSms(tenantId, id, code, newPhone);
|
||||
|
||||
User updateUser = new User();
|
||||
MpUser updateUser = new MpUser();
|
||||
updateUser.setId(userId);
|
||||
updateUser.setTenantId(tenantId);
|
||||
updateUser.setPhone(newPhone);
|
||||
updateUser.setName(currentUser.getName());
|
||||
updateUser.setRealName(currentUser.getRealName());
|
||||
updateUser.setIdentities(currentUser.getIdentities());
|
||||
updateUser.setSex(currentUser.getSex());
|
||||
updateUser.setDeptId(currentUser.getDeptId());
|
||||
updateUser.setPostId(currentUser.getPostId());
|
||||
updateUser.setAvatar(currentUser.getAvatar());
|
||||
updateUser.setEmail(currentUser.getEmail());
|
||||
updateUser.setRemark(currentUser.getRemark());
|
||||
updateUser.setStatus(currentUser.getStatus());
|
||||
// 账号若等于原手机号,同步更新,保证短信登录可用
|
||||
if (StringUtil.isNotBlank(oldPhone) && StringUtil.equals(oldPhone, Func.toStr(currentUser.getAccount()))) {
|
||||
assertAccountAvailable(tenantId, newPhone, userId);
|
||||
updateUser.setAccount(newPhone);
|
||||
} else {
|
||||
updateUser.setAccount(currentUser.getAccount());
|
||||
}
|
||||
boolean updated = userService.updateById(updateUser);
|
||||
boolean updated = mpUserService.updateUser(updateUser);
|
||||
if (!updated) {
|
||||
throw new ServiceException("手机号修改失败");
|
||||
}
|
||||
bladeRedis.del(PHONE_CHANGE_VERIFIED_KEY + userId);
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
MpUserCache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
private User requireCurrentUser(Long userId) {
|
||||
User user = userService.getById(userId);
|
||||
private void assertMpLogin() {
|
||||
if (Func.isEmpty(AuthUtil.getUserId())) {
|
||||
throw new ServiceException("请先登录");
|
||||
}
|
||||
if (!MpUserAuth.isMpUser()) {
|
||||
throw new ServiceException("仅支持小程序用户修改手机号");
|
||||
}
|
||||
}
|
||||
|
||||
private MpUser requireCurrentUser(Long userId) {
|
||||
MpUser user = mpUserService.getById(userId);
|
||||
if (user == null) {
|
||||
throw new ServiceException("用户不存在");
|
||||
throw new ServiceException("小程序用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -178,11 +190,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
}
|
||||
|
||||
private void assertPhoneAvailable(String tenantId, String phone, Long excludeUserId) {
|
||||
Long phoneCount = userService.count(
|
||||
Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getPhone, phone)
|
||||
.ne(User::getId, excludeUserId)
|
||||
Long phoneCount = mpUserService.count(
|
||||
Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getPhone, phone)
|
||||
.ne(MpUser::getId, excludeUserId)
|
||||
);
|
||||
if (phoneCount != null && phoneCount > 0L) {
|
||||
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", phone));
|
||||
@@ -190,11 +202,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
|
||||
}
|
||||
|
||||
private void assertAccountAvailable(String tenantId, String account, Long excludeUserId) {
|
||||
Long accountCount = userService.count(
|
||||
Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getAccount, account)
|
||||
.ne(User::getId, excludeUserId)
|
||||
Long accountCount = mpUserService.count(
|
||||
Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getAccount, account)
|
||||
.ne(MpUser::getId, excludeUserId)
|
||||
);
|
||||
if (accountCount != null && accountCount > 0L) {
|
||||
throw new ServiceException(StringUtil.format("当前用户 [{}] 已存在!", account));
|
||||
|
||||
+74
-1
@@ -133,8 +133,12 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
|
||||
@Override
|
||||
public IPage<UserVO> selectPage(Map<String, Object> user, Query query) {
|
||||
String account = Func.toStr(user.remove("account")).trim();
|
||||
String realName = Func.toStr(user.remove("realName")).trim();
|
||||
String phone = Func.toStr(user.remove("phone")).trim();
|
||||
QueryWrapper<User> queryWrapper = Condition.getQueryWrapper(user, User.class);
|
||||
applyTenantScope(queryWrapper);
|
||||
applyUserFuzzyFilters(queryWrapper, account, realName, phone);
|
||||
return UserWrapper.build().pageVO(page(Condition.getPage(query), queryWrapper));
|
||||
}
|
||||
|
||||
@@ -335,6 +339,23 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
return submitUserInfo(user) && submitUserDept(user) && submitUserScopes(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean changeUserStatus(Long userId, Integer status) {
|
||||
if (Func.isEmpty(userId)) {
|
||||
throw new ServiceException("用户主键不能为空");
|
||||
}
|
||||
if (!Objects.equals(status, DataStatusEnum.ENABLE.getCode()) && !Objects.equals(status, 0)
|
||||
&& !Objects.equals(status, DataStatusEnum.DISABLE.getCode())) {
|
||||
throw new ServiceException("启停状态不正确");
|
||||
}
|
||||
TenantGuard.verify(this, userId, USER);
|
||||
Integer targetStatus = Objects.equals(status, DataStatusEnum.ENABLE.getCode())
|
||||
? DataStatusEnum.ENABLE.getCode() : 0;
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
return changeStatus(Collections.singletonList(userId), targetStatus);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateUserInfo(User user) {
|
||||
// 用户修改自身信息强制指定当前请求账号的ID
|
||||
@@ -360,7 +381,14 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone()));
|
||||
}
|
||||
user.setPassword(null);
|
||||
return updateById(user);
|
||||
Integer originalSex = user.getSex();
|
||||
Integer normalizedSex = normalizeUserSex(originalSex);
|
||||
user.setSex(normalizedSex);
|
||||
boolean updated = updateById(user);
|
||||
if (user.getId() != null && originalSex != null && normalizedSex == null) {
|
||||
this.update(Wrappers.<User>lambdaUpdate().set(User::getSex, null).eq(User::getId, user.getId()));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private boolean submitUserDept(User user) {
|
||||
@@ -378,6 +406,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
|
||||
@Override
|
||||
public IPage<User> selectUserPage(IPage<User> page, User user, Long deptId, String tenantId) {
|
||||
normalizeUserSearch(user);
|
||||
List<Long> deptIdList = SysCache.getDeptChildIds(deptId);
|
||||
return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId));
|
||||
}
|
||||
@@ -713,8 +742,12 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
|
||||
@Override
|
||||
public List<UserExcel> exportUser(Map<String, Object> user) {
|
||||
String account = Func.toStr(user.remove("account")).trim();
|
||||
String realName = Func.toStr(user.remove("realName")).trim();
|
||||
String phone = Func.toStr(user.remove("phone")).trim();
|
||||
QueryWrapper<User> queryWrapper = Condition.getQueryWrapper(user, User.class);
|
||||
applyTenantScope(queryWrapper);
|
||||
applyUserFuzzyFilters(queryWrapper, account, realName, phone);
|
||||
queryWrapper.lambda().eq(User::getIsDeleted, BladeConstant.DB_NOT_DELETED);
|
||||
return exportUser(queryWrapper);
|
||||
}
|
||||
@@ -1077,6 +1110,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone()));
|
||||
}
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
user.setSex(normalizeUserSex(user.getSex()));
|
||||
return save(user) && submitUserDept(user) && submitUserScopes(user);
|
||||
}
|
||||
|
||||
@@ -1087,6 +1121,16 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
if (user.getIncludeNewCustomer() == null) user.setIncludeNewCustomer(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 性别仅保留男 / 女 / 未知;空值或历史占位 -1 不回填,按空处理。
|
||||
*/
|
||||
private Integer normalizeUserSex(Integer sex) {
|
||||
if (Objects.equals(sex, 1) || Objects.equals(sex, 2) || Objects.equals(sex, 3)) {
|
||||
return sex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean submitUserScopes(User user) {
|
||||
userDataScopeMapper.delete(Wrappers.<UserDataScope>lambdaQuery().eq(UserDataScope::getUserId, user.getId()));
|
||||
userCustomerScopeMapper.delete(Wrappers.<UserCustomerScope>lambdaQuery().eq(UserCustomerScope::getUserId, user.getId()));
|
||||
@@ -1127,6 +1171,35 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化用户列表搜索条件,避免首尾空格导致模糊匹配失效。
|
||||
*/
|
||||
private void normalizeUserSearch(User user) {
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if (user.getAccount() != null) {
|
||||
user.setAccount(user.getAccount().trim());
|
||||
}
|
||||
if (user.getRealName() != null) {
|
||||
user.setRealName(user.getRealName().trim());
|
||||
}
|
||||
if (user.getPhone() != null) {
|
||||
user.setPhone(user.getPhone().trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号名、姓名、手机号统一按模糊匹配过滤。
|
||||
*/
|
||||
private void applyUserFuzzyFilters(QueryWrapper<User> queryWrapper, String account, String realName, String phone) {
|
||||
queryWrapper.lambda().like(StringUtil.isNotBlank(account), User::getAccount, account);
|
||||
if (StringUtil.isNotBlank(realName)) {
|
||||
queryWrapper.lambda().and(wrapper -> wrapper.like(User::getRealName, realName).or().like(User::getName, realName));
|
||||
}
|
||||
queryWrapper.lambda().like(StringUtil.isNotBlank(phone), User::getPhone, phone);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void decryptPhone(IPhone user) {
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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.system.util;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springblade.thirdparty.oa.constant.OAConvertConstant;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 组织编码工具:上级编码直接拼接固定 3 位分段数字(不足补零,中间不加 "-")
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class DeptCodeUtil {
|
||||
|
||||
/**
|
||||
* 分段数字位数
|
||||
*/
|
||||
public static final int SEGMENT_WIDTH = 3;
|
||||
|
||||
private DeptCodeUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成子级编码:上级编码 + 三位数字
|
||||
*/
|
||||
public static String formatChildCode(String parentCode, int serial) {
|
||||
return parentCode + String.format("%0" + SEGMENT_WIDTH + "d", serial);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否符合「上级编码 + 三位数字」格式(恰好多出 3 位数字)
|
||||
*/
|
||||
public static boolean matchesChildCode(String parentCode, String deptCode) {
|
||||
if (StringUtils.isBlank(parentCode) || StringUtils.isBlank(deptCode)) {
|
||||
return false;
|
||||
}
|
||||
return deptCode.matches("^" + Pattern.quote(parentCode) + "\\d{" + SEGMENT_WIDTH + "}$");
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为公司编码(GWWL + 三位数字)
|
||||
*/
|
||||
public static boolean isCompanyCode(String deptCode) {
|
||||
return matchesChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已有编码中取下一分段序号(从 1 起)
|
||||
*/
|
||||
public static int nextSerial(String parentCode, Collection<String> existingCodes) {
|
||||
if (StringUtils.isBlank(parentCode)) {
|
||||
return 1;
|
||||
}
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(parentCode) + "(\\d{" + SEGMENT_WIDTH + "})$");
|
||||
int maxSerial = 0;
|
||||
if (existingCodes != null) {
|
||||
for (String code : existingCodes) {
|
||||
if (StringUtils.isBlank(code)) {
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = pattern.matcher(code.trim());
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
maxSerial = Math.max(maxSerial, Integer.parseInt(matcher.group(1)));
|
||||
}
|
||||
}
|
||||
return maxSerial + 1;
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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.system.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.DictCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.enums.DictEnum;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.system.pojo.vo.MpUserVO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 小程序用户包装类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class MpUserWrapper extends BaseEntityWrapper<MpUser, MpUserVO> {
|
||||
|
||||
public static MpUserWrapper build() {
|
||||
return new MpUserWrapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUserVO entityVO(MpUser mpUser) {
|
||||
MpUserVO mpUserVO = Objects.requireNonNull(BeanUtil.copyProperties(mpUser, MpUserVO.class));
|
||||
// 密码哈希不下发前端
|
||||
mpUserVO.setPassword(null);
|
||||
mpUserVO.setIdentitiesName(resolveIdentitiesName(mpUser.getIdentities()));
|
||||
if (mpUser.getSex() != null) {
|
||||
mpUserVO.setSexName(DictCache.getValue(DictEnum.SEX, mpUser.getSex()));
|
||||
}
|
||||
mpUserVO.setCreateUserName(UserCache.getUserRealName(mpUser.getCreateUser()));
|
||||
mpUserVO.setUpdateUserName(UserCache.getUserRealName(mpUser.getUpdateUser()));
|
||||
return mpUserVO;
|
||||
}
|
||||
|
||||
private String resolveIdentitiesName(String identities) {
|
||||
List<String> identityList = MpUserIdentity.split(identities);
|
||||
if (identityList.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return identityList.stream()
|
||||
.map(code -> {
|
||||
MpUserIdentity identity = MpUserIdentity.of(code);
|
||||
return identity == null ? code : identity.getLabel();
|
||||
})
|
||||
.filter(Func::isNotBlank)
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
}
|
||||
+6
-1
@@ -38,6 +38,7 @@ import org.springblade.system.pojo.enums.DictEnum;
|
||||
import org.springblade.system.pojo.vo.UserVO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 包装类,返回视图层所需的字段
|
||||
@@ -81,7 +82,11 @@ public class UserWrapper extends BaseEntityWrapper<User, UserVO> {
|
||||
userVO.setPostId(StringPool.EMPTY);
|
||||
userVO.setPostName("暂未分配");
|
||||
}
|
||||
userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex()));
|
||||
if (user.getSex() == null || Objects.equals(user.getSex(), -1)) {
|
||||
userVO.setSexName("");
|
||||
} else {
|
||||
userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex()));
|
||||
}
|
||||
userVO.setUserTypeName(DictCache.getValue(DictEnum.USER_TYPE, user.getUserType()));
|
||||
return userVO;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试:本模块此前的 src/test 下只有 main() 手跑脚本,没有可自动执行的测试。
|
||||
认证改造的提交/审核链路带状态机语义(同对象至多一条待审、提交不得改生效快照
|
||||
等),必须用可重复执行的单元测试守住,故引入 JUnit 5 与 Mockito。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user