Merge branch 'master' into feature/20261104-bfi-project-contract-ref-v2

This commit is contained in:
刘泉佳
2026-11-09 17:27:47 +08:00
31 changed files with 922 additions and 63 deletions
@@ -274,8 +274,8 @@ public class DeptController extends BladeController {
@PreAuth(AuthConstant.PERMIT_ALL)
@GetMapping("/business-dept-select")
@ApiOperationSupport(order = 12)
@Operation(summary = "业务部门下拉", description = "返回是否业务部门=是的部门列表")
public R<List<Dept>> businessDeptSelect() {
@Operation(summary = "业务部门下拉", description = "返回是否业务部门=是的部门列表(含上级组织名称)")
public R<List<DeptVO>> businessDeptSelect() {
return R.data(deptService.listBusinessDept());
}
@@ -25,6 +25,7 @@
*/
package org.springblade.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
@@ -37,6 +38,7 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.secure.annotation.IsAdmin;
import org.springblade.core.secure.annotation.PreAuth;
@@ -85,7 +87,7 @@ public class RoleController extends BladeController {
}
/**
* 列表
* 分页列表(按树形根节点分页)
*/
@GetMapping("/list")
@Parameters({
@@ -93,9 +95,9 @@ public class RoleController extends BladeController {
@Parameter(name = "roleAlias", description = "角色别名", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
@ApiOperationSupport(order = 2)
@Operation(summary = "列表", description = "传入role")
public R<List<RoleVO>> list(@Parameter(hidden = true) @RequestParam Map<String, Object> role) {
return R.data(roleService.selectList(role));
@Operation(summary = "分页", description = "传入role")
public R<IPage<RoleVO>> list(@Parameter(hidden = true) @RequestParam Map<String, Object> role, Query query) {
return R.data(roleService.selectPage(role, query));
}
/**
@@ -109,9 +109,9 @@ public interface IDeptService extends IService<Dept> {
/**
* 业务部门下拉(是否业务部门=是)
*
* @return 业务部门列表
* @return 业务部门列表(含上级组织名称,便于同名部门区分)
*/
List<Dept> listBusinessDept();
List<DeptVO> listBusinessDept();
/**
* 根据部门名称精确匹配获取部门ID集合
@@ -27,6 +27,7 @@ package org.springblade.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.core.mp.support.Query;
import org.springblade.system.pojo.entity.Role;
import org.springblade.system.pojo.vo.RoleVO;
@@ -58,6 +59,15 @@ public interface IRoleService extends IService<Role> {
*/
List<RoleVO> selectList(Map<String, Object> role);
/**
* 角色分页(按树形根节点分页,子节点随根节点返回)
*
* @param role 查询条件 Map
* @param query 分页参数
* @return 角色分页结果,非超管时仅命中当前会话租户
*/
IPage<RoleVO> selectPage(Map<String, Object> role, Query query);
/**
* 自定义角色分页查询
*
@@ -42,6 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Objects;
import static org.springblade.core.cache.constant.CacheConstant.SYS_CACHE;
@@ -88,6 +89,39 @@ public class BusinessDeptConfigServiceImpl extends BaseServiceImpl<BusinessDeptC
if (Func.isEmpty(platformCompanyId) || Func.isEmpty(businessDeptId)) {
return null;
}
BusinessDeptConfig config = findByPlatformAndDept(platformCompanyId, businessDeptId);
if (config != null) {
return config;
}
// 兼容项目仍挂旧组织 ID / 同名非平台公司节点:按平台公司名称回退匹配现行配置
Dept platformCompany = deptService.getById(platformCompanyId);
String platformCompanyName = platformCompany == null ? null : trimToNull(platformCompany.getDeptName());
if (Func.isEmpty(platformCompanyName)) {
return null;
}
List<Dept> activePlatformList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getIsPlatformCompany, 1)
.eq(Dept::getDeptName, platformCompanyName)
.orderByAsc(Dept::getId));
for (Dept activePlatform : activePlatformList) {
if (activePlatform == null || Func.isEmpty(activePlatform.getId())
|| Objects.equals(activePlatform.getId(), platformCompanyId)) {
continue;
}
config = findByPlatformAndDept(activePlatform.getId(), businessDeptId);
if (config != null) {
return config;
}
}
return getOne(Wrappers.<BusinessDeptConfig>lambdaQuery()
.eq(BusinessDeptConfig::getPlatformCompanyName, platformCompanyName)
.eq(BusinessDeptConfig::getBusinessDeptId, businessDeptId)
.eq(BusinessDeptConfig::getIsDeleted, 0)
.orderByDesc(BusinessDeptConfig::getUpdateTime)
.last("LIMIT 1"), false);
}
private BusinessDeptConfig findByPlatformAndDept(Long platformCompanyId, Long businessDeptId) {
return getOne(Wrappers.<BusinessDeptConfig>lambdaQuery()
.eq(BusinessDeptConfig::getPlatformCompanyId, platformCompanyId)
.eq(BusinessDeptConfig::getBusinessDeptId, businessDeptId)
@@ -321,7 +321,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
@Override
public List<Dept> listBusinessDept() {
public List<DeptVO> listBusinessDept() {
LambdaQueryWrapper<Dept> queryWrapper = Wrappers.<Dept>lambdaQuery()
.eq(Dept::getIsBusinessDept, 1)
.orderByAsc(Dept::getSort)
@@ -329,7 +329,47 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (!AuthUtil.isAdministrator()) {
queryWrapper.eq(Dept::getTenantId, AuthUtil.getTenantId());
}
return list(queryWrapper);
List<Dept> deptList = list(queryWrapper);
if (CollectionUtil.isEmpty(deptList)) {
return Collections.emptyList();
}
// 批量补齐上级名称、所属公司名称:扁平下拉靠公司/上级区分同名业务部门
Set<Long> parentIdSet = deptList.stream()
.map(Dept::getParentId)
.filter(Func::isNotEmpty)
.collect(Collectors.toSet());
Map<Long, String> parentNameMap = Collections.emptyMap();
if (CollectionUtil.isNotEmpty(parentIdSet)) {
parentNameMap = listByIds(parentIdSet).stream()
.filter(parent -> Func.isNotEmpty(parent.getId()))
.collect(Collectors.toMap(Dept::getId, Dept::getDeptName, (left, right) -> left));
}
Set<String> companyCodeSet = deptList.stream()
.map(Dept::getBelongCompanyCode)
.filter(Func::isNotEmpty)
.collect(Collectors.toSet());
Map<String, String> companyNameMap = Collections.emptyMap();
if (CollectionUtil.isNotEmpty(companyCodeSet)) {
companyNameMap = list(Wrappers.<Dept>lambdaQuery().in(Dept::getDeptCode, companyCodeSet)).stream()
.filter(company -> Func.isNotEmpty(company.getDeptCode()))
.collect(Collectors.toMap(Dept::getDeptCode, Dept::getDeptName, (left, right) -> left));
}
Map<Long, String> finalParentNameMap = parentNameMap;
Map<String, String> finalCompanyNameMap = companyNameMap;
return deptList.stream().map(dept -> {
DeptVO deptVO = BeanUtil.copyProperties(dept, DeptVO.class);
if (deptVO == null) {
return null;
}
if (Func.isNotEmpty(dept.getParentId())) {
deptVO.setParentName(finalParentNameMap.getOrDefault(dept.getParentId(), StringPool.EMPTY));
}
if (Func.isNotEmpty(dept.getBelongCompanyCode())) {
deptVO.setBelongCompanyName(
finalCompanyNameMap.getOrDefault(dept.getBelongCompanyCode(), StringPool.EMPTY));
}
return deptVO;
}).filter(Objects::nonNull).toList();
}
@Override
@@ -33,6 +33,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.AllArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tenant.TenantGuard;
import org.springblade.core.tool.constant.BladeConstant;
@@ -92,9 +93,30 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements IR
public List<RoleVO> selectList(Map<String, Object> role) {
QueryWrapper<Role> queryWrapper = Condition.getQueryWrapper(role, Role.class);
applyTenantScope(queryWrapper);
queryWrapper.lambda().orderByAsc(Role::getSort).orderByAsc(Role::getId);
return RoleWrapper.build().listNodeVO(list(queryWrapper));
}
@Override
public IPage<RoleVO> selectPage(Map<String, Object> role, Query query) {
List<RoleVO> tree = selectList(role);
IPage<RoleVO> page = Condition.getPage(query);
long total = tree.size();
page.setTotal(total);
if (total == 0L || page.getSize() < 1L) {
page.setRecords(Collections.emptyList());
return page;
}
int fromIndex = (int) ((page.getCurrent() - 1L) * page.getSize());
if (fromIndex >= tree.size()) {
page.setRecords(Collections.emptyList());
return page;
}
int toIndex = (int) Math.min(fromIndex + page.getSize(), tree.size());
page.setRecords(new ArrayList<>(tree.subList(fromIndex, toIndex)));
return page;
}
@Override
public IPage<RoleVO> selectRolePage(IPage<RoleVO> page, RoleVO role) {
return page.setRecords(baseMapper.selectRolePage(page, role));
@@ -37,8 +37,10 @@ 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.ocr.service.IBusinessLicenseOcrService;
import org.springblade.transport.ocr.service.IQualificationCertificateOcrService;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO;
import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -63,6 +65,7 @@ public class BaiduOcrController extends BladeController {
private final IBaiduOcrService baiduOcrService;
private final IBusinessLicenseOcrService businessLicenseOcrService;
private final IQualificationCertificateOcrService qualificationCertificateOcrService;
/**
* 识别上传的图片。
@@ -126,6 +129,20 @@ public class BaiduOcrController extends BladeController {
return R.data(businessLicenseOcrService.recognizeUrl(imageUrl));
}
/**
* 从业资格证图片地址识别(第三方 /Biaozhunbaodan)。
*
* @param imageUrl 图片地址
* @return 识别结果
*/
@PostMapping("/qualification-certificate")
@ApiOperationSupport(order = 4)
@Operation(summary = "从业资格证OCR识别", description = "按图片地址调用第三方道路运输从业人员从业资格证识别接口")
public R<QualificationCertificateOcrVO> recognizeQualificationCertificate(
@Parameter(description = "图片地址", required = true) @RequestParam String imageUrl) {
return R.data(qualificationCertificateOcrService.recognizeUrl(imageUrl));
}
private BaiduOcrType resolveType(String type) {
try {
return BaiduOcrType.from(type);
@@ -135,9 +135,10 @@ public class VoucherManageController extends BladeController {
@ApiOperationSupport(order = 7)
@Operation(summary = "可关联运输批次")
public R<IPage<Map<String, Object>>> waybillBatches(Query query, @RequestParam(required = false) String batchNo,
@RequestParam(required = false) String createUser, @RequestParam(required = false) Integer waybillCount,
@RequestParam(required = false) String createTimeStart, @RequestParam(required = false) String createTimeEnd) {
return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd));
@RequestParam(required = false) Long projectId, @RequestParam(required = false) String createUser,
@RequestParam(required = false) Integer waybillCount, @RequestParam(required = false) String createTimeStart,
@RequestParam(required = false) String createTimeEnd) {
return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, projectId, createUser, waybillCount, createTimeStart, createTimeEnd));
}
@PostMapping("/audit-pass")
@@ -57,6 +57,7 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.pojo.vo.WaybillLocateVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
@@ -230,6 +231,13 @@ public class WaybillController extends BladeController {
waybillImportBatchService.exportFailures(request, response);
}
@PostMapping("/import-batch/duplicates")
@ApiOperationSupport(order = 6)
@Operation(summary = "查询与已有运单重复的导入行")
public R<List<WaybillImportDuplicateVO>> checkImportDuplicates(@RequestBody WaybillImportBatchRequest request) {
return R.data(waybillImportBatchService.checkDuplicates(request));
}
@PostMapping("/import-batch/confirm")
@ApiOperationSupport(order = 7)
@Operation(summary = "确认运单批量导入")
@@ -13,8 +13,9 @@ import org.apache.ibatis.annotations.Delete;
public interface VoucherWaybillBatchMapper extends BaseMapper<VoucherWaybillBatch> {
@Delete("DELETE FROM blade_voucher_waybill_batch WHERE voucher_id = #{voucherId}")
int deletePhysicalByVoucherId(@Param("voucherId") Long voucherId);
IPage<Map<String, Object>> selectVoucherWaybillBatchPage(IPage<?> page, @Param("tenantId") String tenantId, @Param("batchNo") String batchNo, @Param("createUser") String createUser,
@Param("waybillCount") Integer waybillCount, @Param("createTimeStart") String createTimeStart, @Param("createTimeEnd") String createTimeEnd,
IPage<Map<String, Object>> selectVoucherWaybillBatchPage(IPage<?> page, @Param("tenantId") String tenantId, @Param("batchNo") String batchNo,
@Param("projectId") Long projectId, @Param("createUser") String createUser, @Param("waybillCount") Integer waybillCount,
@Param("createTimeStart") String createTimeStart, @Param("createTimeEnd") String createTimeEnd,
@Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName);
List<Map<String, Object>> selectWaybillBatchesByIds(@Param("tenantId") String tenantId, @Param("ids") List<Long> ids,
@Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName);
@@ -4,12 +4,14 @@
<select id="selectVoucherWaybillBatchPage" resultType="java.util.LinkedHashMap">
SELECT MIN(w.id) AS id, MAX(w.create_time) AS createTime, MAX(w.create_user) AS createUser,
MAX(u.real_name) AS createUserName, COUNT(*) AS waybillCount,
CASE WHEN COUNT(DISTINCT COALESCE(w.project_id, -1)) > 1 THEN '多个项目' ELSE MAX(w.project_name) END AS projectName,
COALESCE(NULLIF(w.batch_no, ''), '未分批运单') AS batchNo
FROM blade_waybill w
LEFT JOIN blade_user u ON u.id = w.create_user
WHERE w.is_deleted = 0 AND w.tenant_id = #{tenantId}
<if test="restrictCarrier">AND w.carrier_name = #{carrierName}</if>
<if test="batchNo != null and batchNo != ''">AND w.batch_no LIKE CONCAT('%', #{batchNo}, '%')</if>
<if test="projectId != null">AND w.project_id = #{projectId}</if>
<if test="createUser != null and createUser != ''">AND w.create_user = #{createUser}</if>
<if test="createTimeStart != null and createTimeStart != ''">AND w.create_time <![CDATA[>=]]> #{createTimeStart}</if>
<if test="createTimeEnd != null and createTimeEnd != ''">AND w.create_time <![CDATA[<=]]> #{createTimeEnd}</if>
@@ -0,0 +1,45 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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.ocr.service;
import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO;
/**
* 从业资格证 OCR 服务。
*
* @author Chill
*/
public interface IQualificationCertificateOcrService {
/**
* 根据图片地址识别道路运输从业人员从业资格证。
*
* @param imageUrl 图片地址
* @return 识别结果
*/
QualificationCertificateOcrVO recognizeUrl(String imageUrl);
}
@@ -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.ocr.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.transport.ocr.service.IQualificationCertificateOcrService;
import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 从业资格证 OCR 服务实现,调用第三方 /Biaozhunbaodan 接口。
*
* @author Chill
*/
@Slf4j
@Service
public class QualificationCertificateOcrServiceImpl implements IQualificationCertificateOcrService {
private static final String PATH = "/Biaozhunbaodan";
private static final int SUCCESS_CODE = 200;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 从业资格证 OCR 服务地址,Nacos:thirdParty.ocr.qualificationCertificateBaseUrl
*/
@Value("${thirdParty.ocr.qualificationCertificateBaseUrl:http://124.71.22.123:8400}")
private String qualificationCertificateBaseUrl;
public QualificationCertificateOcrServiceImpl(ObjectMapper objectMapper,
@Qualifier("baiduOcrHttpClient") HttpClient httpClient) {
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
@Override
public QualificationCertificateOcrVO recognizeUrl(String imageUrl) {
if (StringUtil.isBlank(imageUrl)) {
throw new ServiceException("OCR图片地址不能为空");
}
String normalizedUrl = imageUrl.trim();
validateImageUrl(normalizedUrl);
if (StringUtil.isBlank(qualificationCertificateBaseUrl)) {
throw new ServiceException("从业资格证OCR服务地址未配置(thirdParty.ocr.qualificationCertificateBaseUrl)");
}
String requestUrl = qualificationCertificateBaseUrl.replaceAll("/+$", "") + PATH;
Map<String, String> body = new LinkedHashMap<>();
body.put("img_path", normalizedUrl);
JsonNode response = sendRequest(requestUrl, body);
int code = response.path("code").asInt(Integer.MIN_VALUE);
if (code != SUCCESS_CODE) {
String message = firstNonBlank(response.path("msg").asText(null), response.path("message").asText(null), "未知错误");
log.warn("从业资格证OCR识别失败,code={},msg={},imageUrl={}", code, message, normalizedUrl);
throw new ServiceException("从业资格证OCR识别失败:" + message);
}
QualificationCertificateOcrVO result = new QualificationCertificateOcrVO();
result.setFields(extractFields(response.path("result").path("key_info")));
return result;
}
private JsonNode sendRequest(String requestUrl, Map<String, String> body) {
try {
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
log.error("从业资格证OCR接口响应异常,status={},url={}", response.statusCode(), requestUrl);
throw new ServiceException("从业资格证OCR接口调用失败,HTTP状态码:" + response.statusCode());
}
JsonNode result = objectMapper.readTree(response.body());
if (result == null || !result.isObject()) {
throw new ServiceException("从业资格证OCR响应格式异常");
}
return result;
} catch (ServiceException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
log.error("调用从业资格证OCR接口被中断,url={}", requestUrl, exception);
throw new ServiceException("从业资格证OCR接口调用被中断");
} catch (IllegalArgumentException exception) {
log.error("从业资格证OCR接口地址配置不正确,url={}", requestUrl);
throw new ServiceException("从业资格证OCR接口地址配置不正确");
} catch (IOException exception) {
log.error("调用从业资格证OCR接口失败,url={}", requestUrl, exception);
throw new ServiceException("从业资格证OCR接口调用失败");
}
}
private Map<String, String> extractFields(JsonNode keyInfoNode) {
Map<String, String> fields = new LinkedHashMap<>();
if (keyInfoNode == null || !keyInfoNode.isArray()) {
return fields;
}
for (JsonNode item : keyInfoNode) {
if (item == null || !item.isObject()) {
continue;
}
String key = item.path("key").asText(null);
String value = item.path("value").asText(null);
if (StringUtil.isBlank(key) || value == null) {
continue;
}
String trimmed = value.trim();
if (StringUtil.isBlank(trimmed)) {
continue;
}
fields.put(key.trim(), trimmed);
}
return fields;
}
private void validateImageUrl(String imageUrl) {
if (imageUrl.getBytes(StandardCharsets.UTF_8).length > 1024) {
throw new ServiceException("OCR图片地址长度不能超过1024字节");
}
try {
URI imageUri = URI.create(imageUrl);
String scheme = imageUri.getScheme();
if (StringUtil.isBlank(scheme) || StringUtil.isBlank(imageUri.getHost())
|| imageUri.getUserInfo() != null
|| !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
throw new ServiceException("OCR图片地址必须是有效的HTTP或HTTPS地址");
}
} catch (IllegalArgumentException exception) {
throw new ServiceException("OCR图片地址格式不正确");
}
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (StringUtil.isNotBlank(value)) {
return value;
}
}
return null;
}
}
@@ -30,7 +30,7 @@ public interface IVoucherManageService extends BaseService<VoucherManage> {
void processUploadedVoucher(Long voucherId);
void reprocessUploadedVoucher(Long voucherId);
void removeVoucher(Long id);
IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd);
IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, Long projectId, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd);
void auditPass(Long id);
void auditReject(Long id, String rejectReason);
}
@@ -7,8 +7,11 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
import org.springblade.transport.pojo.entity.WaybillImportBatch;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import java.util.List;
/** 运单批次服务。 */
public interface IWaybillImportBatchService extends BaseService<WaybillImportBatch> {
WaybillImportBatch saveDraft(WaybillImportBatchRequest request);
@@ -18,4 +21,7 @@ public interface IWaybillImportBatchService extends BaseService<WaybillImportBat
IPage<WaybillImportBatchVO> page(IPage<WaybillImportBatch> page, WaybillImportBatchRequest request);
BusinessRemoveResultVO removeBatches(String ids);
String nextBatchNo();
/** 逐行比对导入行与项目下已有运单,返回命中的运单号。 */
List<WaybillImportDuplicateVO> checkDuplicates(WaybillImportBatchRequest request);
}
@@ -907,14 +907,19 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
throw new ServiceException("合同负责部门不存在");
}
Dept platformCompany = SysCache.getDept(platformCompanyId);
if (platformCompany == null) {
throw new ServiceException("平台公司不存在");
}
// 合同编号前缀改由「平台公司 + 业务部门」业务部门配置维护,不再读取组织机构 Dept.contractCodePrefix
// 合同编号前缀改由「平台公司 + 业务部门」业务部门配置维护;系统侧会按同名现行平台公司回退匹配
BusinessDeptConfig config = SysCache.getBusinessDeptConfig(platformCompanyId, organizationId);
String prefix = config == null ? null : TransportBusinessSupport.trimToNull(config.getContractCodePrefix());
if (Func.isEmpty(prefix)) {
throw new ServiceException("业务部门配置中未维护该平台公司与业务部门的合同编码前缀,无法自动生成,请先在业务部门配置中维护或手动输入合同编号");
if (platformCompany == null) {
throw new ServiceException("平台公司不存在或已删除,且未匹配到同名平台公司的合同编码前缀配置,请重新选择平台公司或手动输入合同编号");
}
String platformName = TransportBusinessSupport.trimToNull(platformCompany.getDeptName());
String deptName = TransportBusinessSupport.trimToNull(dept.getDeptName());
throw new ServiceException(String.format(
"业务部门配置中未维护平台公司「%s」与业务部门「%s」的合同编码前缀,无法自动生成;请确认项目平台公司与配置中的平台公司为同一组织后维护,或手动输入合同编号",
Func.toStr(platformName, String.valueOf(platformCompanyId)),
Func.toStr(deptName, String.valueOf(organizationId))));
}
int year = LocalDate.now().getYear();
String head = prefix + "(" + year + ")";
@@ -1193,6 +1193,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
/** 将 Excel 填写值回填到评分明细:选项型匹配选项,分值型写入 scoreInput/selfScore */
private void applyImportedScoreDetail(CustomerCreditScoreDetailVO detail, String fillValue, String selfScoreText) {
BigDecimal importedSelfScore = parseImportedSelfScore(selfScoreText);
if ("score".equalsIgnoreCase(detail.getOptionType())) {
BigDecimal scoreInput;
try {
@@ -1201,12 +1202,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
throw new ServiceException("填写值必须为数字");
}
detail.setScoreInput(scoreInput);
if (Func.isNotEmpty(selfScoreText)) {
try {
detail.setSelfScore(new BigDecimal(selfScoreText));
} catch (NumberFormatException exception) {
throw new ServiceException("自评得分必须为数字");
}
if (importedSelfScore != null) {
detail.setSelfScore(importedSelfScore);
}
return;
}
@@ -1223,13 +1220,19 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
detail.setSelectedOption(optionValue == null ? fillValue : String.valueOf(optionValue));
BigDecimal optionScore = resolveOptionScore(matched);
detail.setSelfScore(optionScore == null ? BigDecimal.ZERO : optionScore);
if (Func.isNotEmpty(selfScoreText)) {
try {
detail.setSelfScore(new BigDecimal(selfScoreText));
} catch (NumberFormatException exception) {
throw new ServiceException("自评得分必须为数字");
}
detail.setSelfScore(importedSelfScore != null ? importedSelfScore : (optionScore == null ? BigDecimal.ZERO : optionScore));
}
/** 解析线下 Excel 自评得分;空值或哨兵值 -1 视为未填写 */
private BigDecimal parseImportedSelfScore(String selfScoreText) {
if (Func.isEmpty(selfScoreText) || "-1".equals(selfScoreText.trim())) {
return null;
}
try {
BigDecimal selfScore = new BigDecimal(selfScoreText.trim());
return selfScore.compareTo(BigDecimal.ONE.negate()) == 0 ? null : selfScore;
} catch (NumberFormatException exception) {
throw new ServiceException("自评得分必须为数字");
}
}
@@ -1720,6 +1723,9 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
scoreVO.setMaxCreditLimit(normalizeSentinelAmount(scoreVO.getMaxCreditLimit()));
scoreVO.setApplyCreditLimit(normalizeSentinelAmount(scoreVO.getApplyCreditLimit()));
scoreVO.setTempApplyCreditLimit(normalizeOptionalScoreAmount(scoreVO.getTempApplyCreditLimit()));
scoreVO.setSelfScore(normalizeSentinelAmount(scoreVO.getSelfScore()));
scoreVO.setReviewScore(normalizeSentinelAmount(scoreVO.getReviewScore()));
scoreVO.setFinalScore(normalizeSentinelAmount(scoreVO.getFinalScore()));
scoreVO.setProofAttachments(normalizeScoreProofAttachments(scoreVO));
CustomerCreditScore score = Objects.requireNonNull(BeanUtil.copyProperties(scoreVO, CustomerCreditScore.class));
score.setId(scoreId);
@@ -1740,6 +1746,9 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
if (detailVO.getScore() == null) {
detailVO.setScore(resolveFullScore(detailVO.getOptionsJson()));
}
detailVO.setSelfScore(normalizeSentinelAmount(detailVO.getSelfScore()));
detailVO.setReviewScore(normalizeSentinelAmount(detailVO.getReviewScore()));
detailVO.setScoreInput(normalizeSentinelAmount(detailVO.getScoreInput()));
CustomerCreditScoreDetail detail = Objects.requireNonNull(BeanUtil.copyProperties(detailVO, CustomerCreditScoreDetail.class));
detail.setId(IdWorker.getId());
detail.setTenantId(score.getTenantId());
@@ -1903,8 +1912,17 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
.collect(Collectors.groupingBy(CustomerCreditScoreDetailVO::getScoreId));
return scores.stream().map(score -> {
CustomerCreditScoreVO scoreVO = Objects.requireNonNull(BeanUtil.copyProperties(score, CustomerCreditScoreVO.class));
scoreVO.setSelfScore(normalizeSentinelAmount(scoreVO.getSelfScore()));
scoreVO.setReviewScore(normalizeSentinelAmount(scoreVO.getReviewScore()));
scoreVO.setFinalScore(normalizeSentinelAmount(scoreVO.getFinalScore()));
scoreVO.setAttachments(parseScoreAttachments(score.getProofAttachments()));
scoreVO.setDetails(detailMap.getOrDefault(score.getId(), new ArrayList<>()));
List<CustomerCreditScoreDetailVO> details = detailMap.getOrDefault(score.getId(), new ArrayList<>());
details.forEach(detail -> {
detail.setSelfScore(normalizeSentinelAmount(detail.getSelfScore()));
detail.setReviewScore(normalizeSentinelAmount(detail.getReviewScore()));
detail.setScoreInput(normalizeSentinelAmount(detail.getScoreInput()));
});
scoreVO.setDetails(details);
scoreVO.setStandards(loadStandards(score.getQuantificationId()));
return scoreVO;
}).toList();
@@ -2024,8 +2042,9 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
: resolveFullScore(itemOptions);
detail.setScore(fullScore == null ? BigDecimal.ZERO : fullScore);
detail.setOptionsJson(buildOptionsJson(itemOptions));
detail.setSelfScore(BigDecimal.ZERO);
detail.setReviewScore(BigDecimal.ZERO);
// 模板明细自评/复评默认空,避免前端线下评估回显 -1/0 哨兵值
detail.setSelfScore(null);
detail.setReviewScore(null);
details.add(detail);
}
return details;
@@ -72,6 +72,7 @@ import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.ContractPartySupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.system.cache.UserCache;
@@ -280,22 +281,26 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
.collect(Collectors.toSet());
Map<Long, ProjectApply> projectById = projectApplyService.listByIds(projectIds).stream()
.collect(Collectors.toMap(ProjectApply::getId, Function.identity()));
// 多方客商汇总名(顿号拼接)需拆开后再匹配客商档案全称,避免变更通过后合同被误过滤
Set<String> partyNames = contracts.stream()
.flatMap(contract -> Stream.of(contract.getPartyA(), contract.getPartyB()))
.flatMap(contract -> Stream.concat(
ContractPartySupport.splitCustomerNames(contract.getPartyA()).stream(),
ContractPartySupport.splitCustomerNames(contract.getPartyB()).stream()))
.filter(Func::isNotEmpty).collect(Collectors.toSet());
Set<String> approvedCustomers = customerArchiveService.list(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getIsDeleted, 0)
.eq(CustomerArchive::getStatus, 1)
.eq(CustomerArchive::getApprovalStatus, "approved")
.in(CustomerArchive::getFullName, partyNames))
Set<String> approvedCustomers = partyNames.isEmpty() ? Set.of() : customerArchiveService.list(
Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getIsDeleted, 0)
.eq(CustomerArchive::getStatus, 1)
.eq(CustomerArchive::getApprovalStatus, "approved")
.in(CustomerArchive::getFullName, partyNames))
.stream().map(CustomerArchive::getFullName).collect(Collectors.toSet());
return contracts.stream()
.filter(contract -> {
ProjectApply project = projectById.get(contract.getProjectId());
return project != null && !Objects.equals(project.getIsDeleted(), 1)
&& List.of("approved", "change_approved").contains(project.getApprovalStatus())
&& approvedCustomers.contains(contract.getPartyA())
&& approvedCustomers.contains(contract.getPartyB());
&& isApprovedPartySummary(contract.getPartyA(), approvedCustomers)
&& isApprovedPartySummary(contract.getPartyB(), approvedCustomers);
})
.map(contract -> {
ProjectApply project = projectById.get(contract.getProjectId());
@@ -1424,12 +1429,29 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
private boolean isApprovedCustomer(String customerName) {
if (Func.isEmpty(customerName)) return false;
return customerArchiveService.count(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getIsDeleted, 0)
.eq(CustomerArchive::getStatus, 1)
.eq(CustomerArchive::getApprovalStatus, "approved")
.eq(CustomerArchive::getFullName, customerName)) > 0;
List<String> names = ContractPartySupport.splitCustomerNames(customerName);
if (names.isEmpty()) {
return false;
}
for (String name : names) {
long count = customerArchiveService.count(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getIsDeleted, 0)
.eq(CustomerArchive::getStatus, 1)
.eq(CustomerArchive::getApprovalStatus, "approved")
.eq(CustomerArchive::getFullName, name));
if (count <= 0) {
return false;
}
}
return true;
}
private boolean isApprovedPartySummary(String partySummary, Set<String> approvedCustomers) {
List<String> names = ContractPartySupport.splitCustomerNames(partySummary);
if (names.isEmpty()) {
return false;
}
return names.stream().allMatch(approvedCustomers::contains);
}
private void validateContractRelations(ContractManage contract) {
@@ -1329,6 +1329,7 @@ public class ReceivablePayableDetailServiceImpl
/**
* 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。
* 应收客商优先取运单客户;多方汇总名称时改用命中计费方案绑定的甲方,避免写入顿号拼接串。
*/
private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) {
if ("payable".equals(settlementType)) {
@@ -1338,10 +1339,31 @@ public class ReceivablePayableDetailServiceImpl
}
return contract == null ? null : contract.getPartyB();
}
if (contract != null && Func.isNotEmpty(contract.getPartyA())) {
return contract.getPartyA();
String waybillCustomer = trimToNull(waybill == null ? null : waybill.getCustomerName());
if (waybillCustomer != null && !waybillCustomer.contains("、")) {
return waybillCustomer;
}
return waybill == null ? null : waybill.getCustomerName();
if (contract != null && waybill != null) {
List<Map<String, Object>> plans = billingPlansForWaybill(contract, waybill);
Map<String, Object> plan = resolveDefaultBillingPlan(plans, waybill.getTransportType());
if (plan != null) {
String planCustomer = trimToNull(stringValue(plan, "customerName"));
if (planCustomer != null) {
return planCustomer;
}
}
String firstParty = ContractPartySupport.firstPartyName(contract.getPartyAJson(), null);
if (firstParty != null) {
return firstParty;
}
}
if (contract != null) {
String partyA = trimToNull(contract.getPartyA());
if (partyA != null && !partyA.contains("、")) {
return partyA;
}
}
return waybillCustomer;
}
@Override
@@ -1486,7 +1508,8 @@ public class ReceivablePayableDetailServiceImpl
}
private String matchedPlanId(Waybill waybill, ContractManage contract) {
List<Map<String, Object>> plans = parseList(contract.getBillingPlanJson());
// 与费用计算共用客商过滤,避免多方汇总客户名先判定命中、再按客商滤空后静默跳过
List<Map<String, Object>> plans = billingPlansForWaybill(contract, waybill);
if (plans.isEmpty()) return null;
Map<String, Object> plan = resolveDefaultBillingPlan(plans, waybill.getTransportType());
if (plan == null) return null;
@@ -1331,8 +1331,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
}
@Override
public IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd) {
return voucherWaybillBatchMapper.selectVoucherWaybillBatchPage(page, AuthUtil.getTenantId(), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd,
public IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, Long projectId, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd) {
return voucherWaybillBatchMapper.selectVoucherWaybillBatchPage(page, AuthUtil.getTenantId(), batchNo, projectId, createUser, waybillCount, createTimeStart, createTimeEnd,
!TransportBusinessSupport.isAdministrator(), currentCarrierName());
}
@@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.common.excel.LenientDateParser;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
@@ -39,6 +40,7 @@ import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillImportBatch;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO;
import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.service.ICustomerArchiveService;
@@ -50,6 +52,7 @@ import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillImportBatchService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -173,6 +176,94 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
return toVO(batch);
}
@Override
public List<WaybillImportDuplicateVO> checkDuplicates(WaybillImportBatchRequest request) {
List<WaybillImportDuplicateVO> duplicates = new ArrayList<>();
if (request == null || Func.isEmpty(request.getRows()) || Func.isEmpty(request.getProjectId())) {
return duplicates;
}
List<Long> carrierIds = resolveCarrierIds(request);
String carrierName = resolveCarrierNames(carrierIds, request.getCarrierName());
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.eq(Waybill::getProjectId, request.getProjectId());
if (Func.isNotEmpty(request.getId())) {
// 追加导入时,本批次已落库的运单不算历史运单。
wrapper.ne(Waybill::getImportBatchId, request.getId());
}
List<Waybill> candidates = waybillService.list(wrapper);
if (Func.isEmpty(candidates)) {
return duplicates;
}
List<Map<String, Object>> rows = request.getRows();
for (int index = 0; index < rows.size(); index++) {
List<String> waybillNos = new ArrayList<>();
for (Waybill waybill : candidates) {
if (!isDuplicateRow(rows.get(index), waybill, carrierIds, carrierName)) continue;
String waybillNo = waybill.getWaybillNo();
if (Func.isNotEmpty(waybillNo) && !waybillNos.contains(waybillNo)) {
waybillNos.add(waybillNo);
}
}
if (waybillNos.isEmpty()) continue;
WaybillImportDuplicateVO duplicate = new WaybillImportDuplicateVO();
duplicate.setRowIndex(index);
duplicate.setWaybillNos(waybillNos);
duplicates.add(duplicate);
}
return duplicates;
}
/**
* 判重口径为导入必填项逐一相同:承运商、车牌、运输方式、起止地址、货物名称与类型、
* 数量及单位、实际发货与实际完成时间。历史运单这些字段常为空,双方都空按相同处理。
*/
private boolean isDuplicateRow(Map<String, Object> row, Waybill waybill, List<Long> carrierIds, String carrierName) {
if (row == null || waybill == null) return false;
if (!carrierMatches(waybill, carrierIds, carrierName)) return false;
if (!samePlate(vehicleNoFromRow(row), waybill.getVehicleNo())) return false;
String transportType = resolveTransportTypeKey(
stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"));
if (!sameText(transportType, waybill.getTransportType())) return false;
if (!sameText(stringValue(row, "departureAddress"), waybill.getDepartureAddress())) return false;
if (!sameText(stringValue(row, "arrivalAddress"), waybill.getArrivalAddress())) return false;
if (!sameText(stringValue(row, "cargoName"), waybill.getCargoName())) return false;
if (!sameText(stringValue(row, "cargoType"), waybill.getCargoType())) return false;
BigDecimal quantity = defaultQuantity(decimalValue(row, "quantity", "数量", "重量"));
if (quantity.compareTo(defaultQuantity(waybill.getQuantity())) != 0) return false;
String quantityUnit = stringValue(row, "quantityUnit", "数量单位");
if (!sameText(Func.isEmpty(quantityUnit) ? "吨" : quantityUnit, waybill.getQuantityUnit())) return false;
if (!Objects.equals(duplicateDate(row.get("actualStartDate")), waybill.getStartDate())) {
return false;
}
return Objects.equals(duplicateDate(row.get("actualEndDate")), waybill.getEndDate());
}
/** 承运商优先按档案 ID 比对;历史手工运单只有名称,退化为名称比对。 */
private boolean carrierMatches(Waybill waybill, List<Long> carrierIds, String carrierName) {
if (Func.isNotEmpty(carrierIds) && waybill.getCarrierId() != null) {
return carrierIds.contains(waybill.getCarrierId());
}
return sameText(waybill.getCarrierName(), carrierName);
}
private boolean sameText(String left, String right) {
String a = left == null ? "" : left.trim();
String b = right == null ? "" : right.trim();
return a.equalsIgnoreCase(b);
}
private boolean samePlate(String left, String right) {
return VehiclePlateSupport.normalize(left).equals(VehiclePlateSupport.normalize(right));
}
/** 判重时的日期解析不阻断查询:无法识别的日期返回 null,按「与空值相同」处理。 */
private LocalDate duplicateDate(Object value) {
if (value == null) return null;
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : LenientDateParser.parseDateLenientlyOrNull(text);
}
/** 草稿与确认导入共用落库流程,差异仅在于运单是否走校验以及是否生成应收应付明细。 */
private WaybillImportBatch persist(WaybillImportBatchRequest request, String importStatus) {
boolean draft = STATUS_DRAFT.equals(importStatus);
@@ -2174,7 +2174,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
if (isRoadTransport(waybill.getTransportType())) {
if ("承运商".equals(waybill.getCarrierType())) {
TransportBusinessSupport.validateRequired(waybill.getCarrierName(), "承运商不能为空");
} else if (!isBatchImport(waybill)) {
} else if (!isBatchImport(waybill) && !isDriverOptionalSource(waybill)) {
TransportBusinessSupport.validateRequired(waybill.getDriverName(), "司机不能为空");
}
}
@@ -2350,6 +2350,14 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return waybill != null && TransportBusinessSupport.DATA_SOURCE_BATCH_IMPORT.equals(waybill.getDataSource());
}
/**
* 计划调度生成的运单允许司机为空:调度明细里司机与手机号对全部承运类型都是选填。
* 数据来源已由 prepare 归一,多联总单调度同样归为计划调度。
*/
private boolean isDriverOptionalSource(Waybill waybill) {
return waybill != null && TransportBusinessSupport.DATA_SOURCE_PLAN_DISPATCH.equals(waybill.getDataSource());
}
private void fillJsonFromFlatFields(Waybill waybill) {
if (Func.isEmpty(waybill.getTaskEntryMode())) {
waybill.setTaskEntryMode("simple");
@@ -279,10 +279,39 @@ public final class ContractPartySupport {
if (plans.isEmpty()) {
return plans;
}
List<Map<String, Object>> matched = matchBillingPlans(plans, customerId, customerName, partyRowId);
if (!matched.isEmpty()) {
return matched;
}
// 运单客户名为合同多方汇总(顿号拼接)时,按各客商名称分别匹配绑定方案
if (Func.isEmpty(partyRowId) && customerId == null && Func.isNotEmpty(customerName) && customerName.contains("、")) {
Map<String, Map<String, Object>> deduped = new LinkedHashMap<>();
for (String part : customerName.split("、")) {
String segment = trimToNull(part);
if (Func.isEmpty(segment)) {
continue;
}
for (Map<String, Object> plan : matchBillingPlans(plans, null, segment, null)) {
deduped.putIfAbsent(billingPlanKey(plan), plan);
}
}
if (!deduped.isEmpty()) {
return new ArrayList<>(deduped.values());
}
}
// 历史方案未绑定客商时,不强制过滤,避免老数据无法计费
if (plans.stream().noneMatch(ContractPartySupport::hasCustomerBinding)) {
return plans;
}
return matched;
}
private static List<Map<String, Object>> matchBillingPlans(List<Map<String, Object>> plans, Long customerId,
String customerName, String partyRowId) {
String normalizedName = normalizePartyName(customerName);
String normalizedPartyRowId = trimToNull(partyRowId);
String customerIdText = customerId == null ? null : String.valueOf(customerId);
List<Map<String, Object>> matched = plans.stream()
return plans.stream()
.filter(plan -> {
if (Func.isNotEmpty(normalizedPartyRowId)
&& Objects.equals(trimToNull(plan.get("partyRowId")), normalizedPartyRowId)) {
@@ -296,11 +325,23 @@ public final class ContractPartySupport {
&& Objects.equals(normalizePartyName(trimToNull(plan.get("customerName"))), normalizedName);
})
.collect(Collectors.toCollection(ArrayList::new));
// 历史方案未绑定客商时,不强制过滤,避免老数据无法计费
if (matched.isEmpty() && plans.stream().noneMatch(ContractPartySupport::hasCustomerBinding)) {
return plans;
}
private static String billingPlanKey(Map<String, Object> plan) {
if (plan == null) {
return "";
}
return matched;
String id = trimToNull(plan.get("id"));
if (Func.isNotEmpty(id)) {
return id;
}
String planId = trimToNull(plan.get("planId"));
if (Func.isNotEmpty(planId)) {
return planId;
}
return String.valueOf(trimToNull(plan.get("customerName"))) + "|"
+ String.valueOf(trimToNull(plan.get("name"))) + "|"
+ String.valueOf(trimToNull(plan.get("planName")));
}
public static List<LoadingCarrierContractVO> expandCarrierOptions(Collection<ContractManage> contracts) {
@@ -507,6 +548,24 @@ public final class ContractPartySupport {
return names.isEmpty() ? null : String.join("、", names);
}
/**
* 拆分合同甲/乙/丙方汇总名称(顿号拼接的多方客商)。
*/
public static List<String> splitCustomerNames(String joinedNames) {
String text = trimToNull(joinedNames);
if (Func.isEmpty(text)) {
return List.of();
}
List<String> names = new ArrayList<>();
for (String part : text.split("、")) {
String name = trimToNull(part);
if (Func.isNotEmpty(name) && !names.contains(name)) {
names.add(name);
}
}
return names;
}
private static String toJsonOrNull(List<Map<String, Object>> rows) {
return rows == null || rows.isEmpty() ? null : JsonUtil.toJson(rows);
}
@@ -0,0 +1,163 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.system.feign.ISysClient;
import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.ITransportPlanService;
import org.springblade.transport.service.IWaybillService;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 批量导入行与历史运单的判重测试。
* <p>
* 口径为导入必填项逐一相同;历史手工运单的承运商档案 ID 与时间列常为空,
* 这类字段双方都空时按相同处理,否则手工运单永远判不出重复。
*/
class WaybillImportDuplicateTest {
private static final Long PROJECT_ID = 1001L;
private static final Long CARRIER_ID = 2002L;
private static final String CARRIER_NAME = "广西驰远干线运输有限公司";
private static final String VEHICLE_NO = "桂A5D61N";
private static final String CARGO_NAME = "混煤";
private static final String DEPARTURE = "鄂尔多斯市达拉特旗树林召镇";
private static final String ARRIVAL = "南宁市江南区沙井大道";
private IWaybillService waybillService;
private WaybillImportBatchServiceImpl service;
@BeforeEach
void setUp() {
waybillService = mock(IWaybillService.class);
service = new WaybillImportBatchServiceImpl(
waybillService,
mock(ICustomerArchiveService.class),
mock(IProjectApplyService.class),
mock(ITransportPlanService.class),
mock(IReceivablePayableDetailService.class),
mock(ILoadingManageService.class),
mock(ISysClient.class),
mock(IDriverService.class));
}
@Test
@DisplayName("必填项全一致:返回命中的已有运单号")
void reportsDuplicateWaybillNo() {
stubCandidates(waybill("YD-20261023-0012"));
WaybillImportBatchRequest req = request(importRow());
List<WaybillImportDuplicateVO> duplicates = service.checkDuplicates(req);
assertEquals(1, duplicates.size(), "rows=" + req.getRows() + " projectId=" + req.getProjectId());
assertEquals(0, duplicates.get(0).getRowIndex());
assertEquals(List.of("YD-20261023-0012"), duplicates.get(0).getWaybillNos());
}
@Test
@DisplayName("数量不同:不判为重复")
void ignoresDifferentQuantity() {
stubCandidates(waybill("YD-20261023-0012"));
Map<String, Object> row = importRow();
row.put("quantity", "21");
assertTrue(service.checkDuplicates(request(row)).isEmpty());
}
@Test
@DisplayName("车牌大小写与空格不同:仍判为重复")
void ignoresPlateFormatting() {
stubCandidates(waybill("YD-20261023-0012"));
Map<String, Object> row = importRow();
row.put("vehicleNo", " 桂a5d61n ");
assertEquals(1, service.checkDuplicates(request(row)).size());
}
@Test
@DisplayName("历史运单没有承运商档案 ID:退化为承运商名称比对")
void matchesByCarrierNameWhenHistoryHasNoId() {
Waybill waybill = waybill("YD-20261023-0012");
waybill.setCarrierId(null);
stubCandidates(waybill);
assertEquals(1, service.checkDuplicates(request(importRow())).size());
}
@Test
@DisplayName("项目为空:直接返回空,不查库")
void returnsEmptyWithoutProject() {
WaybillImportBatchRequest request = request(importRow());
request.setProjectId(null);
assertTrue(service.checkDuplicates(request).isEmpty());
}
private void stubCandidates(Waybill... waybills) {
when(waybillService.list(any(Wrapper.class))).thenReturn(List.of(waybills));
}
private WaybillImportBatchRequest request(Map<String, Object> row) {
WaybillImportBatchRequest request = new WaybillImportBatchRequest();
request.setProjectId(PROJECT_ID);
request.setCarrierId(CARRIER_ID);
request.setCarrierName(CARRIER_NAME);
request.setRows(List.of(row));
return request;
}
private Map<String, Object> importRow() {
Map<String, Object> row = new HashMap<>();
row.put("vehicleNo", VEHICLE_NO);
// 无 Spring 上下文时字典读不到,运输方式按“名称即键”兜底,故此处直接给内部键。
row.put("transportType", "road");
row.put("departureAddress", DEPARTURE);
row.put("arrivalAddress", ARRIVAL);
row.put("cargoName", CARGO_NAME);
row.put("cargoType", "煤制品");
row.put("quantity", "20");
row.put("quantityUnit", "吨");
row.put("actualStartDate", "2026-10-13");
row.put("actualEndDate", "2026-10-14");
return row;
}
private Waybill waybill(String waybillNo) {
Waybill waybill = new Waybill();
waybill.setWaybillNo(waybillNo);
waybill.setProjectId(PROJECT_ID);
waybill.setCarrierId(CARRIER_ID);
waybill.setCarrierName(CARRIER_NAME);
waybill.setVehicleNo(VEHICLE_NO);
waybill.setTransportType("road");
waybill.setDepartureAddress(DEPARTURE);
waybill.setArrivalAddress(ARRIVAL);
waybill.setCargoName(CARGO_NAME);
waybill.setCargoType("煤制品");
waybill.setQuantity(new BigDecimal("20.00"));
waybill.setQuantityUnit("吨");
waybill.setStartDate(LocalDate.of(2026, 10, 13));
waybill.setEndDate(LocalDate.of(2026, 10, 14));
return waybill;
}
}