认证退出与操作日志、密码规则,以及运输业务多项修正

This commit is contained in:
2026-09-22 12:03:59 +08:00
parent 5254a46de8
commit 14c1d9dff0
21 changed files with 790 additions and 26 deletions
@@ -25,6 +25,7 @@
*/
package org.springblade.auth.config;
import org.springblade.auth.filter.LogoutLogFilter;
import org.springblade.auth.granter.IamAwareTokenGranterFactory;
import org.springblade.auth.handler.BladeAuthorizationHandler;
import org.springblade.auth.handler.BladeLockHandler;
@@ -53,9 +54,11 @@ import org.springblade.core.tenant.BladeTenantProperties;
import org.springblade.system.feign.IUserClient;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
@@ -88,6 +91,16 @@ public class BladeAuthConfiguration {
return new BladeLogHandler(authLogClient, bladeProperties, serverInfo);
}
@Bean
public FilterRegistrationBean<LogoutLogFilter> logoutLogFilter(BladeLogHandler logHandler) {
FilterRegistrationBean<LogoutLogFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new LogoutLogFilter(logHandler));
registration.addUrlPatterns("/*");
registration.setName("logoutLogFilter");
registration.setOrder(Ordered.LOWEST_PRECEDENCE - 100);
return registration;
}
@Bean
public PasswordHandler passwordHandler(OAuth2Properties properties) {
return new BladePasswordHandler(properties);
@@ -0,0 +1,80 @@
/**
* 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.auth.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springblade.auth.handler.BladeLogHandler;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springframework.core.Ordered;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 退出登录日志过滤器
* 在清除 Token 前解析当前用户并异步写入认证日志
*
* @author BladeX
*/
@RequiredArgsConstructor
public class LogoutLogFilter extends OncePerRequestFilter implements Ordered {
private static final String LOGOUT_PATH = "/oauth/logout";
private final BladeLogHandler logHandler;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return StringUtil.isBlank(path) || !path.contains(LOGOUT_PATH);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 必须在 logout 清 token 之前取用户,否则无法关联账号
BladeUser user = AuthUtil.getUser();
try {
filterChain.doFilter(request, response);
} finally {
if (user != null) {
logHandler.handleLogoutLog(user, request);
}
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 100;
}
}
@@ -29,12 +29,15 @@ import org.springblade.core.launch.props.BladeProperties;
import org.springblade.core.launch.server.ServerInfo;
import org.springblade.core.oauth2.provider.OAuth2Request;
import org.springblade.core.oauth2.service.OAuth2User;
import org.springblade.core.secure.BladeUser;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.system.pojo.entity.AuthLog;
import org.springblade.system.feign.IAuthLogClient;
import org.springblade.system.pojo.entity.AuthLog;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -42,7 +45,7 @@ import java.util.concurrent.CompletableFuture;
/**
* 认证日志处理器
* 在用户认证成功时异步记录登录日志
* 在用户认证成功/退出时异步记录登录、退出日志
*
* @author BladeX
*/
@@ -50,6 +53,11 @@ import java.util.concurrent.CompletableFuture;
@RequiredArgsConstructor
public class BladeLogHandler {
/**
* 退出登录授权类型标识
*/
public static final String GRANT_TYPE_LOGOUT = "logout";
private final IAuthLogClient authLogClient;
private final BladeProperties bladeProperties;
private final ServerInfo serverInfo;
@@ -72,6 +80,26 @@ public class BladeLogHandler {
});
}
/**
* 记录退出登录日志
*
* @param user 当前登录用户
* @param request HTTP 请求
*/
public void handleLogoutLog(BladeUser user, HttpServletRequest request) {
if (user == null) {
return;
}
CompletableFuture.runAsync(() -> {
try {
AuthLog authLog = buildLogoutLog(user, request);
authLogClient.saveAuthLog(authLog);
} catch (Exception exception) {
log.error("记录退出日志异常:{}", exception.getMessage(), exception);
}
});
}
/**
* 构建认证日志实体
*
@@ -96,4 +124,38 @@ public class BladeLogHandler {
return authLog;
}
/**
* 构建退出日志实体
*
* @param user 当前登录用户
* @param request HTTP 请求
* @return AuthLog
*/
private AuthLog buildLogoutLog(BladeUser user, HttpServletRequest request) {
AuthLog authLog = new AuthLog();
authLog.setUserId(user.getUserId());
authLog.setTenantId(user.getTenantId());
authLog.setServiceId(bladeProperties.getName());
authLog.setServerIp(serverInfo.getIpWithPort());
authLog.setServerHost(serverInfo.getHostName());
authLog.setEnv(bladeProperties.getEnv());
authLog.setAccount(user.getAccount());
authLog.setRealName(resolveRealName(user));
authLog.setGrantType(GRANT_TYPE_LOGOUT);
authLog.setRemoteIp(WebUtil.getIP(request));
authLog.setUserAgent(WebUtil.getUserAgent(request));
authLog.setLoginTime(DateUtil.now());
return authLog;
}
private String resolveRealName(BladeUser user) {
if (StringUtil.isNotBlank(user.getNickName())) {
return user.getNickName();
}
if (StringUtil.isNotBlank(user.getUserName())) {
return user.getUserName();
}
return user.getAccount();
}
}
+10
View File
@@ -31,6 +31,16 @@
的 WebFlux 网关里启动即崩。业务服务经 blade-core-boot 自带 starter-log,运行时不受影响。 -->
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-core-auto</artifactId>
@@ -0,0 +1,145 @@
/**
* 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.common.aspect;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springblade.core.log.annotation.ApiLog;
import org.springblade.core.log.constant.EventConstant;
import org.springblade.core.log.event.ApiLogEvent;
import org.springblade.core.log.model.LogApi;
import org.springblade.core.log.utils.LogAbstractUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springframework.core.annotation.AnnotationUtils;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* 增删改操作日志切面
* 自动拦截 Controller 中的 save/submit/update/remove/delete 等方法,写入 blade_log_api
* 已标注 {@link ApiLog} 的方法跳过,避免重复记录
*
* @author BladeX
*/
@Slf4j
@Aspect
public class OperationApiLogAspect {
@Around("""
execution(* org.springblade..controller..*.save*(..))
|| execution(* org.springblade..controller..*.submit*(..))
|| execution(* org.springblade..controller..*.update*(..))
|| execution(* org.springblade..controller..*.remove*(..))
|| execution(* org.springblade..controller..*.delete*(..))
""")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!shouldRecord(method)) {
return point.proceed();
}
String className = point.getTarget().getClass().getName();
String methodName = method.getName();
String title = resolveTitle(point.getTarget().getClass(), method);
long beginTime = System.currentTimeMillis();
Object result = point.proceed();
long time = System.currentTimeMillis() - beginTime;
try {
publishEvent(methodName, className, title, time);
} catch (Exception e) {
log.warn("记录操作日志失败: {}#{} - {}", className, methodName, e.getMessage());
}
return result;
}
private boolean shouldRecord(Method method) {
if (method.getAnnotation(ApiLog.class) != null) {
return false;
}
HttpServletRequest request = WebUtil.getRequest();
if (request == null) {
return true;
}
String httpMethod = request.getMethod();
return !"GET".equalsIgnoreCase(httpMethod)
&& !"HEAD".equalsIgnoreCase(httpMethod)
&& !"OPTIONS".equalsIgnoreCase(httpMethod);
}
private String resolveTitle(Class<?> targetClass, Method method) {
Tag tag = AnnotationUtils.findAnnotation(targetClass, Tag.class);
Operation operation = AnnotationUtils.findAnnotation(method, Operation.class);
String module = tag == null ? null : firstNonBlank(tag.name(), tag.description());
String action = operation == null ? null : firstNonBlank(operation.summary(), operation.description());
if (StringUtil.isNotBlank(module) && StringUtil.isNotBlank(action)) {
return module + "-" + action;
}
if (StringUtil.isNotBlank(action)) {
return action;
}
String simpleName = targetClass.getSimpleName().replace("Controller", "");
return simpleName + "-" + method.getName();
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (StringUtil.isNotBlank(value)) {
return value.trim();
}
}
return null;
}
private void publishEvent(String methodName, String methodClass, String title, long time) {
HttpServletRequest request = WebUtil.getRequest();
LogApi logApi = new LogApi();
logApi.setType(BladeConstant.LOG_NORMAL_TYPE);
logApi.setTitle(title);
logApi.setTime(String.valueOf(time));
logApi.setMethodClass(methodClass);
logApi.setMethodName(methodName);
LogAbstractUtil.addRequestInfoToLog(request, logApi);
Map<String, Object> event = new HashMap<>(16);
event.put(EventConstant.EVENT_LOG, logApi);
SpringUtil.publishEvent(new ApiLogEvent(event));
}
}
@@ -26,16 +26,27 @@
package org.springblade.common.config;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springblade.common.aspect.OperationApiLogAspect;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
/**
* 公共封装包配置类
*
* @author Chill
*/
@Configuration(proxyBeanMethods = false)
@AllArgsConstructor
@AutoConfiguration
public class BladeCommonConfiguration {
@Bean
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(name = "org.springblade.core.log.event.ApiLogEvent")
@ConditionalOnProperty(value = "blade.log.operation.enabled", havingValue = "true", matchIfMissing = true)
public OperationApiLogAspect operationApiLogAspect() {
return new OperationApiLogAspect();
}
}
@@ -0,0 +1,106 @@
/**
* 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.common.utils;
import org.springblade.core.tool.utils.StringUtil;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
/**
* 登录密码强度规则工具
* 规则:大于8位,同时包含字母、数字、特殊字符(.!@#$%^&*
*
* @author BladeX
*/
public final class PasswordRuleUtil {
/**
* 允许的特殊字符集合
*/
public static final String SPECIAL_CHARS = ".!@#$%^&*";
/**
* 最小长度(大于 8 位 => 至少 9 位)
*/
public static final int MIN_LENGTH = 9;
/**
* 自动生成密码长度
*/
public static final int GENERATE_LENGTH = 10;
/**
* 规则提示文案
*/
public static final String RULE_MESSAGE = "密码须大于8位,且同时包含字母、数字和特殊字符(.!@#$%^&*";
private static final String LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private static final String DIGITS = "23456789";
private static final String ALL_CHARS = LETTERS + DIGITS + SPECIAL_CHARS;
private static final Pattern PASSWORD_PATTERN = Pattern.compile(
"^(?=.*[A-Za-z])(?=.*\\d)(?=.*[.!@#$%^&*]).{" + MIN_LENGTH + ",}$"
);
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private PasswordRuleUtil() {
}
/**
* 校验密码是否符合强度规则
*
* @param password 明文密码
* @return true-符合
*/
public static boolean isValid(String password) {
return StringUtil.isNotBlank(password) && PASSWORD_PATTERN.matcher(password).matches();
}
/**
* 生成符合规则的随机密码
*
* @return 随机密码
*/
public static String generate() {
List<Character> chars = new ArrayList<>(GENERATE_LENGTH);
chars.add(LETTERS.charAt(SECURE_RANDOM.nextInt(LETTERS.length())));
chars.add(DIGITS.charAt(SECURE_RANDOM.nextInt(DIGITS.length())));
chars.add(SPECIAL_CHARS.charAt(SECURE_RANDOM.nextInt(SPECIAL_CHARS.length())));
for (int i = chars.size(); i < GENERATE_LENGTH; i++) {
chars.add(ALL_CHARS.charAt(SECURE_RANDOM.nextInt(ALL_CHARS.length())));
}
Collections.shuffle(chars, SECURE_RANDOM);
StringBuilder password = new StringBuilder(GENERATE_LENGTH);
for (Character ch : chars) {
password.append(ch);
}
return password.toString();
}
}
@@ -0,0 +1 @@
org.springblade.common.config.BladeCommonConfiguration
@@ -59,6 +59,9 @@ public class CustomerReceiptAccount extends TenantEntity {
@Schema(description = "开户行名称")
private String bankName;
@Schema(description = "联行号")
private String cnapsCode;
@Schema(description = "银行账号")
private String bankAccount;
@@ -39,6 +39,7 @@ import org.springblade.common.constant.DataStatusEnum;
import org.bouncycastle.util.encoders.Hex;
import org.springblade.common.constant.ParamConstant;
import org.springblade.common.constant.TenantConstant;
import org.springblade.common.utils.PasswordRuleUtil;
import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
@@ -76,7 +77,6 @@ import org.springblade.system.wrapper.UserWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
@@ -106,9 +106,6 @@ import static org.springblade.core.tenant.TenantGuard.EntityType.USER;
@Slf4j
public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implements IUserService {
private static final String GUEST_NAME = "guest";
private static final String PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
private static final int RANDOM_PASSWORD_LENGTH = 8;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final String IAM_DEFAULT_DICT_CODE = "iam_default";
private static final String IAM_DEFAULT_ROLE_NAME = "默认角色";
private static final String IAM_DEFAULT_DEPT_NAME = "默认部门";
@@ -588,7 +585,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
List<User> userList = TenantGuard.verifyBatch(this, idList, USER);
Map<String, String> passwordMap = new LinkedHashMap<>();
for (User user : userList) {
String password = randomPassword();
String password = PasswordRuleUtil.generate();
User updateUser = new User();
updateUser.setPassword(DigestUtil.encrypt(password));
updateUser.setUpdateTime(DateUtil.now());
@@ -604,6 +601,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
if (!Objects.equals(password, password2)) {
throw new ServiceException("两次输入密码不一致!");
}
validatePlainPassword(password);
User user = TenantGuard.verify(this, userId, USER);
User updateUser = new User();
updateUser.setPassword(DigestUtil.encrypt(password));
@@ -672,7 +670,8 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
if (user.getId() != null) {
this.updateUser(user);
} else {
user.setPassword(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
// 空密码交由 saveUser 按规则填充(初始密码不合规时自动生成)
user.setPassword(null);
this.submit(user);
}
});
@@ -1064,8 +1063,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
}
}
if (Func.isEmpty(user.getPassword())) {
user.setPassword(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
String initPassword = ParamCache.getValue(DEFAULT_PARAM_PASSWORD);
user.setPassword(PasswordRuleUtil.isValid(initPassword) ? initPassword : PasswordRuleUtil.generate());
}
validatePlainPassword(user.getPassword());
user.setPassword(DigestUtil.encrypt(user.getPassword()));
Long userCount = baseMapper.selectCount(Wrappers.<User>query().lambda().eq(User::getTenantId, tenantId).eq(User::getAccount, user.getAccount()));
if (userCount > 0L && Func.isEmpty(user.getId())) {
@@ -1147,12 +1148,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
// }
}
private String randomPassword() {
StringBuilder password = new StringBuilder(RANDOM_PASSWORD_LENGTH);
for (int index = 0; index < RANDOM_PASSWORD_LENGTH; index++) {
password.append(PASSWORD_CHARS.charAt(SECURE_RANDOM.nextInt(PASSWORD_CHARS.length())));
private void validatePlainPassword(String password) {
if (!PasswordRuleUtil.isValid(password)) {
throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
}
return password.toString();
}
}
@@ -37,6 +37,7 @@ import org.springblade.transport.pojo.vo.ReceivablePayableCargoFeeVO;
import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO;
import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Collection;
@@ -82,6 +83,12 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */
void generateForCompletedWaybills(List<Long> waybillIds);
/**
* 运单维护里程后,同步未挂结算单的应收应付明细里程;
* 对按里程、按吨·公里计费的费用行按新里程重新计算。
*/
void syncMileageFromWaybill(Long waybillId, BigDecimal mileage);
/**
* 批量导入完成运单后按合同系统计费模式生成应收、应付明细。
* <p>与导入事务共用同一事务,运单尚未提交,因此直接传入实体而非主键。</p>
@@ -657,6 +657,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
if (contractManage.getInvoiceCycle() != null && contractManage.getInvoiceCycle() <= 0) {
contractManage.setInvoiceCycle(null);
}
BigDecimal contractAmount = contractManage.getContractAmount();
if (contractAmount != null && contractAmount.compareTo(BigDecimal.valueOf(-1)) == 0) {
contractManage.setContractAmount(null);
}
}
private void validateDraft(ContractManage contractManage) {
@@ -621,6 +621,23 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
if (Func.isEmpty(accountVO.getAccountName()) && Func.isEmpty(accountVO.getBankAccount())) {
continue;
}
if (Func.isEmpty(accountVO.getAccountName())) {
throw new ServiceException("收款单位名称不能为空");
}
if (Func.isEmpty(accountVO.getAccountHolderName())) {
throw new ServiceException("开户人姓名不能为空");
}
if (Func.isEmpty(accountVO.getBankName())) {
throw new ServiceException("开户行不能为空");
}
if (Func.isEmpty(accountVO.getCnapsCode())) {
throw new ServiceException("联行号不能为空");
}
if (Func.isEmpty(accountVO.getBankAccount())) {
throw new ServiceException("收款账号不能为空");
}
validateLength(accountVO.getCnapsCode(), 32, "联行号最多32个字符");
validateLength(accountVO.getRemark(), 200, "收款信息备注最多200个字符");
CustomerReceiptAccount account = Objects.requireNonNull(BeanUtil.copyProperties(accountVO, CustomerReceiptAccount.class));
account.setId(IdWorker.getId());
account.setCustomerId(customerId);
@@ -598,7 +598,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
if (Func.isEmpty(request.getRows())) {
throw new ServiceException("请填写需要调整的费用行");
}
String changeReason = requiredText(limitRemark(request.getChangeReason(), 200), "调整原因");
String changeReason = limitRemark(request.getChangeReason(), 200);
Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream()
.collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity()));
if (request.getRows().size() != existingMap.size()) {
@@ -198,6 +198,209 @@ public class ReceivablePayableDetailServiceImpl
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncMileageFromWaybill(Long waybillId, BigDecimal mileage) {
if (waybillId == null || mileage == null) {
return;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
return;
}
BigDecimal normalizedMileage = normalizeGeneratedMileage(mileage);
Set<Long> detailIds = new LinkedHashSet<>();
list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getWaybillId, waybillId)
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo)
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo)
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")))
.forEach(detail -> detailIds.add(detail.getId()));
List<ReceivablePayableCargoFee> relatedFees = cargoFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getIsDeleted, 0)
.eq(ReceivablePayableCargoFee::getWaybillId, waybillId));
relatedFees.stream()
.map(ReceivablePayableCargoFee::getDetailId)
.filter(Objects::nonNull)
.forEach(detailIds::add);
if (detailIds.isEmpty()) {
return;
}
for (Long detailId : detailIds) {
ReceivablePayableDetail detail = getById(detailId);
if (detail == null || Objects.equals(detail.getIsDeleted(), 1)
|| !"pending".equals(detail.getSettlementStatus())
|| Func.isNotEmpty(detail.getPreSettlementNo())
|| Func.isNotEmpty(detail.getFormalSettlementNo())) {
continue;
}
syncDetailMileage(detail, waybill, normalizedMileage);
}
}
private void syncDetailMileage(ReceivablePayableDetail detail, Waybill waybill, BigDecimal mileage) {
List<ReceivablePayableCargoFee> rows = activeCargoFees(detail.getId());
if (rows.isEmpty()) {
if (Objects.equals(detail.getWaybillId(), waybill.getId())) {
detail.setMileage(mileage);
updateById(detail);
}
return;
}
List<String> changes = new ArrayList<>();
boolean feeChanged = false;
for (ReceivablePayableCargoFee fee : rows) {
boolean related = Objects.equals(fee.getWaybillId(), waybill.getId())
|| (fee.getWaybillId() == null && Objects.equals(detail.getWaybillId(), waybill.getId()));
if (!related) {
continue;
}
BigDecimal oldMileage = normalizeGeneratedMileage(fee.getMileage());
fee.setMileage(mileage);
if (isMileageBasedElement(fee.getBillingFactor()) && !isManualFee(fee)
&& recalculateMileageBasedFee(detail, fee, waybill, oldMileage)) {
changes.add("【运单维护里程】按" + fee.getBillingFactor() + "重新计算费用");
}
cargoFeeMapper.updateById(fee);
feeChanged = true;
appendChange(changes, "里程", oldMileage, mileage);
}
if (!feeChanged && !Objects.equals(detail.getWaybillId(), waybill.getId())) {
return;
}
refreshAdjustedDetail(detail, activeCargoFees(detail.getId()));
if (Objects.equals(detail.getWaybillId(), waybill.getId())) {
detail.setMileage(mileage);
updateById(detail);
}
if (!changes.isEmpty()) {
saveChangeRecord(detail, String.join("", changes), "运单维护里程同步", "0001");
}
}
/**
* 按最新运单里程重算「按里程 / 按吨·公里」费用行。
*/
private boolean recalculateMileageBasedFee(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee,
Waybill sourceWaybill, BigDecimal previousMileage) {
Waybill calcWaybill = waybillForMileageRecalc(sourceWaybill, fee, previousMileage);
List<Map<String, Object>> rules = parseList(fee.getBillingRulesJson());
if (rules.isEmpty()) {
ContractManage contract = contractManageService.getById(detail.getContractId());
if (contract != null) {
rules = matchingAdjustedRules(contract, fee, calcWaybill);
if (!rules.isEmpty()) {
fee.setBillingRulesJson(JsonUtil.toJson(rules));
}
}
}
if (rules.isEmpty()) {
log.warn("运单维护里程重算跳过:未找到计费规则,detailId={}, feeId={}", detail.getId(), fee.getId());
return false;
}
Map<String, BigDecimal> calculatedAmounts = new LinkedHashMap<>();
BigDecimal calculatedFreight = BigDecimal.ZERO;
boolean freightRuleMatched = false;
Map<String, Object> measureRule = null;
for (Map<String, Object> rule : rules) {
BigDecimal amount = calculateRule(rule, calcWaybill);
if (amount == null) {
continue;
}
String feeItem = stringValue(rule, "feeItem", "费用");
calculatedAmounts.merge(feeItem, amount, BigDecimal::add);
if (measureRule == null || isFreightRule(rule)) {
measureRule = rule;
}
if (isFreightRule(rule)) {
calculatedFreight = calculatedFreight.add(amount);
freightRuleMatched = true;
}
}
if (calculatedAmounts.isEmpty() || measureRule == null) {
log.warn("运单维护里程重算跳过:计费规则无法试算,detailId={}, feeId={}", detail.getId(), fee.getId());
return false;
}
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
parseMap(fee.getFeeItemsJson()).forEach((name, value) -> feeItems.put(name, decimal(value)));
calculatedAmounts.forEach(feeItems::put);
fee.setTransportQuantity(resolveStoredTransportQuantity(measureRule, calcWaybill));
fee.setUnitPrice(resolveCalculatedUnitPrice(measureRule, calcWaybill));
fee.setBillingFactor(stringValue(measureRule, "billingElement", fee.getBillingFactor()));
fee.setBillingType(stringValue(measureRule, "billingType", fee.getBillingType()));
fee.setPriceUnit(stringValue(measureRule, "billingUnit", fee.getPriceUnit()));
if (freightRuleMatched) {
fee.setFreightAmount(calculatedFreight);
}
fee.setFeeItemsJson(JsonUtil.toJson(feeItems));
BigDecimal afterAmount = adjustedAfterAmount(money(fee.getFreightAmount()), feeItems);
fee.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
fee.setAfterAmount(afterAmount);
return true;
}
private Waybill waybillForMileageRecalc(Waybill sourceWaybill, ReceivablePayableCargoFee fee,
BigDecimal previousMileage) {
Waybill calcWaybill = Objects.requireNonNull(BeanUtil.copyProperties(sourceWaybill, Waybill.class));
calcWaybill.setMileage(sourceWaybill.getMileage());
String element = fee.getBillingFactor();
if (!"按吨·公里".equals(element) && !"按重量".equals(element)
&& !"按数量".equals(element) && !"按体积".equals(element)) {
return calcWaybill;
}
List<Map<String, Object>> matchedGoods = parseList(sourceWaybill.getGoodsJson()).stream()
.filter(goods -> matchesFeeCargo(goods, fee))
.toList();
if (!matchedGoods.isEmpty()) {
Map<String, Object> goods = matchedGoods.get(0);
calcWaybill.setGoodsJson(JsonUtil.toJson(List.of(goods)));
calcWaybill.setCargoName(stringValue(goods, "cargoName", fee.getCargoName()));
calcWaybill.setCargoType(stringValue(goods, "cargoType", fee.getCargoType()));
calcWaybill.setSpecification(stringValue(goods, "specification", fee.getSpecification()));
calcWaybill.setModel(stringValue(goods, "model", fee.getModel()));
calcWaybill.setQuantity(decimal(goods.get("quantity")));
calcWaybill.setQuantityUnit(stringValue(goods, "quantityUnit", fee.getQuantityUnit()));
return calcWaybill;
}
Map<String, Object> goods = new LinkedHashMap<>();
goods.put("cargoName", fee.getCargoName());
goods.put("cargoType", fee.getCargoType());
goods.put("specification", fee.getSpecification());
goods.put("model", fee.getModel());
goods.put("quantityUnit", fee.getQuantityUnit());
BigDecimal quantity = money(fee.getTransportQuantity());
if ("按吨·公里".equals(element)) {
BigDecimal baseMileage = money(previousMileage);
if (baseMileage.signum() > 0) {
quantity = quantity.divide(baseMileage, 8, RoundingMode.HALF_UP);
}
}
goods.put("quantity", quantity);
if ("按体积".equals(element)) {
goods.put("volume", quantity);
}
calcWaybill.setGoodsJson(JsonUtil.toJson(List.of(goods)));
calcWaybill.setCargoName(fee.getCargoName());
calcWaybill.setCargoType(fee.getCargoType());
calcWaybill.setSpecification(fee.getSpecification());
calcWaybill.setModel(fee.getModel());
calcWaybill.setQuantity(quantity);
calcWaybill.setQuantityUnit(fee.getQuantityUnit());
return calcWaybill;
}
private boolean matchesFeeCargo(Map<String, Object> goods, ReceivablePayableCargoFee fee) {
return Objects.equals(stringValue(goods, "cargoName", ""), Objects.toString(fee.getCargoName(), ""))
&& Objects.equals(stringValue(goods, "cargoType", ""), Objects.toString(fee.getCargoType(), ""))
&& Objects.equals(stringValue(goods, "specification", ""), Objects.toString(fee.getSpecification(), ""))
&& Objects.equals(stringValue(goods, "model", ""), Objects.toString(fee.getModel(), ""))
&& Objects.equals(stringValue(goods, "quantityUnit", ""), Objects.toString(fee.getQuantityUnit(), ""));
}
@Override
public ReceivablePayableFeeDetailVO feeDetail(Long id) {
ReceivablePayableDetail detail = getExisting(id);
@@ -890,11 +1093,68 @@ public class ReceivablePayableDetailServiceImpl
Map<String, Object> plan = resolveDefaultBillingPlan(plans, waybill.getTransportType());
if (plan == null) return null;
if (!(plan.get("rules") instanceof List<?> rules)) return null;
boolean matched = rules.stream().anyMatch(value -> value instanceof Map<?, ?> raw && matchesRule(raw, waybill));
boolean matched = rules.stream().anyMatch(value -> {
if (!(value instanceof Map<?, ?> raw)) return false;
Map<String, Object> rule = toRuleMap(raw);
return feeWaybills(rule, waybill).stream()
.anyMatch(feeWaybill -> matchesBillingRule(raw, rule, feeWaybill));
});
if (!matched) return null;
return "__matched__";
}
private Map<String, Object> toRuleMap(Map<?, ?> raw) {
Map<String, Object> rule = new LinkedHashMap<>();
raw.forEach((key, item) -> rule.put(String.valueOf(key), item));
return rule;
}
/**
* 费用生成规则命中:
* 1. 货物需满足匹配规则(始发/目的/货类/货名)
* 2. 普通计费要素还要求货物数量单位与计费单位一致
* 3. 按里程、按吨·公里按运单里程(×重量)另行计算,不校验数量单位
*/
private boolean matchesBillingRule(Map<?, ?> raw, Map<String, Object> rule, Waybill feeWaybill) {
if (!matchesRule(raw, feeWaybill)) {
return false;
}
String element = stringValue(rule, "billingElement", "");
if (isMileageBasedElement(element)) {
return true;
}
return matchesBillingUnit(rule, feeWaybill);
}
private boolean isMileageBasedElement(String element) {
return "按里程".equals(element) || "按吨·公里".equals(element);
}
private boolean matchesBillingUnit(Map<String, Object> rule, Waybill feeWaybill) {
String billingUnit = stringValue(rule, "billingUnit", "").trim();
if (billingUnit.isEmpty()) {
return true;
}
String quantityUnit = resolveQuantityUnit(feeWaybill);
if (quantityUnit.isEmpty()) {
return false;
}
return billingUnit.equals(quantityUnit);
}
private String resolveQuantityUnit(Waybill feeWaybill) {
String quantityUnit = feeWaybill.getQuantityUnit() == null ? "" : feeWaybill.getQuantityUnit().trim();
if (!quantityUnit.isEmpty()) {
return quantityUnit;
}
List<Map<String, Object>> goods = parseList(feeWaybill.getGoodsJson());
return goods.stream()
.map(item -> stringValue(item, "quantityUnit", "").trim())
.filter(Func::isNotEmpty)
.findFirst()
.orElse("");
}
private boolean matchesRule(Map<?, ?> raw, Waybill waybill) {
Object conditionValue = raw.get("matchCondition");
if (!(conditionValue instanceof Map<?, ?> condition) || !hasConfiguredMatchCondition(condition)) return true;
@@ -1387,10 +1647,10 @@ public class ReceivablePayableDetailServiceImpl
Set<List<String>> freightBillingCargoKeys = new LinkedHashSet<>();
for (Object value : (List<?>) plan.get("rules")) {
if (!(value instanceof Map<?, ?> raw)) continue;
Map<String, Object> rule = new LinkedHashMap<>();
raw.forEach((key, item) -> rule.put(String.valueOf(key), item));
Map<String, Object> rule = toRuleMap(raw);
for (Waybill feeWaybill : feeWaybills(rule, waybill)) {
if (matchOnly && !matchesRule(raw, feeWaybill)) continue;
// 统一生成:匹配规则内货物 + 数量单位一致;按里程/按吨·公里跳过单位匹配
if (!matchesBillingRule(raw, rule, feeWaybill)) continue;
BigDecimal amount = calculateRule(rule, feeWaybill);
if (amount == null) continue;
String feeItem = stringValue(rule, "feeItem", "费用");
@@ -1446,13 +1706,25 @@ public class ReceivablePayableDetailServiceImpl
private void fillCalculatedBillingFields(ReceivablePayableCargoFee fee, Waybill feeWaybill,
Map<String, Object> rule) {
fee.setBillingFactor(stringValue(rule, "billingElement", ""));
String element = stringValue(rule, "billingElement", "");
fee.setBillingFactor(element);
fee.setBillingType(stringValue(rule, "billingType", ""));
fee.setTransportQuantity(measure(rule, feeWaybill));
fee.setTransportQuantity(resolveStoredTransportQuantity(rule, feeWaybill));
fee.setPriceUnit(stringValue(rule, "billingUnit", feeWaybill.getPriceUnit()));
fee.setUnitPrice(resolveCalculatedUnitPrice(rule, feeWaybill));
}
/**
* 费用行运输量展示值:按车辆/固定金额的计费量仅试算用(为 1),不回写到运输量字段。
*/
private BigDecimal resolveStoredTransportQuantity(Map<String, Object> rule, Waybill waybill) {
String element = stringValue(rule, "billingElement", "");
if ("按车辆".equals(element) || "固定金额(整单一口价)".equals(element)) {
return null;
}
return measure(rule, waybill);
}
/**
* 解析费用行展示用的实际命中单价。
* 区间计费的规则默认单价仅用于兜底,费用行应展示当前计费量命中的区间单价。
@@ -1635,7 +1907,9 @@ public class ReceivablePayableDetailServiceImpl
return switch (element) {
case "按体积" -> goods.stream().map(this::goodsVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
case "按车辆", "固定金额(整单一口价)" -> BigDecimal.ONE;
// 按里程:计费量=运单里程;固定单价/区间单价/阶梯等均基于该计费量
case "按里程" -> money(waybill.getMileage());
// 按吨·公里:计费量=货物重量数量 × 运单里程
case "按吨·公里" -> goodsQuantity(goods).multiply(money(waybill.getMileage()));
case "按数量" -> goodsQuantity(goods);
default -> goodsQuantity(goods);
@@ -1045,7 +1045,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字");
waybill.setMileage(mileage);
waybill.setMileageRemark(mileageRemark);
return updateById(waybill);
boolean updated = updateById(waybill);
if (updated) {
receivablePayableDetailService.syncMileageFromWaybill(waybill.getId(), mileage);
}
return updated;
}
@Override
@@ -27,6 +27,7 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.vo.ContractManageVO;
import java.math.BigDecimal;
import java.util.Objects;
/**
@@ -47,6 +48,10 @@ public class ContractManageWrapper extends BaseEntityWrapper<ContractManage, Con
contractManageVO.setUpdateUserName(org.springblade.system.cache.UserCache.getUserRealName(contractManage.getUpdateUser()));
contractManageVO.setContractStageName(stageName(contractManage.getContractStage()));
contractManageVO.setApprovalStatusName(statusName(contractManage.getApprovalStatus()));
if (contractManageVO.getContractAmount() != null
&& contractManageVO.getContractAmount().compareTo(BigDecimal.valueOf(-1)) == 0) {
contractManageVO.setContractAmount(null);
}
return contractManageVO;
}
@@ -32,6 +32,7 @@ import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import java.math.BigDecimal;
import java.util.Objects;
/**
@@ -58,9 +59,23 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
waybillVO.setBusinessStatus(displayStatus);
waybillVO.setBusinessStatusName(businessStatusName(displayStatus));
waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
// 未维护里程等占位值 -1 对外展示为空
if (isSentinelMinusOne(waybillVO.getMileage())) {
waybillVO.setMileage(null);
}
if (isSentinelMinusOne(waybillVO.getQuantity())) {
waybillVO.setQuantity(null);
}
if (isSentinelMinusOne(waybillVO.getUnitPrice())) {
waybillVO.setUnitPrice(null);
}
return waybillVO;
}
private static boolean isSentinelMinusOne(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.valueOf(-1)) == 0;
}
public static String businessStatusName(String status) {
if (status == null) {
return "未知";
+3
View File
@@ -197,6 +197,9 @@ blade:
- /wechat/**
#开启错误日志入库
error-log: true
#增删改操作日志自动入库(blade_log_api
operation:
enabled: true
#xss配置
xss:
enabled: true
@@ -93,6 +93,7 @@ CREATE TABLE `blade_customer_receipt_account` (
`account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '收款方名称',
`account_holder_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '开户人姓名',
`bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '开户行名称',
`cnaps_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '联行号',
`bank_account` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '银行账号',
`registered_phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '注册电话',
`registered_address` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '注册地址',
@@ -0,0 +1,4 @@
-- 客商收款信息:新增联行号
ALTER TABLE `blade_customer_receipt_account`
ADD COLUMN `cnaps_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '联行号' AFTER `bank_name`;