24 Commits

Author SHA1 Message Date
b2894lxlx 14c1d9dff0 认证退出与操作日志、密码规则,以及运输业务多项修正 2026-09-22 12:03:59 +08:00
b2894lxlx 5254a46de8 Merge remote-tracking branch 'websoft/master' 2026-09-21 22:33:12 +08:00
b2894lxlx 12ef1210bc 组织同步逻辑修改 2026-09-21 17:01:10 +08:00
b2894lxlx d1821bbfcf 组织同步逻辑修改 2026-09-21 16:37:24 +08:00
b2894lxlx 2fbbf9b0bb 组织同步逻辑修改 2026-09-21 16:21:15 +08:00
b2894lxlx 427e4b0c84 组织同步逻辑修改 2026-09-21 16:03:28 +08:00
b2894lxlx 2404281bff 项目、合同、运单、预结算、正式结算、付款对接mk 2026-09-21 13:52:34 +08:00
b2894lxlx 2dafd44c26 项目、合同、运单、预结算、正式结算、付款对接mk 2026-09-21 12:54:13 +08:00
b2894lxlx cfe36dfc8b 项目、合同、运单、预结算、正式结算、付款对接mk 2026-09-21 11:38:44 +08:00
weicw1996 6b97685165 🐛 修复网关经 blade-common 传递依赖启动失败
fd6563f 为 LenientDateParser 引入的 blade-starter-log(compile scope)
把 blade-core-tool 等整棵依赖树传递给所有依赖 common 的模块。网关
(WebFlux) 的 pom 排除了 spring-boot-starter-web(无 spring-webmvc),
而 blade-core-tool 的自动配置 BladeConverterConfiguration implements
WebMvcConfigurer 且无 @ConditionalOnClass 守卫,Spring 解析该配置类时
抛 FileNotFoundException 导致网关启动即崩。

修复:starter-log 改为 provided —— 编译期 ServiceException 可见,
不再向下游传递。业务服务运行时的 ServiceException 由 blade-core-boot
传递提供,行为不变。

验证:网关以原 classpath 启动成功(Started GateWayApplication in
16.4s);blade-common 与全仓 BUILD SUCCESS。
2026-09-21 10:44:30 +08:00
weicw1996 fd6563f53b 批量导入日期宽容解析:兼容 2026-08-02 与 2026-8-2 等书写变体
- blade-common 新增 LenientDateParser(宽容解析:- / . 分隔符、补零与否、
  时间可省略秒/分;拒绝两位年份、日在前、无分隔符)
- 新增 LenientDateStringConverter / LenientDateTimeStringConverter:
  Excel 数值日期转文本透传,文本解析留待服务层以进入失败明细流程
- 8 个裸走 fastexcel 内置转换的导入 DTO(保险/年检/事故/其他费用/运单/
  币种/用户生日/设备台账)日期字段改为 String 承载,服务层统一经
  LenientDateParser 解析,失败报「<列名> 日期格式无法识别:<原值>」
- MaintenancePlanDateTimeConverter / TireReplacementDateStringConverter
  内部委托公共解析器,6 个 DTO 注解零改动
- 运输计划、运单批量、运输对账、换胎 4 处手动解析点收敛至同一工具类
- UserMapper 导出 SQL 的生日改 DATE_FORMAT 输出,保持导出格式不变

口径见根工作区 docs/import-spec.md(weicw/tms-erp#1)。

回归:port-terminal 45 断言、violation-record 40 断言(含新增不补零
用例 09)、waybill-import 48 断言、insurance-record 新增用例 6 断言,
全部通过。
2026-09-21 09:47:50 +08:00
weicw1996 ddfa52a2ac 收窄运输方式枚举为公路/铁路/水路/航空四种
运输计划导入校验白名单去掉「公路整车、公路配载/零担、跨境海运」

运单批量导入字典兜底值去掉多余枚举,仅保留四种运输方式
2026-09-21 06:24:31 +08:00
weicw1996 37d0ebc21e 导入失败明细去掉空单元格底纹
标红统一只改红色字体,不再对空单元格加浅红底纹(IndexedColors.ROSE)。
底纹原本是为「必填项为空时红字在屏幕上看不见」而加,但它超出
AGENTS.md §6.11「仅出错字段/列使用红色文字」的口径,视觉上也偏重。

- markRed(Cell, boolean) 简化为 markRed(Cell),删除 redFillStyleCache 与 isBlankValue
- 空单元格的红色字体样式照旧写入 styles.xml,api-violation-xlsx-cols.js
  按字体色判定,回归用例 03/04 不受影响
- 影响所有使用 ImportFailureExcelUtil 的导入模块(20+ 个)
2026-09-21 05:52:31 +08:00
weicw1996 993f802111 精简运单导入车牌号校验的失败原因
一次车牌号错误原来最多会叠出「长度不能少于7位」「首位必须是省份简称、
第二位必须是英文字母」「格式不正确」三条原因,导出的失败明细里这一格很长。
现将公路运输下的必填、长度与格式统一为一条「车牌号校验不通过」。

- 校验强度不变:长度 7/8 位本就由 VEHICLE_NO_PATTERN 蕴含,省份简称白名单照旧
- 保留「车牌号」字样,失败明细仍能按列名拆词匹配并标红车牌号列;车牌为空时
  走空单元格红底纹分支,比原来只有文字更容易定位
- 未改动「同一配载标识号下,车牌号不一致」,该原因由 tests/fixtures/waybill-import
  用例 04 断言
2026-09-21 05:45:49 +08:00
weicw1996 4c96583701 🐛 修复码头导入父港口取列不一致导致的静默丢数据
importTerminals 中 setParentCode 取「港口编码」列,而抑制连带报错时
却用「上级港口编码」判断父行是否已声明。用户若把完整编码填入
「码头编码」并让「港口编码」留空,父港口解析失败的异常会被误判为
「父行已声明」而吞掉,码头被静默丢弃且接口返回成功。

- splitByCategory 增加码头行「港口编码」非空校验,明确报错
- 抑制逻辑改为与 setParentCode 取同一列
2026-09-21 05:34:59 +08:00
weicw1996 8485efe00d 🐛 修复导入失败明细无法按列标红的问题
4e1a162 移除了按列名匹配逻辑后只剩失败原因列标红。
现恢复并修正误标红:命中片段被更长片段覆盖时只保留更长的列
(避免原因含「车船类型」时把子串「类型」列一起标红)。
2026-09-21 05:21:27 +08:00
weicw1996 b02aff8ff6 运单导入支持多货物合并与配载路线串联
- 同一运单标识号的多行货物合并为一条运单,各行转为货物明细写入 goodsJson,数量与其他费用合计按行累加

- 新增组内一致性校验:发货地址、到货地址、数量单位、配载标识号不一致时报错,避免无法合并的数据静默落库

- 配载单路线按导入顺序串联,上一票到货地与下一票发货地重合时去重写入途经地,到货地取末票
2026-09-21 04:19:55 +08:00
weicw1996 37309b24f9 🐛 维修记录导出移除多余的“导出失败原因”列
- MaintenanceRecordExportExcel 仅为把父类 @ExcelIgnore 的 errorMessage 重新暴露成
  第 18 列“导出失败原因”而存在,正式导出该列恒为空白,属遗留冗余
- 删除该字段与对应 import,导出回归 17 列,与导入模板字段顺序一致
- 导入失败明细导出不依赖本类:ImportFailureExcelUtil 自行按字段构建列并过滤
  errorMessage,再反射读取失败原因追加“导入失败原因”列,逻辑不受影响
2026-09-20 20:34:56 +08:00
weicw1996 56c7bc020a 🐛 修复维修记录批量导出文件空白的问题
- MaintenanceRecordExcel 的 createTime/updateTime 字段类型为 java.util.Date,
  但仍绑定了 MaintenancePlanDateTimeConverter(Converter<LocalDateTime>),
  导出写单元格时 Date 强转 LocalDateTime 抛 ClassCastException,
  写流中断导致下载到损坏的 xlsx,打开后一片空白
- 移除这两个 Date 审计字段上的 converter,仅保留 @DateTimeFormat("yyyy-MM-dd HH:mm:ss"),
  与 AccidentRecordExportExcel 等同模块导出模型写法保持一致
- 维修时间、出厂时间仍为 LocalDateTime,转换器保留不变,导入解析不受影响
2026-09-20 20:31:02 +08:00
weicw1996 af1430eaee 🐛 修复未配置 MinIO 时 transport 服务无法启动的问题
凭证图片 MinIO 客户端此前为无条件装配,缺少 file.storage.minio.endpoint
配置时会在启动阶段直接抛 IllegalArgumentException,导致整个
blade-transport 服务起不来,并在关闭失败上下文时连带抛出
nacosGracefulShutdownDelegate 的 BeanCreationNotAllowedException,
掩盖了真正的失败原因。

- VoucherMinioConfig 增加 @ConditionalOnProperty,未配置 endpoint 时不注册客户端
- ProcessConfigController、VoucherManageServiceImpl 改用 ObjectProvider
  可选注入,避免构造器注入因 bean 缺失而启动失败
- 新增 minioClient() 访问器,在真正调用时给出"未配置"的明确提示,
  与已有 validateMinioConfig() 的提示风格保持一致

未配置 MinIO 时服务可正常启动,凭证相关接口仅在使用时提示配置缺失;
已配置 MinIO 时行为不变。
2026-09-20 19:31:58 +08:00
weicw1996 304061774e 🔀 合并 dev 分支到 master
解决 9 个文件的冲突,取舍如下:

- 导出模型:采用 dev 的 *ExportExcel 命名与拆分,并保留 master 的
  @DateTimeFormat(dev 改名时漏加,会导致时间列显示为 Date.toString)。
- PortTerminal 导入:保留 master 的两阶段导入 + ImportFailureException
  全量回滚(ImportFailureException 仅 master 有,合并后的 controller 依赖它),
  导出失败明细改用 dev 的 exportFailureReasonOnly(仅标红失败原因列)。
- PortTerminal 导入模板:采用 dev 的"港口编码/码头编码"两列结构,
  相应补上 resolveImportCode 归并规则,并在构建实体时显式赋 code/parentCode。
- PortTerminal 导出:采用 dev 的 PortTerminalExportExcel(接口已如此声明),
  并保留 updateUserName 审计人翻译。
- 违章记录导入:保留 dev 的多错误收集 + 导入失败明细导出流水线,
  删除已被拆列取代的 violationTypeOrItem 映射,补上 clearIrrelevantField,
  并为导入校验补齐"对侧字段应留空"规则以与表单校验一致。

验证:mvn compile -DskipTests 全模块 BUILD SUCCESS。
2026-09-20 18:04:43 +08:00
weicw1996 98697af6df 🐛 修复违章记录导入的类型与事项字段混淆问题
- 导入模型将合并的"类型/事项"列拆分为"*类型"与"*事项"两列,与导出模型保持一致
- 校验与清空逻辑改为按车船类型严格匹配:车辆只允许填类型,船舶只允许填事项,误填对侧字段直接报错而非静默丢弃
- 将清空对侧字段的时机从 prepare 移到 validate 之后,避免用户误填内容被静默丢弃
- 提取 CAR/SHIP 常量,替换散落的"车辆"/"船舶"字面量
2026-09-20 17:51:29 +08:00
weicw1996 9668f6059a 🐛 修复导出文件创建时间与更新时间列为空的问题
- 导出模型的 createTime/updateTime 由 LocalDateTime 改为 java.util.Date,与 BaseEntity 审计字段类型保持一致,避免 BeanUtil.copyProperties 因类型不兼容而静默跳过
- 覆盖车/船务管理 12 个页面导出模型及基础数据等 11 个导出模型,并为新增 Date 字段补齐 @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
- 运单导入显式清空审计时间,避免把导入文件中的创建/更新时间带入实体
2026-09-20 16:32:31 +08:00
weicw1996 cc33bc5ef2 🐛 修复港口码头批量导入的一二级顺序与全量回滚问题
对应 BUG.xlsx 序号 1 与序号 2。

序号 1(同表混装一二级数据):
- 改为两阶段导入,先落库港口(一级)再落库码头(二级),
  消除对 Excel 行序的依赖;码头优先引用本批次新增的港口

序号 2(全失败即整批回滚):
- 逐行收集错误,只要有一行失败就抛异常触发整批回滚,不再部分成功
- 失败明细按【原表全部行】返回,未出错的行不标注,用户只需改错行

附带修复:
- 父港口找不到时改抛专用 ParentPortNotFoundException:
  上级港口声明在本文件里、但那一行自身校验失败时,不再连带给码头行报错,
  避免用户看到"两行都错"的假象;父港口确实漏填时仍照常报错
- 批内重复编码在落库前预检并定位到行,两行都标注原因
- 父港口区域信息为空(历史数据)时保留码头自身填写的值,
  此前会被覆盖成空值导致码头无法导入
2026-09-20 06:27:33 +08:00
93 changed files with 4260 additions and 2184 deletions
@@ -25,6 +25,7 @@
*/ */
package org.springblade.auth.config; package org.springblade.auth.config;
import org.springblade.auth.filter.LogoutLogFilter;
import org.springblade.auth.granter.IamAwareTokenGranterFactory; import org.springblade.auth.granter.IamAwareTokenGranterFactory;
import org.springblade.auth.handler.BladeAuthorizationHandler; import org.springblade.auth.handler.BladeAuthorizationHandler;
import org.springblade.auth.handler.BladeLockHandler; import org.springblade.auth.handler.BladeLockHandler;
@@ -53,9 +54,11 @@ import org.springblade.core.tenant.BladeTenantProperties;
import org.springblade.system.feign.IUserClient; import org.springblade.system.feign.IUserClient;
import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List; import java.util.List;
@@ -88,6 +91,16 @@ public class BladeAuthConfiguration {
return new BladeLogHandler(authLogClient, bladeProperties, serverInfo); 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 @Bean
public PasswordHandler passwordHandler(OAuth2Properties properties) { public PasswordHandler passwordHandler(OAuth2Properties properties) {
return new BladePasswordHandler(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.launch.server.ServerInfo;
import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.provider.OAuth2Request;
import org.springblade.core.oauth2.service.OAuth2User; 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.DateUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil; import org.springblade.core.tool.utils.WebUtil;
import org.springblade.system.pojo.entity.AuthLog;
import org.springblade.system.feign.IAuthLogClient; import org.springblade.system.feign.IAuthLogClient;
import org.springblade.system.pojo.entity.AuthLog;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -42,7 +45,7 @@ import java.util.concurrent.CompletableFuture;
/** /**
* 认证日志处理器 * 认证日志处理器
* 在用户认证成功时异步记录登录日志 * 在用户认证成功/退出时异步记录登录、退出日志
* *
* @author BladeX * @author BladeX
*/ */
@@ -50,6 +53,11 @@ import java.util.concurrent.CompletableFuture;
@RequiredArgsConstructor @RequiredArgsConstructor
public class BladeLogHandler { public class BladeLogHandler {
/**
* 退出登录授权类型标识
*/
public static final String GRANT_TYPE_LOGOUT = "logout";
private final IAuthLogClient authLogClient; private final IAuthLogClient authLogClient;
private final BladeProperties bladeProperties; private final BladeProperties bladeProperties;
private final ServerInfo serverInfo; 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; 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();
}
} }
+19
View File
@@ -22,6 +22,25 @@
<groupId>org.springblade</groupId> <groupId>org.springblade</groupId>
<artifactId>blade-starter-loadbalancer</artifactId> <artifactId>blade-starter-loadbalancer</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-starter-log</artifactId>
<!-- providedLenientDateParser 编译期需要 ServiceException(位于 starter-log),
但 provided 不向下游传递——否则网关会经 common 拿到 blade-core-tool 的
BladeConverterConfigurationimplements WebMvcConfigurer),在无 spring-webmvc
的 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> <dependency>
<groupId>org.springblade</groupId> <groupId>org.springblade</groupId>
<artifactId>blade-core-auto</artifactId> <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; package org.springblade.common.config;
import lombok.AllArgsConstructor; import org.springblade.common.aspect.OperationApiLogAspect;
import org.springframework.context.annotation.Configuration; 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 * @author Chill
*/ */
@Configuration(proxyBeanMethods = false) @AutoConfiguration
@AllArgsConstructor
public class BladeCommonConfiguration { 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();
}
} }
@@ -46,9 +46,11 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** /**
* 导入失败明细 Excel 导出工具类 * 导入失败明细 Excel 导出工具类
@@ -95,7 +97,7 @@ public class ImportFailureExcelUtil {
* @param excelClass 原导入 Excel 类型 * @param excelClass 原导入 Excel 类型
*/ */
public static void export(HttpServletResponse response, String fileName, String sheetName, List<?> data, Class<?> excelClass) { public static void export(HttpServletResponse response, String fileName, String sheetName, List<?> data, Class<?> excelClass) {
exportFailureReasonOnly(response, fileName, sheetName, data, excelClass); export(response, fileName, sheetName, data, excelClass, true);
} }
/** /**
@@ -109,6 +111,16 @@ public class ImportFailureExcelUtil {
*/ */
public static void exportFailureReasonOnly(HttpServletResponse response, String fileName, String sheetName, public static void exportFailureReasonOnly(HttpServletResponse response, String fileName, String sheetName,
List<?> data, Class<?> excelClass) { List<?> data, Class<?> excelClass) {
export(response, fileName, sheetName, data, excelClass, false);
}
/**
* 导出导入失败明细
*
* @param markErrorColumns 是否按失败原因里出现的列名,标红对应字段单元格
*/
private static void export(HttpServletResponse response, String fileName, String sheetName,
List<?> data, Class<?> excelClass, boolean markErrorColumns) {
response.setContentType("application/vnd.ms-excel"); response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
@@ -118,7 +130,7 @@ public class ImportFailureExcelUtil {
List<List<Object>> rows = buildRows(data, excelFields); List<List<Object>> rows = buildRows(data, excelFields);
try { try {
FastExcel.write(response.getOutputStream()) FastExcel.write(response.getOutputStream())
.registerWriteHandler(new ImportFailureCellStyleHandler(excelFields.size())) .registerWriteHandler(new ImportFailureCellStyleHandler(excelFields, rows, markErrorColumns))
.head(head) .head(head)
.sheet(sheetName) .sheet(sheetName)
.doWrite(rows); .doWrite(rows);
@@ -204,15 +216,59 @@ public class ImportFailureExcelUtil {
throw new NoSuchFieldException(String.join(",", fieldNames)); throw new NoSuchFieldException(String.join(",", fieldNames));
} }
/**
* 文本归一化:去掉空白、星号与中英文标点并统一小写
* <p>
* 目的是让「失败原因里写的列名」与「表头列名」能直接做包含匹配,
* 不受「*」「/」「:」等写法差异影响。
*/
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[\\s*_:,。;;()()\\[\\]【】<>《》//、-]", "").toLowerCase();
}
/**
* 取某一列用于定位的候选关键词
* <p>
* 包含表头列名、字段名,以及列名按分隔符拆出的片段
* (如「车牌号/船号」拆出「车牌号」「船号」,失败原因只写其中一段时也能定位)。
*/
private static List<String> columnKeywords(Field field) {
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
String[] value = excelProperty.value();
String columnName = value.length == 0 ? field.getName() : value[0];
List<String> keywords = new ArrayList<>();
keywords.add(columnName);
keywords.add(field.getName());
keywords.addAll(Arrays.asList(columnName.replace("*", "").split("[//、()()\\s]+")));
return keywords.stream()
.map(ImportFailureExcelUtil::normalize)
.filter(keyword -> keyword.length() >= 2)
.distinct()
.toList();
}
private static class ImportFailureCellStyleHandler implements CellWriteHandler { private static class ImportFailureCellStyleHandler implements CellWriteHandler {
private final int failureReasonColumnIndex; private final int failureReasonColumnIndex;
private final boolean markErrorColumns;
private final List<List<Object>> rows;
private final Map<Integer, Set<Integer>> redColumnsByRow = new HashMap<>();
private final Map<Short, CellStyle> redStyleCache = new HashMap<>(); private final Map<Short, CellStyle> redStyleCache = new HashMap<>();
private final Map<Short, CellStyle> noWrapStyleCache = new HashMap<>(); private final Map<Short, CellStyle> noWrapStyleCache = new HashMap<>();
private final Map<Integer, Integer> columnWidthCache = new HashMap<>(); private final Map<Integer, Integer> columnWidthCache = new HashMap<>();
private ImportFailureCellStyleHandler(int failureReasonColumnIndex) { private ImportFailureCellStyleHandler(List<Field> excelFields, List<List<Object>> rows, boolean markErrorColumns) {
this.failureReasonColumnIndex = failureReasonColumnIndex; this.failureReasonColumnIndex = excelFields.size();
this.markErrorColumns = markErrorColumns;
this.rows = rows;
if (markErrorColumns) {
List<List<String>> columnKeywords = excelFields.stream()
.map(ImportFailureExcelUtil::columnKeywords)
.toList();
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
redColumnsByRow.put(rowIndex, resolveRedColumns(rows.get(rowIndex), columnKeywords));
}
}
} }
@Override @Override
@@ -230,11 +286,83 @@ public class ImportFailureExcelUtil {
return; return;
} }
adjustColumnWidth(cell); adjustColumnWidth(cell);
if (cell.getColumnIndex() == failureReasonColumnIndex) { if (relativeRowIndex == null || relativeRowIndex < 0 || relativeRowIndex >= rows.size()) {
return;
}
if (shouldMarkRed(relativeRowIndex, cell.getColumnIndex())) {
markRed(cell); markRed(cell);
} }
} }
/**
* 计算某一行需要标红的列
* <p>
* 失败原因里出现的列名即视为出错列;但当某个命中片段被另一列更长的命中
* 片段完全覆盖时(如原因「车船类型不能为空」同时命中「车船类型」和它的
* 子串「类型」),只保留更长的那一列,避免把无关列一起标红。
*/
private Set<Integer> resolveRedColumns(List<Object> row, List<List<String>> columnKeywords) {
Set<Integer> redColumns = new LinkedHashSet<>();
redColumns.add(failureReasonColumnIndex);
String failureReason = normalize(String.valueOf(row.get(failureReasonColumnIndex)));
if (failureReason.isEmpty()) {
return redColumns;
}
List<int[]> matches = new ArrayList<>();
for (int column = 0; column < columnKeywords.size(); column++) {
for (String keyword : columnKeywords.get(column)) {
int fromIndex = 0;
while (true) {
int index = failureReason.indexOf(keyword, fromIndex);
if (index < 0) {
break;
}
matches.add(new int[]{index, index + keyword.length(), column});
fromIndex = index + 1;
}
}
}
for (int[] match : matches) {
if (!isSubsumed(match, matches)) {
redColumns.add(match[2]);
}
}
return redColumns;
}
/**
* 判断该命中片段是否被另一列更长的命中片段完全覆盖
*/
private boolean isSubsumed(int[] match, List<int[]> matches) {
int matchLength = match[1] - match[0];
for (int[] other : matches) {
if (other[2] == match[2]) {
continue;
}
if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) {
return true;
}
}
return false;
}
private boolean shouldMarkRed(int rowIndex, int columnIndex) {
if (columnIndex == failureReasonColumnIndex) {
return true;
}
if (!markErrorColumns) {
return false;
}
Set<Integer> redColumns = redColumnsByRow.get(rowIndex);
return redColumns != null && redColumns.contains(columnIndex);
}
/**
* 标红单元格
* <p>
* 统一只改字体颜色,不加底纹;空单元格的红色字体在屏幕上不可见,
* 这类错误依靠失败原因列的文字定位。
*/
private void markRed(Cell cell) { private void markRed(Cell cell) {
CellStyle currentStyle = cell.getCellStyle(); CellStyle currentStyle = cell.getCellStyle();
CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> {
@@ -0,0 +1,198 @@
/**
* 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.excel;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 批量导入宽容日期解析器(口径见根工作区 docs/import-spec.md)。
* <p>
* 解析规则:
* <ul>
* <li>分隔符 {@code -}、{@code /}、{@code .} 均接受;补零与否均可(2026-08-02 ≡ 2026-8-2</li>
* <li>日期时间:日期部分同上,时间 HH:mm:ss,可省略秒或秒+分(2026-8-2 12:3 可解析)</li>
* <li>拒绝:两位年份(26-8-2)、日在前(2/8/2026)、无分隔符(20260802</li>
* </ul>
*
* @author Chill
*/
public final class LenientDateParser {
/**
* 日期部分:4 位年 + 分隔符 + 1~2 位月 + 同一分隔符 + 1~2 位日(年在前,拒绝两位年份与日在前)。
*/
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})([-/.])(\\d{1,2})\\2(\\d{1,2})");
/**
* 时间部分:1~2 位时[:1~2 位分[:1~2 位秒]],逐级可省略。
*/
private static final Pattern TIME_PATTERN = Pattern.compile("(\\d{1,2})(?::(\\d{1,2})(?::(\\d{1,2}))?)?");
private LenientDateParser() {
}
/**
* 解析日期文本,失败返回 {@code null}。
*
* @param value 单元格原始文本
* @return 日期;无法识别时返回 null
*/
public static LocalDate parseDateOrNull(String value) {
String normalized = normalize(value);
if (normalized == null) {
return null;
}
Matcher matcher = DATE_PATTERN.matcher(normalized);
if (!matcher.matches()) {
return null;
}
try {
return LocalDate.of(Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(3)), Integer.parseInt(matcher.group(4)));
} catch (NumberFormatException | java.time.DateTimeException exception) {
return null;
}
}
/**
* 解析日期文本;允许携带合法时间部分并截断(如 2026-9-1 8:0:0 按 2026-09-01 解析,
* 与 fastexcel 内置 LocalDate 转换的既有宽容度保持一致),失败返回 {@code null}。
*
* @param value 单元格原始文本
* @return 日期;无法识别时返回 null
*/
public static LocalDate parseDateLenientlyOrNull(String value) {
LocalDate date = parseDateOrNull(value);
if (date != null) {
return date;
}
LocalDateTime dateTime = parseDateTimeOrNull(value);
return dateTime == null ? null : dateTime.toLocalDate();
}
/**
* 解析日期时间文本;纯日期按当日零点处理,失败返回 {@code null}。
*
* @param value 单元格原始文本
* @return 日期时间;无法识别时返回 null
*/
public static LocalDateTime parseDateTimeOrNull(String value) {
String normalized = normalize(value);
if (normalized == null) {
return null;
}
// 拆出日期与时间两部分;中间允许 1 个及以上空白或小写 t(2026-8-2t12:3 亦接受)。
String[] parts = normalized.split("[ \\t]+|(?<=\\d)t", 2);
if (parts.length == 0) {
return null;
}
LocalDate date = parseDateOrNull(parts[0]);
if (date == null) {
return null;
}
if (parts.length == 1) {
return date.atStartOfDay();
}
Matcher matcher = TIME_PATTERN.matcher(parts[1]);
if (!matcher.matches()) {
return null;
}
try {
int hour = Integer.parseInt(matcher.group(1));
int minute = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2));
int second = matcher.group(3) == null ? 0 : Integer.parseInt(matcher.group(3));
return LocalDateTime.of(date, LocalTime.of(hour, minute, second));
} catch (NumberFormatException | java.time.DateTimeException exception) {
return null;
}
}
/**
* 解析日期文本,失败抛出携带口径文案的 {@link org.springblade.core.log.exception.ServiceException}。
* 空白与 {@code null} 返回 {@code null}(可选字段由业务校验决定是否必填)。
*
* @param value 单元格原始文本
* @param columnName 导入模板列名(用于失败原因文案与失败明细标红定位)
* @return 日期
*/
public static LocalDate parseDate(String value, String columnName) {
if (value == null || value.trim().isEmpty()) {
return null;
}
LocalDate date = parseDateLenientlyOrNull(value);
if (date == null) {
throw unrecognizedDate(value, columnName);
}
return date;
}
/**
* 解析日期时间文本,失败抛出携带口径文案的 {@link org.springblade.core.log.exception.ServiceException}。
* 空白与 {@code null} 返回 {@code null}(可选字段由业务校验决定是否必填)。
*
* @param value 单元格原始文本
* @param columnName 导入模板列名(用于失败原因文案与失败明细标红定位)
* @return 日期时间
*/
public static LocalDateTime parseDateTime(String value, String columnName) {
if (value == null || value.trim().isEmpty()) {
return null;
}
LocalDateTime dateTime = parseDateTimeOrNull(value);
if (dateTime == null) {
throw unrecognizedDate(value, columnName);
}
return dateTime;
}
/**
* 按口径构造「日期格式无法识别」错误。
*
* @param value 单元格原始文本
* @param columnName 导入模板列名
* @return 业务异常
*/
public static org.springblade.core.log.exception.ServiceException unrecognizedDate(String value, String columnName) {
String displayName = columnName == null || columnName.isBlank() ? "日期" : columnName;
return new org.springblade.core.log.exception.ServiceException(
displayName + " 日期格式无法识别:" + (value == null ? "" : value.trim()));
}
/**
* 归一化输入:去首尾空白,跳过空白值。
*/
private static String normalize(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
}
@@ -0,0 +1,75 @@
/**
* 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.excel;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.idev.excel.util.DateUtils;
import java.time.LocalDate;
/**
* 批量导入宽容日期转换器(String 承载):Excel 数值日期(序列号)转 ISO 文本,文本原样透传。
* <p>
* 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错——
* 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取,
* 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。
*
* @author Chill
*/
public class LenientDateStringConverter implements Converter<String> {
@Override
public Class<?> supportJavaTypeKey() {
return String.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public String convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
if (cellData.getType() == CellDataTypeEnum.NUMBER) {
LocalDate date = DateUtils.getLocalDateTime(cellData.getNumberValue().doubleValue(),
globalConfiguration.getUse1904windowing()).toLocalDate();
return date.toString();
}
return cellData.getStringValue();
}
@Override
public WriteCellData<?> convertToExcelData(String value, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
return new WriteCellData<>(value);
}
}
@@ -0,0 +1,82 @@
/**
* 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.excel;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.idev.excel.util.DateUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 批量导入宽容日期时间转换器(String 承载):Excel 数值日期(序列号)转
* {@code yyyy-MM-dd HH:mm:ss} 文本,文本原样透传。
* <p>
* 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错——
* 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取,
* 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。
*
* @author Chill
*/
public class LenientDateTimeStringConverter implements Converter<String> {
/**
* 数值日期序列号转文本的输出格式,与导入模板展示格式保持一致。
*/
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public Class<?> supportJavaTypeKey() {
return String.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public String convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
if (cellData.getType() == CellDataTypeEnum.NUMBER) {
LocalDateTime dateTime = DateUtils.getLocalDateTime(cellData.getNumberValue().doubleValue(),
globalConfiguration.getUse1904windowing());
return dateTime.format(DATE_TIME_FORMATTER);
}
return cellData.getStringValue();
}
@Override
public WriteCellData<?> convertToExcelData(String value, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
return new WriteCellData<>(value);
}
}
@@ -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
@@ -25,10 +25,13 @@
*/ */
package org.springblade.system.pojo.entity; package org.springblade.system.pojo.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -185,4 +188,20 @@ public class Dept extends TenantEntity {
@Schema(description = "是否平台公司:0否,1是") @Schema(description = "是否平台公司:0否,1是")
private Integer isPlatformCompany; private Integer isPlatformCompany;
/**
* OA主键,仅同步记录,不对外展示
*/
@JsonIgnore
@Schema(description = "OA主键", hidden = true)
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private String oaId;
/**
* OA上级主键,仅同步记录,不对外展示
*/
@JsonIgnore
@Schema(description = "OA上级主键", hidden = true)
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private String oaSupSubComId;
} }
@@ -1,6 +1,6 @@
package org.springblade.transport.pojo.dto; package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -59,6 +59,9 @@ public class CustomerReceiptAccount extends TenantEntity {
@Schema(description = "开户行名称") @Schema(description = "开户行名称")
private String bankName; private String bankName;
@Schema(description = "联行号")
private String cnapsCode;
@Schema(description = "银行账号") @Schema(description = "银行账号")
private String bankAccount; private String bankAccount;
@@ -30,6 +30,8 @@ import org.springblade.transport.pojo.entity.ProjectApply;
import java.io.Serial; import java.io.Serial;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/** /**
* 项目立项视图实体类 * 项目立项视图实体类
@@ -104,4 +106,16 @@ public class ProjectApplyVO extends ProjectApply {
@Schema(description = "风险计算额度基数") @Schema(description = "风险计算额度基数")
private BigDecimal maxFundLimit; private BigDecimal maxFundLimit;
@TableField(exist = false)
@Schema(description = "货物类型字典,公开页回显使用")
private List<Map<String, String>> cargoTypeOptions;
@TableField(exist = false)
@Schema(description = "运输类型字典,公开页回显使用")
private List<Map<String, String>> transportTypeOptions;
@TableField(exist = false)
@Schema(description = "结算方式字典,公开页回显使用")
private List<Map<String, String>> settlementModeOptions;
} }
@@ -173,6 +173,20 @@ public class DeptController extends BladeController {
return R.data(deptService.syncIamOrganizations()); return R.data(deptService.syncIamOrganizations());
} }
/**
* 清除非顶级组织,供自动同步前确认后调用
*/
@IsAdmin
@PostMapping("/clear-non-top")
@ApiOperationSupport(order = 8)
@Operation(summary = "清除非顶级组织")
public R<Integer> clearNonTopDepts() {
int clearedCount = deptService.clearNonTopDepts();
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
return R.data(clearedCount);
}
/** /**
* 从OA按页同步公司 * 从OA按页同步公司
*/ */
@@ -43,6 +43,8 @@ import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel; import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.excel.PortTerminalExportExcel; import org.springblade.system.excel.PortTerminalExportExcel;
import org.springblade.system.excel.PortTerminalImporter; import org.springblade.system.excel.PortTerminalImporter;
@@ -175,14 +177,25 @@ public class PortTerminalController extends BladeController {
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件"); return R.fail("请上传 .xls,.xlsx 标准格式文件");
} }
List<PortTerminalExcel> failureList = portTerminalService.importPortTerminal(ExcelUtil.read(file, PortTerminalExcel.class)); try {
if (Func.isNotEmpty(failureList)) { portTerminalService.importPortTerminal(ExcelUtil.read(file, PortTerminalExcel.class));
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class); } catch (ImportFailureException exception) {
// 全失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
exportFailure(response, exception.getFailureList());
return null; return null;
} }
return R.success("操作成功"); return R.success("操作成功");
} }
/**
* 导出导入失败明细,内容为原表全部数据并在末尾追加失败原因列。
* <p>
* 失败数据仅标红出错单元格与失败原因列,表头保持默认样式。
*/
private void exportFailure(HttpServletResponse response, List<?> failureList) {
ImportFailureExcelUtil.exportFailureReasonOnly(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class);
}
/** /**
* 导出港口码头主数据 * 导出港口码头主数据
*/ */
@@ -80,6 +80,9 @@ public interface DeptConvert {
} }
// 公司所属公司编码就是他自己的部门编码 // 公司所属公司编码就是他自己的部门编码
dept.setBelongCompanyCode(dept.getDeptCode()); dept.setBelongCompanyCode(dept.getDeptCode());
// OA主键与上级OA主键,分批同步结束后用于重建层级
dept.setOaId(company.getId());
dept.setOaSupSubComId(company.getSupsubcomid());
// 显示顺序处理 // 显示顺序处理
dept.setSort(OAUtils.parseInt(company.getShoworder())); dept.setSort(OAUtils.parseInt(company.getShoworder()));
// 部门类型,公司 // 部门类型,公司
@@ -100,6 +103,13 @@ public interface DeptConvert {
Dept dept = this.baseConvert(oaDept); Dept dept = this.baseConvert(oaDept);
// 部门编码添加前缀 // 部门编码添加前缀
dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId()); dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId());
// OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键
dept.setOaId(oaDept.getId());
if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) {
dept.setOaSupSubComId(oaDept.getSubcompanyid1());
} else {
dept.setOaSupSubComId(oaDept.getSupdepid());
}
// 部门所属公司编码就是他所属公司编码加前缀 // 部门所属公司编码就是他所属公司编码加前缀
dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode()); dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode());
String parentCode = dept.getParentCode(); String parentCode = dept.getParentCode();
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 币种汇率 Excel * 币种汇率 Excel
@@ -62,8 +61,8 @@ public class CurrencyExcel implements Serializable {
@ExcelProperty("汇率") @ExcelProperty("汇率")
private BigDecimal exchangeRate; private BigDecimal exchangeRate;
@ExcelProperty("生效日期") @ExcelProperty(value = "生效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate effectiveDate; private String effectiveDate;
@ExcelProperty("状态") @ExcelProperty("状态")
private String statusName; private String statusName;
@@ -71,8 +70,8 @@ public class CurrencyExcel implements Serializable {
@ExcelProperty("来源") @ExcelProperty("来源")
private String dataSource; private String dataSource;
@ExcelProperty("失效日期") @ExcelProperty(value = "失效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate expiryDate; private String expiryDate;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -0,0 +1,60 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.excel;
import org.springblade.core.log.exception.ServiceException;
import java.io.Serial;
import java.util.List;
/**
* 导入失败异常,携带失败明细用于导出原表并标注错误。
* <p>
* 批量导入采用「全失败即整批回滚」语义:任一行校验失败都会抛出本异常触发事务回滚,
* 失败明细在抛异常前已收集完毕,因此回滚不影响明细的内容。
*
* @author Chill
*/
public class ImportFailureException extends ServiceException {
@Serial
private static final long serialVersionUID = 1L;
/**
* 失败明细,包含原表全部数据,错误行已标注错误原因。
*/
private final transient List<?> failureList;
public ImportFailureException(List<?> failureList) {
super("导入失败,已回滚全部数据");
this.failureList = failureList;
}
public List<?> getFailureList() {
return failureList;
}
}
@@ -34,7 +34,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date;
/** /**
* UserExcel * UserExcel
@@ -102,7 +101,7 @@ public class UserExcel implements Serializable {
private String postName; private String postName;
@ColumnWidth(20) @ColumnWidth(20)
@ExcelProperty("生日") @ExcelProperty(value = "生日", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private Date birthday; private String birthday;
} }
@@ -95,7 +95,7 @@
</select> </select>
<select id="exportUser" resultType="org.springblade.system.excel.UserExcel"> <select id="exportUser" resultType="org.springblade.system.excel.UserExcel">
SELECT id, tenant_id, user_type, account, name, real_name, email, phone, birthday, role_id, dept_id, post_id FROM blade_user ${ew.customSqlSegment} SELECT id, tenant_id, user_type, account, name, real_name, email, phone, DATE_FORMAT(birthday, '%Y-%m-%d') AS birthday, role_id, dept_id, post_id FROM blade_user ${ew.customSqlSegment}
</select> </select>
<select id="selectCustomerOptions" resultType="java.util.HashMap"> <select id="selectCustomerOptions" resultType="java.util.HashMap">
@@ -146,6 +146,13 @@ public interface IDeptService extends IService<Dept> {
*/ */
boolean removeDept(String ids); boolean removeDept(String ids);
/**
* 删除非顶级组织,顶级组织(父级为 0)保留
*
* @return 删除数量
*/
int clearNonTopDepts();
/** /**
* 新增或修改部门 * 新增或修改部门
* <p> * <p>
@@ -187,4 +194,16 @@ public interface IDeptService extends IService<Dept> {
*/ */
void updateAncestors(Date startTime); void updateAncestors(Date startTime);
/**
* 按 OA 主键与上级 OA 主键重建组织、部门父子关系,并刷新祖级列表
*/
void rebuildOaHierarchy();
/**
* 删除 OA 同步后无法挂到上级的组织(上级 OA 主键在本地不存在),并级联删除其子级
*
* @return 删除数量
*/
int removeOaOrphansWithoutParent();
} }
@@ -125,6 +125,8 @@ public class CurrencyServiceImpl extends BaseServiceImpl<CurrencyMapper, Currenc
CurrencyExcel excel = data.get(index); CurrencyExcel excel = data.get(index);
try { try {
Currency currency = Objects.requireNonNull(BeanUtil.copyProperties(excel, Currency.class)); Currency currency = Objects.requireNonNull(BeanUtil.copyProperties(excel, Currency.class));
currency.setEffectiveDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEffectiveDate(), "生效日期"));
currency.setExpiryDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getExpiryDate(), "失效日期"));
currency.setDataSource(SOURCE_BATCH); currency.setDataSource(SOURCE_BATCH);
currency.setStatus(STATUS_ENABLED); currency.setStatus(STATUS_ENABLED);
prepare(currency, SOURCE_BATCH); prepare(currency, SOURCE_BATCH);
@@ -46,6 +46,7 @@ import org.springblade.system.cache.SysCache;
import org.springblade.system.mapper.DeptMapper; import org.springblade.system.mapper.DeptMapper;
import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.enums.DeptCategory;
import org.springblade.system.pojo.vo.DeptVO; import org.springblade.system.pojo.vo.DeptVO;
import org.springblade.system.pojo.vo.UserVO; import org.springblade.system.pojo.vo.UserVO;
import org.springblade.system.props.IamSyncProperties; import org.springblade.system.props.IamSyncProperties;
@@ -53,6 +54,7 @@ import org.springblade.system.service.IDeptService;
import org.springblade.system.service.IUserService; import org.springblade.system.service.IUserService;
import org.springblade.system.wrapper.DeptWrapper; import org.springblade.system.wrapper.DeptWrapper;
import org.springblade.system.wrapper.UserWrapper; import org.springblade.system.wrapper.UserWrapper;
import org.springblade.thirdparty.oa.constant.OAConstant;
import org.springblade.thirdparty.oa.constant.OAConvertConstant; import org.springblade.thirdparty.oa.constant.OAConvertConstant;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -232,6 +234,21 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
return removeByIds(idList); return removeByIds(idList);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public int clearNonTopDepts() {
Long nonTopCount = this.count(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getParentId)
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
if (nonTopCount == null || nonTopCount == 0L) {
return 0;
}
this.remove(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getParentId)
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
return nonTopCount.intValue();
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean submit(Dept dept) { public boolean submit(Dept dept) {
@@ -451,8 +468,12 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
} }
private void validateDeptCategory(Dept dept, Dept parent) { private void validateDeptCategory(Dept dept, Dept parent) {
// 顶级组织不要求选择上级组织
if (parent == null) { if (parent == null) {
throw new ServiceException("请选择上级组织"); if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) {
throw new ServiceException("请选择承运商");
}
return;
} }
List<Integer> allowedCategories; List<Integer> allowedCategories;
if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) { if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) {
@@ -483,13 +504,24 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (deptCode.length() > 30) { if (deptCode.length() > 30) {
throw new ServiceException("组织编码不能超过30个字符"); throw new ServiceException("组织编码不能超过30个字符");
} }
if (parent == null || StringUtil.isBlank(parent.getDeptCode())) { // 顶级组织不依赖上级编码
if (parent == null) {
this.ensureDeptCodeUnique(dept, deptCode);
dept.setDeptCode(deptCode);
return;
}
if (StringUtil.isBlank(parent.getDeptCode())) {
throw new ServiceException("请选择已配置组织编码的上级组织"); throw new ServiceException("请选择已配置组织编码的上级组织");
} }
String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+"; String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+";
if (!deptCode.matches(pattern)) { if (!deptCode.matches(pattern)) {
throw new ServiceException("组织编码格式应为:上级编码-分段数字"); throw new ServiceException("组织编码格式应为:上级编码-分段数字");
} }
this.ensureDeptCodeUnique(dept, deptCode);
dept.setDeptCode(deptCode);
}
private void ensureDeptCodeUnique(Dept dept, String deptCode) {
LambdaQueryWrapper<Dept> queryWrapper = Wrappers.<Dept>lambdaQuery().eq(Dept::getDeptCode, deptCode); LambdaQueryWrapper<Dept> queryWrapper = Wrappers.<Dept>lambdaQuery().eq(Dept::getDeptCode, deptCode);
if (Func.isNotEmpty(dept.getId())) { if (Func.isNotEmpty(dept.getId())) {
queryWrapper.ne(Dept::getId, dept.getId()); queryWrapper.ne(Dept::getId, dept.getId());
@@ -497,7 +529,6 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (baseMapper.selectCount(queryWrapper) > 0) { if (baseMapper.selectCount(queryWrapper) > 0) {
throw new ServiceException("组织编码已存在"); throw new ServiceException("组织编码已存在");
} }
dept.setDeptCode(deptCode);
} }
@Override @Override
@@ -540,24 +571,243 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
return UserWrapper.build().listVO(userList); return UserWrapper.build().listVO(userList);
} }
/**
* 统一的租户范围过滤:超管放行,其他用户强制限定为当前会话租户。
*/
private void applyTenantScope(QueryWrapper<Dept> queryWrapper) {
if (!AuthUtil.isAdministrator()) {
queryWrapper.lambda().eq(Dept::getTenantId, AuthUtil.getTenantId());
}
}
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@Override @Override
public void updateAncestors(Date startTime) { public void updateAncestors(Date startTime) {
// 1. 查询需要更新祖级列表的部门 this.refreshAncestors(false, startTime);
List<Dept> list = this.list(Wrappers.<Dept>lambdaQuery() }
// 祖级列表为空
@Transactional(rollbackFor = Exception.class)
@Override
public void rebuildOaHierarchy() {
List<Dept> oaDeptList = this.list(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, ""));
if (CollectionUtil.isEmpty(oaDeptList)) {
log.info("没有记录OA主键的组织,跳过层级重建");
return;
}
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
Map<Long, Long> desiredParentIdMap = new LinkedHashMap<>();
Map<Long, String> desiredParentCodeMap = new HashMap<>();
for (Dept dept : oaDeptList) {
OaParentLink parentLink = this.resolveOaParentLink(dept, companyByOaId, departmentByOaId);
if (parentLink == null) {
continue;
}
desiredParentIdMap.put(dept.getId(), parentLink.parentId());
desiredParentCodeMap.put(dept.getId(), parentLink.parentCode());
}
Set<Long> cyclicIds = this.findCyclicDeptIds(desiredParentIdMap);
if (CollectionUtil.isNotEmpty(cyclicIds)) {
log.warn("检测到OA组织循环引用,跳过层级重建 ids={}", cyclicIds);
cyclicIds.forEach(cyclicId -> {
desiredParentIdMap.remove(cyclicId);
desiredParentCodeMap.remove(cyclicId);
});
}
List<Dept> updateList = new ArrayList<>();
for (Dept dept : oaDeptList) {
Long parentId = desiredParentIdMap.get(dept.getId());
if (parentId == null) {
continue;
}
String parentCode = desiredParentCodeMap.get(dept.getId());
if (Objects.equals(parentId, dept.getParentId()) && StringUtil.equals(parentCode, dept.getParentCode())) {
continue;
}
Dept updateParam = new Dept();
updateParam.setId(dept.getId());
updateParam.setParentId(parentId);
updateParam.setParentCode(parentCode);
updateList.add(updateParam);
}
if (CollectionUtil.isNotEmpty(updateList)) {
this.updateBatchById(updateList);
log.info("按OA主键重建组织层级,更新父子关系{}条", updateList.size());
} else {
log.info("OA组织层级已是最新,无需调整父子关系");
}
this.refreshAncestors(true, null);
}
@Transactional(rollbackFor = Exception.class)
@Override
public int removeOaOrphansWithoutParent() {
List<Dept> oaDeptList = this.list(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, ""));
if (CollectionUtil.isEmpty(oaDeptList)) {
return 0;
}
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
Set<Long> deleteIds = new HashSet<>();
for (Dept dept : oaDeptList) {
String parentOaId = dept.getOaSupSubComId();
// 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
continue;
}
boolean parentExists;
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
parentExists = companyByOaId.containsKey(parentOaId);
} else {
parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
}
if (!parentExists) {
deleteIds.add(dept.getId());
}
}
if (CollectionUtil.isEmpty(deleteIds)) {
log.info("没有需要清理的OA孤儿组织");
return 0;
}
// 按 OA 上级关系级联:上级被删则子级一并删除
boolean oaCascadeChanged = true;
while (oaCascadeChanged) {
oaCascadeChanged = false;
for (Dept dept : oaDeptList) {
if (deleteIds.contains(dept.getId())) {
continue;
}
String parentOaId = dept.getOaSupSubComId();
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
continue;
}
Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())
? companyByOaId.get(parentOaId)
: Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId));
if (parent != null && deleteIds.contains(parent.getId())) {
deleteIds.add(dept.getId());
oaCascadeChanged = true;
}
}
}
// 按本地 parent_id 再级联一轮,清掉挂在已删节点下的子树
List<Dept> allDeptList = this.list();
boolean parentIdCascadeChanged = true;
while (parentIdCascadeChanged) {
parentIdCascadeChanged = false;
for (Dept dept : allDeptList) {
if (deleteIds.contains(dept.getId())) {
continue;
}
if (dept.getParentId() != null && deleteIds.contains(dept.getParentId())) {
deleteIds.add(dept.getId());
parentIdCascadeChanged = true;
}
}
}
this.removeByIds(deleteIds);
log.info("删除OA层级无法挂载的组织{}条", deleteIds.size());
return deleteIds.size();
}
/**
* 按机构类型建立 OA 主键索引
*/
private Map<String, Dept> indexByOaId(List<Dept> oaDeptList, Integer deptCategory) {
Map<String, Dept> oaIdMap = new HashMap<>();
for (Dept dept : oaDeptList) {
if (!Objects.equals(deptCategory, dept.getDeptCategory()) || StringUtil.isBlank(dept.getOaId())) {
continue;
}
Dept previous = oaIdMap.put(dept.getOaId(), dept);
if (previous != null) {
log.warn("OA主键重复,层级重建使用后一条 category={} oaId={} id={} previousId={}",
dept.getDeptCategory(), dept.getOaId(), dept.getId(), previous.getId());
}
}
return oaIdMap;
}
/**
* 根据 OA 上级主键解析本系统父级
*/
private OaParentLink resolveOaParentLink(Dept dept, Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
String parentOaId = dept.getOaSupSubComId();
boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId);
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
if (rootParent) {
return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID);
}
Dept parent = companyByOaId.get(parentOaId);
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
dept.getOaId(), parentOaId, dept.getId());
return null;
}
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId));
}
if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) {
return null;
}
if (rootParent) {
log.warn("部门缺少上级OA主键,跳过层级重建 oaId={} deptId={}", dept.getOaId(), dept.getId());
return null;
}
boolean parentIsDepartment = StringUtil.isNotBlank(dept.getParentCode())
&& dept.getParentCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
boolean parentIsCompany = StringUtil.isNotBlank(dept.getParentCode())
&& dept.getParentCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
Dept parent;
if (parentIsDepartment) {
parent = departmentByOaId.get(parentOaId);
} else if (parentIsCompany) {
parent = companyByOaId.get(parentOaId);
} else {
parent = departmentByOaId.get(parentOaId);
if (parent == null) {
parent = companyByOaId.get(parentOaId);
}
}
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
log.warn("未找到上级组织,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
dept.getOaId(), parentOaId, dept.getId());
return null;
}
String prefix = DeptCategory.DEPT.getCode().equals(parent.getDeptCategory())
? OAConvertConstant.DEPARTMENT_OA_PREFIX : OAConvertConstant.COMPANY_OA_PREFIX;
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, prefix, parentOaId));
}
private String resolveParentCode(Dept parent, String prefix, String parentOaId) {
if (StringUtil.isNotBlank(parent.getDeptCode())) {
return parent.getDeptCode();
}
return prefix + parentOaId;
}
/**
* 找出父子关系成环的部门
*/
private Set<Long> findCyclicDeptIds(Map<Long, Long> parentIdMap) {
Set<Long> cyclicIds = new HashSet<>();
for (Long deptId : parentIdMap.keySet()) {
Set<Long> visiting = new HashSet<>();
Long currentId = deptId;
while (currentId != null && !OAConvertConstant.ROOT_PARENT_ID.equals(currentId)) {
if (!visiting.add(currentId)) {
cyclicIds.addAll(visiting);
break;
}
currentId = parentIdMap.get(currentId);
}
}
return cyclicIds;
}
/**
* 刷新祖级列表
*
* @param refreshAll 是否刷新全部部门
* @param startTime 增量起始时间,仅 refreshAll 为 false 时生效
*/
private void refreshAncestors(boolean refreshAll, Date startTime) {
List<Dept> list = refreshAll ? this.list() : this.list(Wrappers.<Dept>lambdaQuery()
.isNull(Dept::getAncestors) .isNull(Dept::getAncestors)
// 开始时间不为空,查询同步时间大于开始时间或修改时间大于开始时间的数据
.or(startTime != null, wrapper -> wrapper.ge(Dept::getSyncTime, startTime)) .or(startTime != null, wrapper -> wrapper.ge(Dept::getSyncTime, startTime))
.or(startTime != null, wrapper -> wrapper.ge(Dept::getUpdateTime, startTime)) .or(startTime != null, wrapper -> wrapper.ge(Dept::getUpdateTime, startTime))
); );
@@ -565,29 +815,19 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
log.info("需要更新祖级列表的部门为空"); log.info("需要更新祖级列表的部门为空");
return; return;
} }
// 所有公司部门列表 List<DeptVO> allList = (refreshAll ? list : this.list()).stream()
List<DeptVO> allList = this.list().stream()
.map(dept -> BeanUtil.copyProperties(dept, DeptVO.class)) .map(dept -> BeanUtil.copyProperties(dept, DeptVO.class))
.toList(); .toList();
// id对应的部门map
Map<Long, DeptVO> idDeptMap = allList.stream() Map<Long, DeptVO> idDeptMap = allList.stream()
.collect(Collectors.toMap(DeptVO::getId, Function.identity(), (a, b) -> b)); .collect(Collectors.toMap(DeptVO::getId, Function.identity(), (a, b) -> b));
// 获取根节点列表
List<DeptVO> rootList = getRootList(allList, idDeptMap); List<DeptVO> rootList = getRootList(allList, idDeptMap);
// 计算祖级列表
setAncestors(rootList); setAncestors(rootList);
// 3. 更新祖级列表
List<Dept> updateParams = list.stream() List<Dept> updateParams = list.stream()
.filter(dept -> { .filter(dept -> {
DeptVO vo = idDeptMap.get(dept.getId()); DeptVO vo = idDeptMap.get(dept.getId());
if (vo == null) { if (vo == null || vo.getAncestors() == null) {
return false; return false;
} }
if (vo.getAncestors() == null) {
return false;
}
// 祖级列表和数据库的不一样的才需要更新
return !StringUtil.equals(vo.getAncestors(), dept.getAncestors()); return !StringUtil.equals(vo.getAncestors(), dept.getAncestors());
}) })
.map(dept -> { .map(dept -> {
@@ -602,6 +842,21 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
} }
} }
/**
* OA 上级链接
*/
private record OaParentLink(Long parentId, String parentCode) {
}
/**
* 统一的租户范围过滤:超管放行,其他用户强制限定为当前会话租户。
*/
private void applyTenantScope(QueryWrapper<Dept> queryWrapper) {
if (!AuthUtil.isAdministrator()) {
queryWrapper.lambda().eq(Dept::getTenantId, AuthUtil.getTenantId());
}
}
/** /**
* 获取根节点列表 * 获取根节点列表
* @param allList * @param allList
@@ -74,10 +74,18 @@ public class OASyncServiceImpl implements IOASyncService {
Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> { Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> {
// 1. 同步公司 // 1. 同步公司
this.syncCompany(queryStartTime); this.syncCompany(queryStartTime);
// 公司分批入库后,重建层级并清理无法挂载的公司
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
// 2. 同步部门 // 2. 同步部门
this.syncDept(queryStartTime); this.syncDept(queryStartTime);
// 2. 更新部门祖级列表 // 3. 分批写入完成后,按 OA 主键重建组织、部门层级
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
// 4. 更新部门祖级列表
deptService.updateAncestors(queryStartTime); deptService.updateAncestors(queryStartTime);
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
}, syncAll); }, syncAll);
// 2. 推送mk // 2. 推送mk
mkPushService.pushOrgAndRecord(startTime); mkPushService.pushOrgAndRecord(startTime);
@@ -196,7 +204,7 @@ public class OASyncServiceImpl implements IOASyncService {
return new ServiceException("调用OA接口查询公司信息失败"); return new ServiceException("调用OA接口查询公司信息失败");
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> { }, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
// 处理数据 // 处理数据
OrgSyncCount syncCount = handleCompany(list); OrgSyncCount syncCount = handleCompany(list, false);
notHandleList.addAll(syncCount.getNotHandledList()); notHandleList.addAll(syncCount.getNotHandledList());
}); });
// 3. 未处理的数据 // 3. 未处理的数据
@@ -213,7 +221,7 @@ public class OASyncServiceImpl implements IOASyncService {
* @param startTime 查询开始时间 * @param startTime 查询开始时间
*/ */
private void syncDept(Date startTime) { private void syncDept(Date startTime) {
String subCompanyIds = getSubCompanyIds(); String subCompanyIds = getOaSubCompanyIds(false);
if (StringUtils.isEmpty(subCompanyIds)) { if (StringUtils.isEmpty(subCompanyIds)) {
ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数"); ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数");
return; return;
@@ -228,7 +236,7 @@ public class OASyncServiceImpl implements IOASyncService {
return new ServiceException("调用OA接口查询部门信息失败"); return new ServiceException("调用OA接口查询部门信息失败");
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> { }, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
// 处理数据 // 处理数据
OrgSyncCount syncCount = handleDept(list); OrgSyncCount syncCount = handleDept(list, false);
notHandleList.addAll(syncCount.getNotHandledList()); notHandleList.addAll(syncCount.getNotHandledList());
}); });
// 3. 未处理的数据 // 3. 未处理的数据
@@ -334,13 +342,18 @@ public class OASyncServiceImpl implements IOASyncService {
List<OACompanyResponse> oaCompanies = responseData.getDataList() == null List<OACompanyResponse> oaCompanies = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList(); ? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
OrgSyncCount syncCount = handleCompany(oaCompanies); OrgSyncCount syncCount = handleCompany(oaCompanies, true);
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
} }
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
if (Boolean.TRUE.equals(pageVO.getFinished())) {
// 公司全部分批入库后:重建层级,并删除上级不存在的数据
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
}
CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}", ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
return pageVO; return pageVO;
@@ -356,9 +369,9 @@ public class OASyncServiceImpl implements IOASyncService {
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) { private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) {
int pageNo = current < 1 ? 1 : current; int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200); int pageSize = size < 1 ? 20 : Math.min(size, 200);
String subCompanyIds = getSubCompanyIds(); String subCompanyIds = getOaSubCompanyIds(true);
if (StringUtils.isEmpty(subCompanyIds)) { if (StringUtils.isEmpty(subCompanyIds)) {
ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数,跳过部门同步"); ComposeLogUtil.getLastLog().warn("未查询到已同步的OA公司,跳过部门同步");
OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty()); OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty());
emptyPageVO.setFinished(true); emptyPageVO.setFinished(true);
return emptyPageVO; return emptyPageVO;
@@ -375,17 +388,19 @@ public class OASyncServiceImpl implements IOASyncService {
List<OADepartmentResponse> oaDepartments = responseData.getDataList() == null List<OADepartmentResponse> oaDepartments = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList(); ? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
OrgSyncCount syncCount = handleDept(oaDepartments); OrgSyncCount syncCount = handleDept(oaDepartments, true);
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
} }
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount); OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount);
if (Boolean.TRUE.equals(pageVO.getFinished())) { if (Boolean.TRUE.equals(pageVO.getFinished())) {
// 部门同步完成后新祖级列表 // 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
deptService.updateAncestors(null); deptService.updateAncestors(null);
} }
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}", ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
return pageVO; return pageVO;
@@ -469,19 +484,22 @@ public class OASyncServiceImpl implements IOASyncService {
/** /**
* 处理oa公司 * 处理oa公司
* @param oaCompanies * @param oaCompanies OA公司列表
* @param syncAll true=自动同步时不过滤字典白名单;false=仅同步配置的公司名称
* @return 同步统计 * @return 同步统计
*/ */
private OrgSyncCount handleCompany(List<OACompanyResponse> oaCompanies) { private OrgSyncCount handleCompany(List<OACompanyResponse> oaCompanies, boolean syncAll) {
if (CollectionUtil.isEmpty(oaCompanies)) { if (CollectionUtil.isEmpty(oaCompanies)) {
return OrgSyncCount.empty(); return OrgSyncCount.empty();
} }
// 获取需要的公司名称 // 获取需要的公司名称
Set<String> companyNames = getCompanyNames(); Set<String> companyNames = syncAll ? Collections.emptySet() : getCompanyNames();
// 转换数据 // 转换数据
List<Dept> allParam = oaCompanies.stream() List<Dept> allParam = oaCompanies.stream()
// 筛选只要需要的公司 // 强制跳过上级公司为 0 的数据
.filter(company -> companyNames.contains(company.getSubcompanyname())) .filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid()))
// 非全量同步时,筛选只要配置的公司
.filter(company -> syncAll || companyNames.contains(company.getSubcompanyname()))
.map(deptConvert::company2dept) .map(deptConvert::company2dept)
.toList(); .toList();
int filteredSkipCount = oaCompanies.size() - allParam.size(); int filteredSkipCount = oaCompanies.size() - allParam.size();
@@ -501,10 +519,11 @@ public class OASyncServiceImpl implements IOASyncService {
/** /**
* 处理oa部门 * 处理oa部门
* @param oaDepts * @param oaDepts OA部门列表
* @param syncAll true=自动同步时不过滤根公司部门白名单;false=沿用原过滤规则
* @return 同步统计 * @return 同步统计
*/ */
private OrgSyncCount handleDept(List<OADepartmentResponse> oaDepts) { private OrgSyncCount handleDept(List<OADepartmentResponse> oaDepts, boolean syncAll) {
if (CollectionUtil.isEmpty(oaDepts)) { if (CollectionUtil.isEmpty(oaDepts)) {
return OrgSyncCount.empty(); return OrgSyncCount.empty();
} }
@@ -512,31 +531,42 @@ public class OASyncServiceImpl implements IOASyncService {
Map<String, Long> companyDeptMap = getAllCompanyDeptMap(); Map<String, Long> companyDeptMap = getAllCompanyDeptMap();
// 根公司id // 根公司id
String rootCompanyId = getRootCompanyId(); String rootCompanyId = getRootCompanyId();
if (rootCompanyId == null) { if (!syncAll && rootCompanyId == null) {
return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList()); return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList());
} }
// 根公司下要同步的部门名称 // 根公司下要同步的部门名称
Set<String> rootCompanyDeptNames = getRootCompanyDeptNames(); Set<String> rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames();
// 转换数据 // 转换数据
List<Dept> allParam = oaDepts.stream() List<Dept> allParam = oaDepts.stream()
// 部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门 // 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门
.filter(oaDept -> !rootCompanyId.equals(oaDept.getSubcompanyid1()) || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) .filter(oaDept -> syncAll
|| rootCompanyId == null
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
.map(dept -> deptConvert.dept2dept(dept, companyDeptMap)) .map(dept -> deptConvert.dept2dept(dept, companyDeptMap))
.toList(); .toList();
int filteredSkipCount = oaDepts.size() - allParam.size(); int filteredSkipCount = oaDepts.size() - allParam.size();
if (CollectionUtil.isEmpty(allParam)) { if (CollectionUtil.isEmpty(allParam)) {
return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList());
} }
Set<String> deptCodes = allParam.stream() Set<String> deptCodes = new HashSet<>();
.map(Dept::getDeptCode) allParam.forEach(dept -> {
.collect(Collectors.toSet()); if (StringUtils.isNotEmpty(dept.getDeptCode())) {
// 查询数据库的部门,转换成map deptCodes.add(dept.getDeptCode());
Map<String, Long> deptMap = deptService.list(Wrappers.<Dept>lambdaQuery() }
if (StringUtils.isNotEmpty(dept.getParentCode())) {
deptCodes.add(dept.getParentCode());
}
});
// 查询数据库的部门,转换成map。包含本页及上级编码,便于命中已入库的上级
Map<String, Long> deptMap = deptCodes.isEmpty() ? new HashMap<>() : deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
.in(Dept::getDeptCode, deptCodes) .in(Dept::getDeptCode, deptCodes)
).stream() ).stream()
.filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode()))
.collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b)); .collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b));
// 公司编码也放入 map,根部门挂到公司时才能解析父级
companyDeptMap.forEach(deptMap::putIfAbsent);
// 处理部门 // 处理部门
List<Dept> notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT); List<Dept> notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT);
int syncedCount = allParam.size() - notHandledList.size(); int syncedCount = allParam.size() - notHandledList.size();
@@ -564,14 +594,30 @@ public class OASyncServiceImpl implements IOASyncService {
} }
}); });
// 重新把没有父id的数据设置一下父id // 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据
allParam.stream() int unresolvedParentCount = 0;
.filter(dept -> dept.getParentId() == null && deptMap.containsKey(dept.getDeptCode())) for (Dept dept : allParam) {
.forEach(dept -> dept.setParentId(deptMap.get(dept.getParentCode()))); if (dept.getParentId() != null) {
continue;
}
Long parentId = deptMap.get(dept.getParentCode());
if (parentId != null) {
dept.setParentId(parentId);
continue;
}
unresolvedParentCount++;
if (!existsDeptIds.contains(dept.getId())) {
dept.setParentId(OAConvertConstant.ROOT_PARENT_ID);
}
}
if (unresolvedParentCount > 0) {
ComposeLogUtil.getLastLog().warn("同步{}时有{}条上级尚未入库,已先保存,分批结束后重建层级并清理无法挂载数据",
deptCategory.getName(), unresolvedParentCount);
}
// 父id不为空,保存数据 // 父id不为空才新增;已存在的记录即使父id未解析也更新(空父id不会覆盖原值)
List<Dept> list = allParam.stream() List<Dept> list = allParam.stream()
.filter(dept -> dept.getParentId() != null) .filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId()))
.toList(); .toList();
// 不存在的新增 // 不存在的新增
List<Dept> addList = list.stream() List<Dept> addList = list.stream()
@@ -589,9 +635,9 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.getLastLog().info("批量修改{}{}", deptCategory.getName(), updateList.size()); ComposeLogUtil.getLastLog().info("批量修改{}{}", deptCategory.getName(), updateList.size());
deptService.updateBatchById(updateList); deptService.updateBatchById(updateList);
} }
// 返回未处理的 // 返回仍未保存的数据
return allParam.stream() return allParam.stream()
.filter(dept -> dept.getParentId() == null) .filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))
.toList(); .toList();
} }
@@ -627,26 +673,50 @@ public class OASyncServiceImpl implements IOASyncService {
} }
/** /**
* 获取oa查询参数,子公司id参数 * 获取 OA 部门查询使用的子公司 id
* @return *
* @param syncAll true=取已同步的全部 OA 公司;false=仅取字典配置的公司
* @return 逗号分隔的 OA 公司 id
*/ */
private String getSubCompanyIds() { private String getOaSubCompanyIds(boolean syncAll) {
List<Dept> companyList;
if (syncAll) {
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
);
} else {
Set<String> companyNames = this.getCompanyNames(); Set<String> companyNames = this.getCompanyNames();
if (companyNames.isEmpty()) { if (companyNames.isEmpty()) {
return null; return null;
} }
return deptService.list(Wrappers.<Dept>lambdaQuery() companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
// 根据名称查询公司
.in(Dept::getDeptName, companyNames) .in(Dept::getDeptName, companyNames)
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
).stream() );
// 只要部门编码不为空,且包含了oa公司前缀的 }
.filter(dept -> StringUtils.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().contains(OAConvertConstant.COMPANY_OA_PREFIX)) return companyList.stream()
// 去掉oa公司前缀 .map(this::resolveOaCompanyId)
.map(dept -> dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "")) .filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.joining(",")); .collect(Collectors.joining(","));
} }
/**
* 解析本地公司对应的 OA 公司 id
*/
private String resolveOaCompanyId(Dept dept) {
if (dept == null) {
return null;
}
if (StringUtils.isNotBlank(dept.getOaId())) {
return dept.getOaId();
}
if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX)) {
return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
}
return null;
}
/** /**
* 获取默认角色id * 获取默认角色id
*/ */
@@ -35,6 +35,7 @@ import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache; import org.springblade.system.cache.UserCache;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel; import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.excel.PortTerminalExportExcel; import org.springblade.system.excel.PortTerminalExportExcel;
import org.springblade.system.mapper.PortTerminalMapper; import org.springblade.system.mapper.PortTerminalMapper;
@@ -45,17 +46,19 @@ import org.springblade.system.service.IPortTerminalService;
import org.springblade.system.service.IRegionService; import org.springblade.system.service.IRegionService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.io.Serial;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TreeMap; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -69,12 +72,10 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
private static final String CATEGORY_PORT = "港口"; private static final String CATEGORY_PORT = "港口";
private static final String CATEGORY_TERMINAL = "码头"; private static final String CATEGORY_TERMINAL = "码头";
private static final String SOURCE_INITIAL = "初始化"; private static final String SOURCE_INITIAL = "初始化";
private static final String SOURCE_INITIAL_IMPORT = "初始化导入";
private static final String SOURCE_INITIAL_OLD = "初始导入"; private static final String SOURCE_INITIAL_OLD = "初始导入";
private static final String SOURCE_BATCH = "批量导入"; private static final String SOURCE_BATCH = "批量导入";
private static final String SOURCE_MANUAL = "动录"; private static final String SOURCE_MANUAL = "工导";
private static final String SOURCE_MANUAL_OLD = "手工导入";
private static final int STATUS_ENABLED = 1; private static final int STATUS_ENABLED = 1;
private static final int STATUS_DISABLED = 2; private static final int STATUS_DISABLED = 2;
private static final int CODE_MAX_LENGTH = 30; private static final int CODE_MAX_LENGTH = 30;
@@ -141,270 +142,209 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isEmpty(data)) { if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空"); throw new ServiceException("导入数据不能为空");
} }
Map<Integer, PortTerminalExcel> errorMap = new TreeMap<>(); // 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
Map<String, Integer> codeCountMap = buildImportCodeCountMap(data); List<PortTerminalExcel> failureList = new ArrayList<>();
importPortTerminalByCategory(data, codeCountMap, errorMap, true); // 批内已占用的编码,用于识别文件内重复数据。
importPortTerminalByCategory(data, codeCountMap, errorMap, false); Map<String, Integer> occupiedCodeMap = new HashMap<>();
if (Func.isNotEmpty(errorMap)) { // 本文件声明的一级数据(港口)编码集合。
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 用于区分两种情况:父港口"漏填"与"父行自身校验失败"。
// 后者说明父行已被标红、用户只需改那一行,因此不再连带给码头行报错。
Set<String> declaredPortCodeSet = new HashSet<>();
// 两阶段导入:先落库一级数据(港口),再落库二级数据(码头),消除对 Excel 行序的依赖。
Map<String, PortTerminal> batchPortMap = new LinkedHashMap<>();
List<Integer> portIndexList = new ArrayList<>();
List<Integer> terminalIndexList = new ArrayList<>();
splitByCategory(data, portIndexList, terminalIndexList, occupiedCodeMap, declaredPortCodeSet, failureList);
importPorts(data, portIndexList, batchPortMap, failureList);
importTerminals(data, terminalIndexList, batchPortMap, declaredPortCodeSet, failureList);
if (Func.isNotEmpty(failureList)) {
// 抛出携带失败明细的异常,触发事务回滚。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new ImportFailureException(data);
} }
return new ArrayList<>(errorMap.values()); return failureList;
} }
private void importPortTerminalByCategory(List<PortTerminalExcel> data, Map<String, Integer> codeCountMap, /**
Map<Integer, PortTerminalExcel> errorMap, boolean importPort) { * 按类型拆分数据行,并完成与阶段无关的基础校验(类型、编码格式、批内重复)。
*
* @param data 导入数据
* @param portIndexList 港口行下标
* @param terminalIndexList 码头行下标
* @param occupiedCodeMap 编码占用情况,值为首次出现的下标
* @param declaredPortCodeSet 本文件声明的港口编码(含编码格式不合法的行)
* @param failureList 失败明细
*/
private void splitByCategory(List<PortTerminalExcel> data, List<Integer> portIndexList, List<Integer> terminalIndexList,
Map<String, Integer> occupiedCodeMap, Set<String> declaredPortCodeSet,
List<PortTerminalExcel> failureList) {
for (int index = 0; index < data.size(); index++) { for (int index = 0; index < data.size(); index++) {
PortTerminalExcel excel = data.get(index); PortTerminalExcel excel = data.get(index);
boolean isPort = CATEGORY_PORT.equals(trimToEmpty(excel.getCategory())); String category = trimToEmpty(excel.getCategory());
if (isPort != importPort) { boolean categoryValid = CATEGORY_PORT.equals(category) || CATEGORY_TERMINAL.equals(category);
if (!categoryValid) {
failureList.add(buildFailure(data, index, "类型只能为港口或码头"));
continue; continue;
} }
PortTerminal portTerminal = buildImportPortTerminal(excel); // 码头行的「港口编码」既是编号前缀,也是"所属港口"的来源(见 importTerminals)。
// 缺失时下游解析不到父港口,而该异常又会被"父行已声明"的抑制逻辑吞掉,
// 导致码头被静默丢弃、接口却返回成功——所以必须在这里明确拦下。
if (CATEGORY_TERMINAL.equals(category) && Func.isEmpty(trimToEmpty(excel.getPortCode()))) {
failureList.add(buildFailure(data, index, "码头必须填写所属港口编码"));
continue;
}
// 只要类型是港口就登记为"本文件已声明",即使它的编码格式不合法:
// 这样引用它的码头行不会被连带报错,用户只需修正这一个港口行。
if (CATEGORY_PORT.equals(category)) {
String declaredCode = resolveImportCode(excel);
if (Func.isNotEmpty(declaredCode)) {
declaredPortCodeSet.add(declaredCode);
}
}
String code = resolveImportCode(excel);
boolean codeValid = CATEGORY_PORT.equals(category)
? PORT_CODE_PATTERN.matcher(code).matches()
: TERMINAL_CODE_PATTERN.matcher(code).matches();
if (!codeValid) {
String message = CATEGORY_PORT.equals(category) ? "港口编码为5位大写字母" : "码头编码格式为港口编码-码头标识";
failureList.add(buildFailure(data, index, message));
continue;
}
// 批内重复:两行都需要标记,由用户决定保留哪一行。
if (occupiedCodeMap.containsKey(code)) {
markFailure(data, occupiedCodeMap.get(code), "编码 " + code + " 在文件中重复出现");
failureList.add(buildFailure(data, index, "编码 " + code + " 在文件中重复出现"));
continue;
}
occupiedCodeMap.put(code, index);
if (CATEGORY_PORT.equals(category)) {
portIndexList.add(index);
} else {
terminalIndexList.add(index);
}
}
}
/**
* 阶段一:导入港口(一级数据),同时登记到批次内存映射,供码头引用。
*/
private void importPorts(List<PortTerminalExcel> data, List<Integer> portIndexList, Map<String, PortTerminal> batchPortMap,
List<PortTerminalExcel> failureList) {
for (Integer index : portIndexList) {
PortTerminalExcel excel = data.get(index);
try {
PortTerminal portTerminal = Objects.requireNonNull(BeanUtil.copyProperties(excel, PortTerminal.class));
// 导入模板按"港口编码/码头编码"两列填写,需归并为实体编码。
portTerminal.setCode(resolveImportCode(excel));
portTerminal.setDataSource(SOURCE_BATCH); portTerminal.setDataSource(SOURCE_BATCH);
portTerminal.setStatus(STATUS_ENABLED); portTerminal.setStatus(STATUS_ENABLED);
normalizeImportPortTerminal(portTerminal);
List<String> validationErrors = validateImportPortTerminal(excel, portTerminal, codeCountMap);
if (Func.isNotEmpty(validationErrors)) {
excel.setErrorMessage(formatImportErrorMessage(validationErrors));
errorMap.put(index, excel);
continue;
}
try {
prepare(portTerminal, SOURCE_BATCH); prepare(portTerminal, SOURCE_BATCH);
validate(portTerminal); validate(portTerminal);
prepareImportTarget(portTerminal); save(portTerminal);
if (!saveOrUpdate(portTerminal)) { batchPortMap.put(portTerminal.getCode(), portTerminal);
throw new ServiceException("港口码头保存失败"); } catch (Exception exception) {
failureList.add(buildFailure(data, index, resolveMessage(exception)));
}
}
}
/**
* 阶段二:导入码头(二级数据),上级港口优先取本批次新增的港口,其次回查数据库。
*
* @param declaredPortCodeSet 本文件声明的港口编码,用于避免连带误报
*/
private void importTerminals(List<PortTerminalExcel> data, List<Integer> terminalIndexList, Map<String, PortTerminal> batchPortMap,
Set<String> declaredPortCodeSet, List<PortTerminalExcel> failureList) {
for (Integer index : terminalIndexList) {
PortTerminalExcel excel = data.get(index);
try {
PortTerminal portTerminal = Objects.requireNonNull(BeanUtil.copyProperties(excel, PortTerminal.class));
// 导入模板按"港口编码/码头编码"两列填写,需归并为实体编码;上级港口取本行的港口编码列。
portTerminal.setCode(resolveImportCode(excel));
portTerminal.setParentCode(trimToEmpty(excel.getPortCode()).toUpperCase(Locale.ROOT));
portTerminal.setDataSource(SOURCE_BATCH);
portTerminal.setStatus(STATUS_ENABLED);
prepareTerminal(portTerminal, batchPortMap);
validate(portTerminal);
save(portTerminal);
} catch (ParentPortNotFoundException exception) {
// 上级港口就声明在本文件里,只是那一行自己校验失败(已被标红)。
// 此时码头行本身没有问题,不再连带报错,避免用户看到"两行都错"的假象。
// 注意这里必须与上面 setParentCode 取同一列(港口编码);
// 若改用「上级港口编码」,父港口取不到时会被误判为"已声明"而吞掉异常。
if (!declaredPortCodeSet.contains(trimToEmpty(excel.getPortCode()).toUpperCase(Locale.ROOT))) {
failureList.add(buildFailure(data, index, resolveMessage(exception)));
} }
} catch (Exception exception) { } catch (Exception exception) {
String message = exception instanceof ServiceException ? exception.getMessage() : "导入失败"; failureList.add(buildFailure(data, index, resolveMessage(exception)));
excel.setErrorMessage(formatImportErrorMessage(List.of(message)));
errorMap.put(index, excel);
} }
} }
} }
private void prepareImportTarget(PortTerminal portTerminal) { /**
if (Func.isNotEmpty(portTerminal.getId())) { * 码头导入:上级港口优先匹配本批次新增的港口,其次由 prepare 回查数据库。
*/
private void prepareTerminal(PortTerminal portTerminal, Map<String, PortTerminal> batchPortMap) {
String parentCode = trimToEmpty(portTerminal.getParentCode()).toUpperCase(Locale.ROOT);
PortTerminal parent = Func.isEmpty(parentCode) ? null : batchPortMap.get(parentCode);
if (Func.isNotEmpty(parent)) {
applyParent(portTerminal, parent);
// 父信息已由本批次港口回填,无需再回查数据库。
prepare(portTerminal, SOURCE_BATCH, true);
return; return;
} }
PortTerminal existingPortTerminal = baseMapper.selectByCodeIncludingDeleted(portTerminal.getCode()); portTerminal.setParentCode(parentCode);
if (existingPortTerminal == null) { prepare(portTerminal, SOURCE_BATCH);
return;
}
if (!Objects.equals(existingPortTerminal.getIsDeleted(), 1)) {
throw new ServiceException("该编码已存在");
}
baseMapper.restoreById(existingPortTerminal.getId());
portTerminal.setId(existingPortTerminal.getId());
portTerminal.setIsDeleted(0);
} }
private String formatImportErrorMessage(List<String> validationErrors) { /**
StringBuilder errorMessage = new StringBuilder(); * 将上级港口信息回填到码头,保证码头不会出现没有港口的数据。
for (int index = 0; index < validationErrors.size(); index++) { */
if (index > 0) { private void applyParent(PortTerminal portTerminal, PortTerminal parent) {
errorMessage.append(System.lineSeparator()); portTerminal.setParentId(parent.getId());
portTerminal.setParentCode(parent.getCode());
portTerminal.setParentName(parent.getName());
// 与回查数据库保持一致:父港口区域信息为空时保留码头自身填写的值。
if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setCountry(parent.getCountry());
} }
errorMessage.append(index + 1).append(". ").append(validationErrors.get(index)); if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setCity(parent.getCity());
} }
return errorMessage.toString(); if (Func.isNotEmpty(parent.getDistrictCode())) {
portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setRegionCode(parent.getDistrictCode());
} else if (Func.isNotEmpty(portTerminal.getDistrictCode())) {
portTerminal.setRegionCode(portTerminal.getDistrictCode());
} }
if (Func.isNotEmpty(parent.getDistrictName())) {
private Map<String, Integer> buildImportCodeCountMap(List<PortTerminalExcel> data) { portTerminal.setDistrictName(parent.getDistrictName());
Map<String, Integer> codeCountMap = new HashMap<>();
for (PortTerminalExcel excel : data) {
String code = resolveImportCode(excel);
if (Func.isNotEmpty(code)) {
codeCountMap.merge(code, 1, Integer::sum);
}
}
return codeCountMap;
}
private PortTerminal buildImportPortTerminal(PortTerminalExcel excel) {
PortTerminal portTerminal = new PortTerminal();
portTerminal.setCode(resolveImportCode(excel));
portTerminal.setName(excel.getName());
portTerminal.setCategory(excel.getCategory());
portTerminal.setParentName(excel.getParentName());
portTerminal.setParentCode(CATEGORY_TERMINAL.equals(trimToEmpty(excel.getCategory())) ? excel.getPortCode() : excel.getParentCode());
portTerminal.setCountry(excel.getCountry());
portTerminal.setProvinceName(excel.getProvinceName());
portTerminal.setCity(excel.getCity());
portTerminal.setDistrictName(excel.getDistrictName());
portTerminal.setDetailAddress(excel.getDetailAddress());
portTerminal.setLongitude(excel.getLongitude());
portTerminal.setLatitude(excel.getLatitude());
portTerminal.setRemark(excel.getRemark());
return portTerminal;
}
private String resolveImportCode(PortTerminalExcel excel) {
String portCode = trimToEmpty(excel.getPortCode()).toUpperCase(Locale.ROOT);
String terminalCode = trimToEmpty(excel.getTerminalCode()).toUpperCase(Locale.ROOT);
if (Func.isEmpty(terminalCode)) {
return portCode;
}
return Func.isEmpty(portCode) ? terminalCode : portCode + "-" + terminalCode;
}
private void normalizeImportPortTerminal(PortTerminal portTerminal) {
portTerminal.setCode(trimToEmpty(portTerminal.getCode()).toUpperCase(Locale.ROOT));
portTerminal.setCategory(trimToEmpty(portTerminal.getCategory()));
portTerminal.setName(trimToEmpty(portTerminal.getName()));
portTerminal.setParentCode(trimToNull(portTerminal.getParentCode()));
if (Func.isNotEmpty(portTerminal.getParentCode())) {
portTerminal.setParentCode(portTerminal.getParentCode().toUpperCase(Locale.ROOT));
}
portTerminal.setParentName(trimToNull(portTerminal.getParentName()));
portTerminal.setCountry(trimToEmpty(portTerminal.getCountry()));
portTerminal.setProvinceCode(trimToNull(portTerminal.getProvinceCode()));
portTerminal.setProvinceName(trimToNull(portTerminal.getProvinceName()));
portTerminal.setCity(trimToEmpty(portTerminal.getCity()));
portTerminal.setDistrictCode(trimToNull(portTerminal.getDistrictCode()));
portTerminal.setDistrictName(trimToNull(portTerminal.getDistrictName()));
portTerminal.setRegionCode(trimToNull(portTerminal.getRegionCode()));
if (Func.isEmpty(portTerminal.getDistrictCode()) && Func.isNotEmpty(portTerminal.getRegionCode())) {
portTerminal.setDistrictCode(portTerminal.getRegionCode());
}
portTerminal.setDetailAddress(trimToNull(portTerminal.getDetailAddress()));
portTerminal.setRemark(trimToNull(portTerminal.getRemark()));
}
private List<String> validateImportPortTerminal(PortTerminalExcel excel, PortTerminal portTerminal,
Map<String, Integer> codeCountMap) {
List<String> validationErrors = new ArrayList<>();
if (!CATEGORY_PORT.equals(portTerminal.getCategory()) && !CATEGORY_TERMINAL.equals(portTerminal.getCategory())) {
addValidationError(validationErrors, "类型只能为港口或码头");
}
validateImportCodeColumns(excel, portTerminal, validationErrors);
if (Func.isEmpty(portTerminal.getCode())) {
addValidationError(validationErrors, "编码不能为空");
} else {
validateImportLength(portTerminal.getCode(), CODE_MAX_LENGTH, "编码不能超过30字", validationErrors);
if (CATEGORY_PORT.equals(portTerminal.getCategory()) && !PORT_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
addValidationError(validationErrors, "港口编码为5位大写字母");
}
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory()) && !TERMINAL_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
addValidationError(validationErrors, "码头编码格式为港口编码-码头标识");
}
if (codeCountMap.getOrDefault(portTerminal.getCode(), 0) > 1) {
addValidationError(validationErrors, "编码在本次导入中重复");
}
if (count(Wrappers.<PortTerminal>lambdaQuery()
.eq(PortTerminal::getCode, portTerminal.getCode())
.eq(PortTerminal::getIsDeleted, 0)) > 0L) {
addValidationError(validationErrors, "该编码已存在");
}
}
if (Func.isEmpty(portTerminal.getName())) {
addValidationError(validationErrors, "港口/码头名称不能为空");
}
validateImportLength(portTerminal.getName(), NAME_MAX_LENGTH, "港口/码头名称不能超过100字", validationErrors);
validateImportLength(portTerminal.getCountry(), REGION_MAX_LENGTH, "国家不能超过50字", validationErrors);
validateImportLength(portTerminal.getProvinceName(), REGION_MAX_LENGTH, "所属省份不能超过50字", validationErrors);
validateImportLength(portTerminal.getCity(), REGION_MAX_LENGTH, "城市不能超过50字", validationErrors);
validateImportLength(portTerminal.getDistrictName(), REGION_MAX_LENGTH, "区县不能超过50字", validationErrors);
validateImportLength(portTerminal.getRegionCode(), REGION_CODE_MAX_LENGTH, "行政区划编码不能超过32字", validationErrors);
if (Func.isEmpty(portTerminal.getDetailAddress())) {
addValidationError(validationErrors, "详细地址不能为空");
}
validateImportLength(portTerminal.getDetailAddress(), DETAIL_ADDRESS_MAX_LENGTH, "详细地址不能超过255字", validationErrors);
validateImportLength(portTerminal.getRemark(), REMARK_MAX_LENGTH, "备注不能超过200个字", validationErrors);
if (Func.isEmpty(portTerminal.getLongitude())) {
addValidationError(validationErrors, "经度不能为空");
} else if (!validRange(portTerminal.getLongitude(), MIN_LONGITUDE, MAX_LONGITUDE)) {
addValidationError(validationErrors, "经度范围为 -180 到 180");
}
if (Func.isEmpty(portTerminal.getLatitude())) {
addValidationError(validationErrors, "纬度不能为空");
} else if (!validRange(portTerminal.getLatitude(), MIN_LATITUDE, MAX_LATITUDE)) {
addValidationError(validationErrors, "纬度范围为 -90 到 90");
}
if (CATEGORY_PORT.equals(portTerminal.getCategory())) {
validateImportPortRegion(portTerminal, validationErrors);
}
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory())) {
validateImportParentPort(portTerminal, validationErrors);
}
return validationErrors;
}
private void validateImportCodeColumns(PortTerminalExcel excel, PortTerminal portTerminal,
List<String> validationErrors) {
String portCode = trimToEmpty(excel.getPortCode()).toUpperCase(Locale.ROOT);
String terminalCode = trimToEmpty(excel.getTerminalCode()).toUpperCase(Locale.ROOT);
String parentCode = trimToEmpty(excel.getParentCode()).toUpperCase(Locale.ROOT);
if (Func.isEmpty(portCode)) {
addValidationError(validationErrors, "港口编码不能为空");
}
if (!CATEGORY_TERMINAL.equals(portTerminal.getCategory())) {
return;
}
if (Func.isEmpty(terminalCode)) {
addValidationError(validationErrors, "码头编码不能为空");
}
if (Func.isEmpty(portTerminal.getParentName())) {
addValidationError(validationErrors, "上级港口不能为空");
}
if (Func.isEmpty(parentCode)) {
addValidationError(validationErrors, "上级港口编码不能为空");
} else if (Func.isNotEmpty(portCode) && !portCode.equals(parentCode) && !portCode.endsWith(parentCode)) {
addValidationError(validationErrors, "港口编码与上级港口编码不匹配");
} }
} }
private void validateImportPortRegion(PortTerminal portTerminal, List<String> validationErrors) { /**
if (Func.isEmpty(portTerminal.getCountry())) { * 构造失败明细,行号按 Excel 中的实际行号(表头占第 1 行)推算。
addValidationError(validationErrors, "国家不能为空"); */
private PortTerminalExcel buildFailure(List<PortTerminalExcel> data, int index, String message) {
PortTerminalExcel excel = data.get(index);
markFailure(data, index, message);
return excel;
} }
if (Func.isEmpty(portTerminal.getCity())) {
addValidationError(validationErrors, "城市不能为空"); /**
} * 仅标注错误原因,不重复加入失败明细集合。
if (Func.isEmpty(portTerminal.getProvinceName())) { */
addValidationError(validationErrors, "所属省份不能为空"); private void markFailure(List<PortTerminalExcel> data, int index, String message) {
} if (index >= 0 && index < data.size()) {
if (Func.isEmpty(portTerminal.getDistrictCode()) && Func.isEmpty(portTerminal.getDistrictName())) { data.get(index).setErrorMessage("" + (index + 2) + "行:" + message);
addValidationError(validationErrors, "区县不能为空");
return;
}
try {
fillRegion(portTerminal);
} catch (ServiceException exception) {
addValidationError(validationErrors, exception.getMessage());
} }
} }
private void validateImportParentPort(PortTerminal portTerminal, List<String> validationErrors) { /**
if (Func.isEmpty(portTerminal.getParentCode())) { * 解析异常信息,非业务异常统一提示导入失败。
addValidationError(validationErrors, "上级港口编码不能为空"); */
return; private String resolveMessage(Exception exception) {
} return exception instanceof ServiceException ? exception.getMessage() : "导入失败";
PortTerminal parentPort = getOne(Wrappers.<PortTerminal>lambdaQuery()
.eq(PortTerminal::getCode, portTerminal.getParentCode())
.eq(PortTerminal::getCategory, CATEGORY_PORT)
.eq(PortTerminal::getIsDeleted, 0), false);
if (Func.isEmpty(parentPort)) {
addValidationError(validationErrors, "上级港口编码对应的港口不存在");
} else if (Func.isNotEmpty(portTerminal.getParentName()) && !Objects.equals(portTerminal.getParentName(), parentPort.getName())) {
addValidationError(validationErrors, "上级港口与上级港口编码不匹配");
}
if (Func.isNotEmpty(portTerminal.getCode()) && !portTerminal.getCode().startsWith(portTerminal.getParentCode() + "-")) {
addValidationError(validationErrors, "码头编码必须以上级港口编码开头");
}
}
private void validateImportLength(String value, int maxLength, String message, List<String> validationErrors) {
if (Func.isNotEmpty(value) && value.length() > maxLength) {
addValidationError(validationErrors, message);
}
}
private void addValidationError(List<String> validationErrors, String message) {
if (Func.isNotEmpty(message) && !validationErrors.contains(message)) {
validationErrors.add(message);
}
} }
@Override @Override
@@ -430,12 +370,21 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
} }
private void prepare(PortTerminal portTerminal, String defaultDataSource) { private void prepare(PortTerminal portTerminal, String defaultDataSource) {
prepare(portTerminal, defaultDataSource, false);
}
/**
* 整理并补全港口码头数据。
*
* @param portTerminal 港口码头
* @param defaultDataSource 默认数据来源
* @param parentResolved 上级港口是否已确定(批量导入时由批次内存匹配得到,无需回查数据库)
*/
private void prepare(PortTerminal portTerminal, String defaultDataSource, boolean parentResolved) {
portTerminal.setCode(trimToEmpty(portTerminal.getCode()).toUpperCase(Locale.ROOT)); portTerminal.setCode(trimToEmpty(portTerminal.getCode()).toUpperCase(Locale.ROOT));
portTerminal.setCategory(trimToEmpty(portTerminal.getCategory())); portTerminal.setCategory(trimToEmpty(portTerminal.getCategory()));
portTerminal.setName(trimToEmpty(portTerminal.getName())); portTerminal.setName(trimToEmpty(portTerminal.getName()));
portTerminal.setCountry(trimToEmpty(portTerminal.getCountry())); portTerminal.setCountry(trimToEmpty(portTerminal.getCountry()));
portTerminal.setProvinceCode(trimToNull(portTerminal.getProvinceCode()));
portTerminal.setProvinceName(trimToNull(portTerminal.getProvinceName()));
portTerminal.setCity(trimToEmpty(portTerminal.getCity())); portTerminal.setCity(trimToEmpty(portTerminal.getCity()));
portTerminal.setDistrictCode(trimToNull(portTerminal.getDistrictCode())); portTerminal.setDistrictCode(trimToNull(portTerminal.getDistrictCode()));
portTerminal.setDistrictName(trimToNull(portTerminal.getDistrictName())); portTerminal.setDistrictName(trimToNull(portTerminal.getDistrictName()));
@@ -456,6 +405,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setParentName(null); portTerminal.setParentName(null);
return; return;
} }
if (parentResolved) {
return;
}
fillParentPort(portTerminal); fillParentPort(portTerminal);
} }
@@ -471,7 +423,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
.eq(PortTerminal::getIsDeleted, 0)); .eq(PortTerminal::getIsDeleted, 0));
} }
if (Func.isEmpty(parent)) { if (Func.isEmpty(parent)) {
throw new ServiceException("码头必须选择上级港口"); // 用专用异常类型:调用方需要区分"父港口真的漏填"与"父行自身失败"
// 后者不应连带给码头行报错(见 importTerminals)。
throw new ParentPortNotFoundException("码头必须选择上级港口");
} }
if (!CATEGORY_PORT.equals(parent.getCategory())) { if (!CATEGORY_PORT.equals(parent.getCategory())) {
throw new ServiceException("上级港口类型不正确"); throw new ServiceException("上级港口类型不正确");
@@ -479,31 +433,25 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setParentId(parent.getId()); portTerminal.setParentId(parent.getId());
portTerminal.setParentCode(parent.getCode()); portTerminal.setParentCode(parent.getCode());
portTerminal.setParentName(parent.getName()); portTerminal.setParentName(parent.getName());
portTerminal.setCountry(inheritParentValue(parent.getCountry(), portTerminal.getCountry())); // 父港口区域信息为空时保留码头自身填写的值,避免历史数据(区县为空)导致码头无法导入。
portTerminal.setProvinceCode(inheritParentValue(parent.getProvinceCode(), portTerminal.getProvinceCode())); if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setProvinceName(inheritParentValue(parent.getProvinceName(), portTerminal.getProvinceName())); portTerminal.setCountry(parent.getCountry());
portTerminal.setCity(inheritParentValue(parent.getCity(), portTerminal.getCity())); }
portTerminal.setDistrictCode(inheritParentValue(parent.getDistrictCode(), portTerminal.getDistrictCode())); if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setDistrictName(inheritParentValue(parent.getDistrictName(), portTerminal.getDistrictName())); portTerminal.setCity(parent.getCity());
portTerminal.setRegionCode(inheritParentValue(parent.getDistrictCode(), portTerminal.getRegionCode())); }
if (Func.isEmpty(portTerminal.getDistrictCode()) && Func.isNotEmpty(portTerminal.getDistrictName())) { if (Func.isNotEmpty(parent.getDistrictCode())) {
fillRegion(portTerminal); portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setRegionCode(parent.getDistrictCode());
} else if (Func.isNotEmpty(portTerminal.getRegionCode())) {
portTerminal.setDistrictCode(portTerminal.getRegionCode());
}
if (Func.isNotEmpty(parent.getDistrictName())) {
portTerminal.setDistrictName(parent.getDistrictName());
} }
} }
private void fillRegion(PortTerminal portTerminal) { private void fillRegion(PortTerminal portTerminal) {
Region selectedProvince = null;
if (Func.isNotEmpty(portTerminal.getProvinceCode())) {
selectedProvince = regionService.getById(portTerminal.getProvinceCode());
}
if (Func.isEmpty(selectedProvince) && Func.isNotEmpty(portTerminal.getProvinceName())) {
selectedProvince = regionService.getOne(Wrappers.<Region>lambdaQuery()
.eq(Region::getName, portTerminal.getProvinceName())
.eq(Region::getRegionLevel, 1), false);
}
if (Func.isEmpty(selectedProvince)) {
throw new ServiceException("请选择所属省份");
}
Region district = null; Region district = null;
if (Func.isNotEmpty(portTerminal.getDistrictCode())) { if (Func.isNotEmpty(portTerminal.getDistrictCode())) {
district = regionService.getById(portTerminal.getDistrictCode()); district = regionService.getById(portTerminal.getDistrictCode());
@@ -512,7 +460,6 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isNotEmpty(portTerminal.getCity())) { if (Func.isNotEmpty(portTerminal.getCity())) {
List<Region> cityList = regionService.list(Wrappers.<Region>lambdaQuery() List<Region> cityList = regionService.list(Wrappers.<Region>lambdaQuery()
.eq(Region::getName, portTerminal.getCity()) .eq(Region::getName, portTerminal.getCity())
.eq(Region::getParentCode, selectedProvince.getCode())
.eq(Region::getRegionLevel, 2)); .eq(Region::getRegionLevel, 2));
for (Region city : cityList) { for (Region city : cityList) {
district = regionService.getOne(Wrappers.<Region>lambdaQuery() district = regionService.getOne(Wrappers.<Region>lambdaQuery()
@@ -536,26 +483,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isEmpty(city)) { if (Func.isEmpty(city)) {
throw new ServiceException("区县所属城市不存在"); throw new ServiceException("区县所属城市不存在");
} }
Region province = regionService.getById(city.getParentCode());
if (Func.isEmpty(province)) {
throw new ServiceException("城市所属省份不存在");
}
if (Func.isNotEmpty(portTerminal.getProvinceCode())
&& !Objects.equals(portTerminal.getProvinceCode(), province.getCode())) {
throw new ServiceException("城市与省份不匹配");
}
if (Func.isNotEmpty(portTerminal.getProvinceName())
&& !Objects.equals(portTerminal.getProvinceName(), province.getName())) {
throw new ServiceException("城市与省份不匹配");
}
if (!Objects.equals(selectedProvince.getCode(), province.getCode())) {
throw new ServiceException("城市与省份不匹配");
}
if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) { if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) {
throw new ServiceException("区县与城市不匹配"); throw new ServiceException("区县与城市不匹配");
} }
portTerminal.setProvinceCode(province.getCode());
portTerminal.setProvinceName(province.getName());
portTerminal.setCity(city.getName()); portTerminal.setCity(city.getName());
portTerminal.setDistrictCode(district.getCode()); portTerminal.setDistrictCode(district.getCode());
portTerminal.setDistrictName(district.getName()); portTerminal.setDistrictName(district.getName());
@@ -575,7 +505,6 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
validateLength(portTerminal.getCode(), CODE_MAX_LENGTH, "编码不能超过30字"); validateLength(portTerminal.getCode(), CODE_MAX_LENGTH, "编码不能超过30字");
validateLength(portTerminal.getName(), NAME_MAX_LENGTH, "港口/码头名称不能超过100字"); validateLength(portTerminal.getName(), NAME_MAX_LENGTH, "港口/码头名称不能超过100字");
validateLength(portTerminal.getCountry(), REGION_MAX_LENGTH, "国家不能超过50字"); validateLength(portTerminal.getCountry(), REGION_MAX_LENGTH, "国家不能超过50字");
validateLength(portTerminal.getProvinceName(), REGION_MAX_LENGTH, "所属省份不能超过50字");
validateLength(portTerminal.getCity(), REGION_MAX_LENGTH, "城市不能超过50字"); validateLength(portTerminal.getCity(), REGION_MAX_LENGTH, "城市不能超过50字");
validateLength(portTerminal.getDistrictName(), REGION_MAX_LENGTH, "区县不能超过50字"); validateLength(portTerminal.getDistrictName(), REGION_MAX_LENGTH, "区县不能超过50字");
validateLength(portTerminal.getRegionCode(), REGION_CODE_MAX_LENGTH, "行政区划编码不能超过32字"); validateLength(portTerminal.getRegionCode(), REGION_CODE_MAX_LENGTH, "行政区划编码不能超过32字");
@@ -596,15 +525,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isEmpty(portTerminal.getCity())) { if (Func.isEmpty(portTerminal.getCity())) {
throw new ServiceException("城市不能为空"); throw new ServiceException("城市不能为空");
} }
if (Func.isEmpty(portTerminal.getProvinceCode())) {
throw new ServiceException("请选择所属省份");
}
if (Func.isEmpty(portTerminal.getDistrictCode())) { if (Func.isEmpty(portTerminal.getDistrictCode())) {
throw new ServiceException("区县不能为空"); throw new ServiceException("区县不能为空");
} }
if (Func.isEmpty(portTerminal.getDetailAddress())) {
throw new ServiceException("详细地址不能为空");
}
validateDataSource(portTerminal.getDataSource()); validateDataSource(portTerminal.getDataSource());
validateStatus(portTerminal.getStatus()); validateStatus(portTerminal.getStatus());
validateRange(portTerminal.getLongitude(), MIN_LONGITUDE, MAX_LONGITUDE, "经度范围为 -180 到 180"); validateRange(portTerminal.getLongitude(), MIN_LONGITUDE, MAX_LONGITUDE, "经度范围为 -180 到 180");
@@ -655,12 +578,21 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
return Func.isEmpty(value) || (value.compareTo(min) >= 0 && value.compareTo(max) <= 0); return Func.isEmpty(value) || (value.compareTo(min) >= 0 && value.compareTo(max) <= 0);
} }
/**
* 归并导入行编码:仅填港口编码时取港口编码,填了码头编码时拼接为"港口编码-码头编码"。
*/
private String resolveImportCode(PortTerminalExcel excel) {
String portCode = trimToEmpty(excel.getPortCode()).toUpperCase(Locale.ROOT);
String terminalCode = trimToEmpty(excel.getTerminalCode()).toUpperCase(Locale.ROOT);
if (Func.isEmpty(terminalCode)) {
return portCode;
}
return Func.isEmpty(portCode) ? terminalCode : portCode + "-" + terminalCode;
}
private String normalizeDataSource(String dataSource) { private String normalizeDataSource(String dataSource) {
String value = trimToEmpty(dataSource); String value = trimToEmpty(dataSource);
if (SOURCE_INITIAL_IMPORT.equals(value) || SOURCE_INITIAL_OLD.equals(value)) { return SOURCE_INITIAL_OLD.equals(value) ? SOURCE_INITIAL : value;
return SOURCE_INITIAL;
}
return SOURCE_MANUAL_OLD.equals(value) ? SOURCE_MANUAL : value;
} }
private void validateEnabledTerminal(Long parentId) { private void validateEnabledTerminal(Long parentId) {
@@ -686,9 +618,23 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
return trimValue.isEmpty() ? null : trimValue; return trimValue.isEmpty() ? null : trimValue;
} }
private String inheritParentValue(String parentValue, String currentValue) { /**
String normalizedParentValue = trimToNull(parentValue); * 上级港口找不到时抛出。
return normalizedParentValue == null ? currentValue : normalizedParentValue; * <p>
* 与普通业务异常区分开,是因为调用方需要判断:这个父港口到底是"用户漏填了",
* 还是"父港口那一行就在本文件里、只是它自己校验失败"。后者不该连带给码头行报错。
*
* @author Chill
*/
private static class ParentPortNotFoundException extends ServiceException {
@Serial
private static final long serialVersionUID = 1L;
ParentPortNotFoundException(String message) {
super(message);
}
} }
} }
@@ -39,6 +39,7 @@ import org.springblade.common.constant.DataStatusEnum;
import org.bouncycastle.util.encoders.Hex; import org.bouncycastle.util.encoders.Hex;
import org.springblade.common.constant.ParamConstant; import org.springblade.common.constant.ParamConstant;
import org.springblade.common.constant.TenantConstant; import org.springblade.common.constant.TenantConstant;
import org.springblade.common.utils.PasswordRuleUtil;
import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -106,9 +106,6 @@ import static org.springblade.core.tenant.TenantGuard.EntityType.USER;
@Slf4j @Slf4j
public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implements IUserService { public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implements IUserService {
private static final String GUEST_NAME = "guest"; 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_DICT_CODE = "iam_default";
private static final String IAM_DEFAULT_ROLE_NAME = "默认角色"; private static final String IAM_DEFAULT_ROLE_NAME = "默认角色";
private static final String IAM_DEFAULT_DEPT_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); List<User> userList = TenantGuard.verifyBatch(this, idList, USER);
Map<String, String> passwordMap = new LinkedHashMap<>(); Map<String, String> passwordMap = new LinkedHashMap<>();
for (User user : userList) { for (User user : userList) {
String password = randomPassword(); String password = PasswordRuleUtil.generate();
User updateUser = new User(); User updateUser = new User();
updateUser.setPassword(DigestUtil.encrypt(password)); updateUser.setPassword(DigestUtil.encrypt(password));
updateUser.setUpdateTime(DateUtil.now()); updateUser.setUpdateTime(DateUtil.now());
@@ -604,6 +601,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
if (!Objects.equals(password, password2)) { if (!Objects.equals(password, password2)) {
throw new ServiceException("两次输入密码不一致!"); throw new ServiceException("两次输入密码不一致!");
} }
validatePlainPassword(password);
User user = TenantGuard.verify(this, userId, USER); User user = TenantGuard.verify(this, userId, USER);
User updateUser = new User(); User updateUser = new User();
updateUser.setPassword(DigestUtil.encrypt(password)); updateUser.setPassword(DigestUtil.encrypt(password));
@@ -672,7 +670,8 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
if (user.getId() != null) { if (user.getId() != null) {
this.updateUser(user); this.updateUser(user);
} else { } else {
user.setPassword(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); // 空密码交由 saveUser 按规则填充(初始密码不合规时自动生成)
user.setPassword(null);
this.submit(user); this.submit(user);
} }
}); });
@@ -689,6 +688,9 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
*/ */
private User buildImportUser(UserExcel userExcel, String tenantId) { private User buildImportUser(UserExcel userExcel, String tenantId) {
User user = Objects.requireNonNull(BeanUtil.copyProperties(userExcel, User.class)); User user = Objects.requireNonNull(BeanUtil.copyProperties(userExcel, User.class));
// 宽容解析生日文本(2026-8-2 等写法),User.birthday 为 java.util.Date 需转换
java.time.LocalDate birthday = org.springblade.common.excel.LenientDateParser.parseDate(userExcel.getBirthday(), "生日");
user.setBirthday(birthday == null ? null : java.sql.Date.valueOf(birthday));
user.setTenantId(tenantId); user.setTenantId(tenantId);
user.setUserType(Func.toInt(DictCache.getKey(DictEnum.USER_TYPE, userExcel.getUserTypeName()), 1)); user.setUserType(Func.toInt(DictCache.getKey(DictEnum.USER_TYPE, userExcel.getUserTypeName()), 1));
user.setDeptId(Func.toStrWithEmpty(SysCache.getDeptIds(tenantId, userExcel.getDeptName()), StringPool.EMPTY)); user.setDeptId(Func.toStrWithEmpty(SysCache.getDeptIds(tenantId, userExcel.getDeptName()), StringPool.EMPTY));
@@ -1061,8 +1063,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
} }
} }
if (Func.isEmpty(user.getPassword())) { 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())); user.setPassword(DigestUtil.encrypt(user.getPassword()));
Long userCount = baseMapper.selectCount(Wrappers.<User>query().lambda().eq(User::getTenantId, tenantId).eq(User::getAccount, user.getAccount())); Long userCount = baseMapper.selectCount(Wrappers.<User>query().lambda().eq(User::getTenantId, tenantId).eq(User::getAccount, user.getAccount()));
if (userCount > 0L && Func.isEmpty(user.getId())) { if (userCount > 0L && Func.isEmpty(user.getId())) {
@@ -1144,12 +1148,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
// } // }
} }
private String randomPassword() { private void validatePlainPassword(String password) {
StringBuilder password = new StringBuilder(RANDOM_PASSWORD_LENGTH); if (!PasswordRuleUtil.isValid(password)) {
for (int index = 0; index < RANDOM_PASSWORD_LENGTH; index++) { throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
password.append(PASSWORD_CHARS.charAt(SECURE_RANDOM.nextInt(PASSWORD_CHARS.length())));
} }
return password.toString();
} }
} }
@@ -6,14 +6,20 @@ package org.springblade.transport.config;
import io.minio.MinioClient; import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/** /**
* 凭证图片 MinIO 客户端配置。 * 凭证图片 MinIO 客户端配置。
* <p>
* 连接参数由 Nacos 的 file.storage.minio 配置提供。 * 连接参数由 Nacos 的 file.storage.minio 配置提供。
* 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端,
* 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动;
* 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。
*/ */
@Configuration @Configuration
@ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint")
public class VoucherMinioConfig { public class VoucherMinioConfig {
@Bean @Bean
@@ -52,6 +52,7 @@ import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.ProcessConfigVO; import org.springblade.transport.pojo.vo.ProcessConfigVO;
import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IProcessConfigService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -83,20 +84,34 @@ public class ProcessConfigController extends BladeController {
private final VoucherFileMapper voucherFileMapper; private final VoucherFileMapper voucherFileMapper;
private final VoucherImageMapper voucherImageMapper; private final VoucherImageMapper voucherImageMapper;
private final VoucherManageMapper voucherManageMapper; private final VoucherManageMapper voucherManageMapper;
private final MinioClient minioClient; private final ObjectProvider<MinioClient> minioClientProvider;
@Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}")
private String minioBucketName; private String minioBucketName;
public ProcessConfigController(IProcessConfigService processConfigService, WaybillMapper waybillMapper, public ProcessConfigController(IProcessConfigService processConfigService, WaybillMapper waybillMapper,
VoucherFileMapper voucherFileMapper, VoucherImageMapper voucherImageMapper, VoucherFileMapper voucherFileMapper, VoucherImageMapper voucherImageMapper,
VoucherManageMapper voucherManageMapper, VoucherManageMapper voucherManageMapper,
MinioClient minioClient) { ObjectProvider<MinioClient> minioClientProvider) {
this.processConfigService = processConfigService; this.processConfigService = processConfigService;
this.waybillMapper = waybillMapper; this.waybillMapper = waybillMapper;
this.voucherFileMapper = voucherFileMapper; this.voucherFileMapper = voucherFileMapper;
this.voucherImageMapper = voucherImageMapper; this.voucherImageMapper = voucherImageMapper;
this.voucherManageMapper = voucherManageMapper; this.voucherManageMapper = voucherManageMapper;
this.minioClient = minioClient; this.minioClientProvider = minioClientProvider;
}
/**
* 获取 MinIO 客户端。
* <p>
* 未配置 file.storage.minio.endpoint 时该客户端不会被注册,
* 此时凭证预览地址无法生成,抛出明确提示而非启动即失败。
*/
private MinioClient minioClient() {
MinioClient minioClient = minioClientProvider.getIfAvailable();
if (minioClient == null) {
throw new IllegalStateException("Nacos 未配置 file.storage.minio.endpoint,凭证文件功能不可用");
}
return minioClient;
} }
@GetMapping("/detail") @GetMapping("/detail")
@@ -147,7 +162,7 @@ public class ProcessConfigController extends BladeController {
result.put("waybillNo", image.getWaybillNo()); result.put("waybillNo", image.getWaybillNo());
result.put("objectKey", image.getObjectKey()); result.put("objectKey", image.getObjectKey());
try { try {
result.put("url", minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() result.put("url", minioClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET).bucket(minioBucketName).object(image.getObjectKey()) .method(Method.GET).bucket(minioBucketName).object(image.getObjectKey())
.expiry(1, TimeUnit.HOURS).build())); .expiry(1, TimeUnit.HOURS).build()));
} catch (Exception e) { } catch (Exception e) {
@@ -270,7 +285,7 @@ public class ProcessConfigController extends BladeController {
result.put("objectKey", image.objectKey()); result.put("objectKey", image.objectKey());
result.put("matched", image.matched()); result.put("matched", image.matched());
try { try {
result.put("url", minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() result.put("url", minioClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET).bucket(minioBucketName).object(image.objectKey()) .method(Method.GET).bucket(minioBucketName).object(image.objectKey())
.expiry(1, TimeUnit.HOURS).build())); .expiry(1, TimeUnit.HOURS).build()));
} catch (Exception exception) { } catch (Exception exception) {
@@ -36,7 +36,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 事故记录 Excel * 事故记录 Excel
@@ -60,8 +59,8 @@ public class AccidentRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*事故发生日期") @ExcelProperty(value = "*事故发生日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate accidentDate; private String accidentDate;
@ExcelProperty("事故发生地点") @ExcelProperty("事故发生地点")
private String accidentLocation; private String accidentLocation;
@@ -37,7 +37,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 事故记录导出 Excel * 事故记录导出 Excel
@@ -83,14 +83,14 @@ public class AccidentRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 年检记录 Excel * 年检记录 Excel
@@ -59,11 +58,11 @@ public class AnnualInspectionRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*检测评定日期") @ExcelProperty(value = "*检测评定日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate inspectionAssessmentDate; private String inspectionAssessmentDate;
@ExcelProperty("*有效期截止日") @ExcelProperty(value = "*有效期截止日", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate validUntilDate; private String validUntilDate;
@ExcelProperty("*车辆技术等级") @ExcelProperty("*车辆技术等级")
private String vehicleTechnicalLevel; private String vehicleTechnicalLevel;
@@ -36,7 +36,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 年检记录导出 Excel * 年检记录导出 Excel
@@ -83,14 +83,14 @@ public class AnnualInspectionRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -24,6 +24,7 @@ package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore; import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -88,9 +89,11 @@ public class CommonAddressExportExcel implements Serializable {
private String remark; private String remark;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -24,6 +24,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.format.NumberFormat; import cn.idev.excel.annotation.format.NumberFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
@@ -93,9 +94,11 @@ public class CommonCargoExportExcel implements Serializable {
private String deptName; private String deptName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -75,8 +76,10 @@ public class CommonRouteExportExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.format.NumberFormat; import cn.idev.excel.annotation.format.NumberFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
@@ -33,7 +34,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 合同管理 Excel * 合同管理 Excel
@@ -111,8 +112,10 @@ public class ContractManageExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
private LocalDateTime updateTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime;
} }
@@ -7,12 +7,10 @@ package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore; import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDate;
/** /**
* 设备台账 Excel * 设备台账 Excel
@@ -29,7 +27,7 @@ public class EquipmentLedgerExcel implements Serializable {
@ExcelProperty("设备品牌") private String equipmentBrand; @ExcelProperty("设备品牌") private String equipmentBrand;
@ExcelProperty("设备类型") private String equipmentType; @ExcelProperty("设备类型") private String equipmentType;
@ExcelProperty("规格型号") private String specificationModel; @ExcelProperty("规格型号") private String specificationModel;
@ExcelProperty("出厂日期") @DateTimeFormat("yyyy-MM-dd") private LocalDate factoryDate; @ExcelProperty(value = "出厂日期", converter = org.springblade.common.excel.LenientDateStringConverter.class) private String factoryDate;
@ExcelProperty("备注") private String remark; @ExcelProperty("备注") private String remark;
@ExcelIgnore private String errorMessage; @ExcelIgnore private String errorMessage;
} }
@@ -11,6 +11,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date;
/** /**
* ETC记录导出 Excel * ETC记录导出 Excel
@@ -50,14 +51,14 @@ public class EtcRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 保险记录 Excel * 保险记录 Excel
@@ -68,11 +67,11 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("*保单号") @ExcelProperty("*保单号")
private String policyNo; private String policyNo;
@ExcelProperty("*开始日期") @ExcelProperty(value = "*开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate startDate; private String startDate;
@ExcelProperty("*结束日期") @ExcelProperty(value = "*结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate endDate; private String endDate;
@ExcelProperty("保额") @ExcelProperty("保额")
private BigDecimal insuredAmount; private BigDecimal insuredAmount;
@@ -83,8 +82,8 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("发票号") @ExcelProperty("发票号")
private String invoiceNo; private String invoiceNo;
@ExcelProperty("开票日期") @ExcelProperty(value = "开票日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate invoiceDate; private String invoiceDate;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -36,7 +36,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 保险记录导出 Excel * 保险记录导出 Excel
@@ -83,14 +83,14 @@ public class InsuranceRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -33,22 +33,20 @@ import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty; import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.idev.excel.util.DateUtils; import cn.idev.excel.util.DateUtils;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/** /**
* 保养记录日期转换器,兼容日期文本、日期时间文本和 Excel 数值日期。 * 保养记录日期转换器,兼容日期文本、日期时间文本和 Excel 数值日期。
* <p>
* 文本解析委托公共宽容解析器(支持 2026-8-2、2026/8/2 等写法,口径见根工作区
* docs/import-spec.md);无法识别的文本按既有行为抛出转换异常,
* 文案带原值,与「日期格式无法识别」口径一致。
* *
* @author Chill * @author Chill
*/ */
public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime> { public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime> {
private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_TIME_MINUTE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT);
@Override @Override
public Class<?> supportJavaTypeKey() { public Class<?> supportJavaTypeKey() {
@@ -71,16 +69,14 @@ public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
return null; return null;
} }
String normalizedValue = value.trim(); LocalDateTime parsed = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
try { if (parsed != null) {
return LocalDateTime.parse(normalizedValue, DATE_TIME_FORMATTER); return parsed;
} catch (DateTimeParseException ignored) {
try {
return LocalDateTime.parse(normalizedValue, DATE_TIME_MINUTE_FORMATTER);
} catch (DateTimeParseException ignoredMinute) {
return LocalDate.parse(normalizedValue, DATE_FORMATTER).atStartOfDay();
}
} }
// 无法识别的文本:与既有行为一致抛出转换异常(ExcelDataConvertException 是 RuntimeException
// 会带上本文案一路抛到 Controller,由全局异常处理返回给前端提示)。
throw new cn.idev.excel.exception.ExcelDataConvertException(-1, -1, cellData, contentProperty,
value.trim() + " 日期格式无法识别");
} }
@Override @Override
@@ -11,7 +11,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.io.Serial; import java.io.Serial;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 车辆保养记录导出 Excel * 车辆保养记录导出 Excel
@@ -26,12 +26,12 @@ public class MaintenancePlanExportExcel extends MaintenancePlanExcel {
@ExcelProperty(value = "创建时间", index = 12) @ExcelProperty(value = "创建时间", index = 12)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty(value = "更新人", index = 13) @ExcelProperty(value = "更新人", index = 13)
private String updateUserName; private String updateUserName;
@ExcelProperty(value = "更新时间", index = 14) @ExcelProperty(value = "更新时间", index = 14)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
} }
@@ -38,6 +38,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date;
/** /**
* 车辆维修记录 Excel * 车辆维修记录 Excel
@@ -95,16 +96,16 @@ public class MaintenanceRecordExcel implements Serializable {
@NumberFormat("0.00") @NumberFormat("0.00")
private BigDecimal mileage; private BigDecimal mileage;
@ExcelProperty(value = "创建时间", converter = MaintenancePlanDateTimeConverter.class) @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty(value = "更新时间", converter = MaintenancePlanDateTimeConverter.class) @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("里程单位") @ExcelProperty("里程单位")
private String mileageUnit; private String mileageUnit;
@@ -25,7 +25,6 @@
*/ */
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -42,7 +41,4 @@ public class MaintenanceRecordExportExcel extends MaintenanceRecordExcel {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@ExcelProperty(value = "导出失败原因", index = 17)
private String errorMessage;
} }
@@ -36,7 +36,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 里程记录导出 Excel * 里程记录导出 Excel
@@ -72,14 +72,14 @@ public class MileageRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -11,6 +11,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date;
/** /**
* 油电记录导出 Excel * 油电记录导出 Excel
@@ -61,14 +62,14 @@ public class OilElectricRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -16,7 +16,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 其他费用记录 Excel * 其他费用记录 Excel
@@ -40,8 +39,8 @@ public class OtherExpenseRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*费用日期") @ExcelProperty(value = "*费用日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate expenseDate; private String expenseDate;
@ExcelProperty("*费用类型") @ExcelProperty("*费用类型")
private String expenseType; private String expenseType;
@@ -11,7 +11,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 其他费用记录导出 Excel * 其他费用记录导出 Excel
@@ -43,14 +43,14 @@ public class OtherExpenseRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -69,8 +70,10 @@ public class ProcessConfigExportExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -33,6 +34,7 @@ import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date;
/** /**
* 项目立项 Excel * 项目立项 Excel
@@ -123,8 +125,10 @@ public class ProjectApplyExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
private LocalDateTime updateTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime;
} }
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -30,7 +31,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 发货模板 Excel * 发货模板 Excel
@@ -59,8 +60,10 @@ public class ShippingTemplateExcel implements Serializable {
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
private LocalDateTime updateTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
} }
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -32,7 +33,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 临时额度申请 Excel * 临时额度申请 Excel
@@ -81,8 +82,10 @@ public class TemporaryCreditLimitExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
private LocalDateTime updateTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime;
} }
@@ -38,6 +38,10 @@ import java.time.format.DateTimeFormatter;
/** /**
* 换胎日期文本转换器,保留非法日期原值并兼容 Excel 数值日期。 * 换胎日期文本转换器,保留非法日期原值并兼容 Excel 数值日期。
* <p>
* 数值日期序列号转 ISO 文本,文本原样透传;文本的宽容解析由服务层委托
* {@link org.springblade.common.excel.LenientDateParser} 完成(支持 2026-8-2 等写法,
* 口径见根工作区 docs/import-spec.md)。
* *
* @author Chill * @author Chill
*/ */
@@ -37,7 +37,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 换胎记录导出 Excel * 换胎记录导出 Excel
@@ -76,14 +76,14 @@ public class TireReplacementRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -28,7 +28,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 变更记录导出 Excel * 变更记录导出 Excel
@@ -57,14 +57,14 @@ public class TransportChangeRecordExportExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -23,6 +23,7 @@
package org.springblade.transport.excel; package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import cn.idev.excel.annotation.write.style.ColumnWidth; import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight; import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight; import cn.idev.excel.annotation.write.style.HeadRowHeight;
@@ -31,7 +32,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
/** /**
* 运输计划 Excel * 运输计划 Excel
@@ -92,8 +93,10 @@ public class TransportPlanExcel implements Serializable {
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
private LocalDateTime updateTime; @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date updateTime;
} }
@@ -37,6 +37,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date;
/** /**
* 违章记录 Excel * 违章记录 Excel
@@ -95,14 +96,14 @@ public class ViolationRecordExcel implements Serializable {
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty("更新时间") @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelIgnore @ExcelIgnore
private String errorMessage; private String errorMessage;
@@ -60,8 +60,11 @@ public class ViolationRecordImportExcel implements Serializable {
@ExcelProperty("*驾驶人") @ExcelProperty("*驾驶人")
private String driverName; private String driverName;
@ExcelProperty("*类型/事项") @ExcelProperty("*类型")
private String violationTypeOrItem; private String violationType;
@ExcelProperty("*事项")
private String violationItem;
@ExcelProperty(value = "*时间", converter = MaintenancePlanDateTimeConverter.class) @ExcelProperty(value = "*时间", converter = MaintenancePlanDateTimeConverter.class)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
@@ -108,10 +108,10 @@ public class WaybillExcel implements Serializable {
private String escortPhone; private String escortPhone;
@ExcelProperty("里程(km)") @ExcelProperty("里程(km)")
private BigDecimal mileage; private BigDecimal mileage;
@ExcelProperty("预计发货日期") @ExcelProperty(value = "预计发货日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate estimatedStartTime; private String estimatedStartTime;
@ExcelProperty("预计完成日期") @ExcelProperty(value = "预计完成日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate estimatedEndTime; private String estimatedEndTime;
@ExcelProperty("单价") @ExcelProperty("单价")
private BigDecimal unitPrice; private BigDecimal unitPrice;
@ExcelProperty("计价单位") @ExcelProperty("计价单位")
@@ -126,10 +126,10 @@ public class WaybillExcel implements Serializable {
private String businessStatus; private String businessStatus;
@ExcelProperty("数据来源") @ExcelProperty("数据来源")
private String dataSource; private String dataSource;
@ExcelProperty("开始日期") @ExcelProperty(value = "开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate startDate; private String startDate;
@ExcelProperty("结束日期") @ExcelProperty(value = "结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate endDate; private String endDate;
@ExcelProperty("计划名称") @ExcelProperty("计划名称")
private String planName; private String planName;
@ExcelProperty("多联总单") @ExcelProperty("多联总单")
@@ -22,9 +22,15 @@
*/ */
package org.springblade.transport.mapper; package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.entity.ProjectApply;
import java.util.List;
import java.util.Map;
/** /**
* 项目立项 Mapper 接口 * 项目立项 Mapper 接口
* *
@@ -32,4 +38,26 @@ import org.springblade.transport.pojo.entity.ProjectApply;
*/ */
public interface ProjectApplyMapper extends BaseMapper<ProjectApply> { public interface ProjectApplyMapper extends BaseMapper<ProjectApply> {
/**
* 按租户读取业务字典项供未登录公开页回显下拉文案
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT dict_key AS dictKey, dict_value AS dictValue
FROM blade_dict_biz
WHERE is_deleted = 0 AND parent_id <> 0 AND tenant_id = #{tenantId} AND code = #{code}
""")
List<Map<String, Object>> selectDictBizItems(@Param("tenantId") String tenantId, @Param("code") String code);
/**
* 读取系统字典项供未登录公开页回显下拉文案
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT dict_key AS dictKey, dict_value AS dictValue
FROM blade_dict
WHERE is_deleted = 0 AND parent_id <> 0 AND code = #{code}
""")
List<Map<String, Object>> selectDictItems(@Param("code") String code);
} }
@@ -73,7 +73,7 @@ public final class MkProcessBizHandlers {
@Override @Override
public Object publicDetail(Long id) { public Object publicDetail(Long id) {
return service.detail(id); return service.publicDetail(id);
} }
@Override @Override
@@ -143,7 +143,7 @@ public final class MkProcessBizHandlers {
@Override @Override
public Object publicDetail(Long id) { public Object publicDetail(Long id) {
return service.detail(id); return service.publicDetail(id);
} }
@Override @Override
@@ -211,7 +211,7 @@ public final class MkProcessBizHandlers {
@Override @Override
public Object publicDetail(Long id) { public Object publicDetail(Long id) {
return service.getById(id); return service.publicDetail(id);
} }
@Override @Override
@@ -40,6 +40,14 @@ public interface IContractManageService extends BaseService<ContractManage> {
IPage<ContractManageVO> selectContractManagePage(IPage<ContractManage> page, ContractManageVO contractManage); IPage<ContractManageVO> selectContractManagePage(IPage<ContractManage> page, ContractManageVO contractManage);
ContractManageVO detail(Long id); ContractManageVO detail(Long id);
/**
* 公开详情不校验当前用户所属组织
*
* @param id 主键
* @return 合同详情
*/
ContractManageVO publicDetail(Long id);
boolean saveDraft(ContractManage contractManage); boolean saveDraft(ContractManage contractManage);
boolean submit(ContractManage contractManage); boolean submit(ContractManage contractManage);
boolean toTemporary(Long id); boolean toTemporary(Long id);
@@ -40,6 +40,14 @@ public interface IProjectApplyService extends BaseService<ProjectApply> {
IPage<ProjectApplyVO> selectProjectApplyPage(IPage<ProjectApply> page, ProjectApplyVO projectApply); IPage<ProjectApplyVO> selectProjectApplyPage(IPage<ProjectApply> page, ProjectApplyVO projectApply);
ProjectApplyVO detail(Long id); ProjectApplyVO detail(Long id);
/**
* 公开详情不校验当前用户所属组织
*
* @param id 主键
* @return 项目立项详情
*/
ProjectApplyVO publicDetail(Long id);
Map<String, Integer> fundRiskStats(ProjectApplyVO projectApply); Map<String, Integer> fundRiskStats(ProjectApplyVO projectApply);
Map<String, Object> changeRecordDetail(Long id, Integer recordIndex); Map<String, Object> changeRecordDetail(Long id, Integer recordIndex);
boolean saveDraft(ProjectApply projectApply); boolean saveDraft(ProjectApply projectApply);
@@ -37,6 +37,7 @@ import org.springblade.transport.pojo.vo.ReceivablePayableCargoFeeVO;
import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO;
import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Collection; import java.util.Collection;
@@ -82,6 +83,12 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */ /** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */
void generateForCompletedWaybills(List<Long> waybillIds); void generateForCompletedWaybills(List<Long> waybillIds);
/**
* 运单维护里程后同步未挂结算单的应收应付明细里程
* 对按里程按吨·公里计费的费用行按新里程重新计算
*/
void syncMileageFromWaybill(Long waybillId, BigDecimal mileage);
/** /**
* 批量导入完成运单后按合同系统计费模式生成应收应付明细 * 批量导入完成运单后按合同系统计费模式生成应收应付明细
* <p>与导入事务共用同一事务运单尚未提交因此直接传入实体而非主键</p> * <p>与导入事务共用同一事务运单尚未提交因此直接传入实体而非主键</p>
@@ -46,6 +46,14 @@ public interface IWaybillService extends BaseService<Waybill> {
IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill); IPage<WaybillVO> selectWaybillPage(IPage<Waybill> page, WaybillVO waybill);
WaybillVO detail(Long id); WaybillVO detail(Long id);
/**
* 公开详情不校验当前用户所属组织
*
* @param id 主键
* @return 运单详情
*/
WaybillVO publicDetail(Long id);
/** /**
* 管理端运单打卡记录 + 司机上传凭证图label=节点-凭证类型 * 管理端运单打卡记录 + 司机上传凭证图label=节点-凭证类型
*/ */
@@ -94,6 +94,7 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl<AccidentRecordMap
AccidentRecordExcel excel = data.get(index); AccidentRecordExcel excel = data.get(index);
try { try {
AccidentRecord accidentRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AccidentRecord.class)); AccidentRecord accidentRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AccidentRecord.class));
accidentRecord.setAccidentDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getAccidentDate(), "事故发生日期"));
prepare(accidentRecord); prepare(accidentRecord);
List<String> validationErrors = validateImportAccidentRecord(accidentRecord); List<String> validationErrors = validateImportAccidentRecord(accidentRecord);
if (Func.isNotEmpty(validationErrors)) { if (Func.isNotEmpty(validationErrors)) {
@@ -113,6 +113,8 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl<AnnualIns
AnnualInspectionRecordExcel excel = data.get(index); AnnualInspectionRecordExcel excel = data.get(index);
try { try {
AnnualInspectionRecord annualInspectionRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AnnualInspectionRecord.class)); AnnualInspectionRecord annualInspectionRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AnnualInspectionRecord.class));
annualInspectionRecord.setInspectionAssessmentDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getInspectionAssessmentDate(), "检测评定日期"));
annualInspectionRecord.setValidUntilDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getValidUntilDate(), "有效期截止日"));
prepare(annualInspectionRecord); prepare(annualInspectionRecord);
List<String> validationErrors = validateImportAnnualInspectionRecord(annualInspectionRecord); List<String> validationErrors = validateImportAnnualInspectionRecord(annualInspectionRecord);
if (Func.isNotEmpty(validationErrors)) { if (Func.isNotEmpty(validationErrors)) {
@@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
@@ -97,6 +98,15 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
return contractManageVO; return contractManageVO;
} }
@Override
@TenantIgnore
public ContractManageVO publicDetail(Long id) {
ContractManageVO contractManageVO = ContractManageWrapper.build().entityVO(loadExists(id));
normalizeOptionalIntegerFields(contractManageVO);
contractManageVO.setReadonly(true);
return contractManageVO;
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean saveDraft(ContractManage contractManage) { public boolean saveDraft(ContractManage contractManage) {
@@ -647,6 +657,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
if (contractManage.getInvoiceCycle() != null && contractManage.getInvoiceCycle() <= 0) { if (contractManage.getInvoiceCycle() != null && contractManage.getInvoiceCycle() <= 0) {
contractManage.setInvoiceCycle(null); contractManage.setInvoiceCycle(null);
} }
BigDecimal contractAmount = contractManage.getContractAmount();
if (contractAmount != null && contractAmount.compareTo(BigDecimal.valueOf(-1)) == 0) {
contractManage.setContractAmount(null);
}
} }
private void validateDraft(ContractManage contractManage) { private void validateDraft(ContractManage contractManage) {
@@ -621,6 +621,23 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
if (Func.isEmpty(accountVO.getAccountName()) && Func.isEmpty(accountVO.getBankAccount())) { if (Func.isEmpty(accountVO.getAccountName()) && Func.isEmpty(accountVO.getBankAccount())) {
continue; 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)); CustomerReceiptAccount account = Objects.requireNonNull(BeanUtil.copyProperties(accountVO, CustomerReceiptAccount.class));
account.setId(IdWorker.getId()); account.setId(IdWorker.getId());
account.setCustomerId(customerId); account.setCustomerId(customerId);
@@ -91,6 +91,7 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl<EquipmentLedgerM
EquipmentLedgerExcel excel = data.get(index); EquipmentLedgerExcel excel = data.get(index);
try { try {
EquipmentLedger equipmentLedger = Objects.requireNonNull(BeanUtil.copyProperties(excel, EquipmentLedger.class)); EquipmentLedger equipmentLedger = Objects.requireNonNull(BeanUtil.copyProperties(excel, EquipmentLedger.class));
equipmentLedger.setFactoryDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getFactoryDate(), "出厂日期"));
prepare(equipmentLedger); prepare(equipmentLedger);
if (equipmentLedger.getId() == null && Func.isEmpty(equipmentLedger.getEquipmentCode())) { if (equipmentLedger.getId() == null && Func.isEmpty(equipmentLedger.getEquipmentCode())) {
equipmentLedger.setEquipmentCode(nextEquipmentCode(importEquipmentCodes)); equipmentLedger.setEquipmentCode(nextEquipmentCode(importEquipmentCodes));
@@ -119,6 +119,9 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
InsuranceRecordExcel excel = data.get(index); InsuranceRecordExcel excel = data.get(index);
try { try {
InsuranceRecord insuranceRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, InsuranceRecord.class)); InsuranceRecord insuranceRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, InsuranceRecord.class));
insuranceRecord.setStartDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getStartDate(), "开始日期"));
insuranceRecord.setEndDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEndDate(), "结束日期"));
insuranceRecord.setInvoiceDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getInvoiceDate(), "开票日期"));
prepare(insuranceRecord); prepare(insuranceRecord);
List<String> validationErrors = validateImportInsuranceRecord(insuranceRecord); List<String> validationErrors = validateImportInsuranceRecord(insuranceRecord);
if (Func.isNotEmpty(insuranceRecord.getVehicleType()) && Func.isNotEmpty(insuranceRecord.getInsuranceType()) && Func.isNotEmpty(insuranceRecord.getPolicyNo())) { if (Func.isNotEmpty(insuranceRecord.getVehicleType()) && Func.isNotEmpty(insuranceRecord.getInsuranceType()) && Func.isNotEmpty(insuranceRecord.getPolicyNo())) {
@@ -174,6 +174,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
throw new ServiceException("配载标识号已存在:" + normalizedLoadingNo); throw new ServiceException("配载标识号已存在:" + normalizedLoadingNo);
} }
Waybill first = waybills.get(0); Waybill first = waybills.get(0);
Waybill last = waybills.get(waybills.size() - 1);
LoadingManage loadingManage = new LoadingManage(); LoadingManage loadingManage = new LoadingManage();
loadingManage.setLoadingNo(normalizedLoadingNo); loadingManage.setLoadingNo(normalizedLoadingNo);
loadingManage.setLoadingSubNos(waybills.stream() loadingManage.setLoadingSubNos(waybills.stream()
@@ -197,7 +198,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCarrierName(first.getCarrierName()); loadingManage.setCarrierName(first.getCarrierName());
loadingManage.setCarrierContractId(first.getCarrierContractId()); loadingManage.setCarrierContractId(first.getCarrierContractId());
loadingManage.setDepartureAddress(first.getDepartureAddress()); loadingManage.setDepartureAddress(first.getDepartureAddress());
loadingManage.setArrivalAddress(first.getArrivalAddress()); loadingManage.setTransitAddress(buildImportedRouteTransitAddress(waybills));
loadingManage.setArrivalAddress(last.getArrivalAddress());
loadingManage.setOriginalNo(first.getOriginalNo()); loadingManage.setOriginalNo(first.getOriginalNo());
loadingManage.setDataSource("批量导入"); loadingManage.setDataSource("批量导入");
loadingManage.setStartDate(first.getStartDate()); loadingManage.setStartDate(first.getStartDate());
@@ -222,6 +224,39 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getLoadingNo, normalizedLoadingNo)); .set(Waybill::getLoadingNo, normalizedLoadingNo));
} }
/**
* 根据导入运单构建配载单的途经地
* 组内运单按导入顺序串联路线上一票的到货地址与下一票的发货地址相同中途卸货点时只保留一个点
* 最终形成首票发货地 途经点 末票到货地
*/
private String buildImportedRouteTransitAddress(List<Waybill> waybills) {
// 按顺序收集全部节点首票发货地每票到货地节点与相邻前一点相同则重合跳过
List<String> nodes = new ArrayList<>();
for (Waybill waybill : waybills) {
String departure = TransportBusinessSupport.trimToNull(waybill.getDepartureAddress());
String arrival = TransportBusinessSupport.trimToNull(waybill.getArrivalAddress());
appendRouteNode(nodes, departure);
appendRouteNode(nodes, arrival);
}
// 途经点 = 去掉首尾首票发货地末票到货地后的中间节点
List<String> transitNodes = nodes.size() > 2 ? nodes.subList(1, nodes.size() - 1) : List.of();
if (Func.isEmpty(transitNodes)) {
return null;
}
return String.join(" - ", transitNodes);
}
private void appendRouteNode(List<String> nodes, String address) {
if (Func.isEmpty(address)) {
return;
}
if (!nodes.isEmpty() && nodes.get(nodes.size() - 1).equals(address)) {
// 与上一节点相同视为同一地点重合不重复
return;
}
nodes.add(address);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public BusinessRemoveResultVO removeLoadingManage(String ids) { public BusinessRemoveResultVO removeLoadingManage(String ids) {
@@ -75,6 +75,7 @@ public class OtherExpenseRecordServiceImpl extends BaseServiceImpl<OtherExpenseR
OtherExpenseRecordExcel excel = data.get(index); OtherExpenseRecordExcel excel = data.get(index);
try { try {
OtherExpenseRecord otherExpenseRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, OtherExpenseRecord.class)); OtherExpenseRecord otherExpenseRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, OtherExpenseRecord.class));
otherExpenseRecord.setExpenseDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getExpenseDate(), "费用日期"));
otherExpenseRecord.setDataSource("批量导入"); otherExpenseRecord.setDataSource("批量导入");
prepare(otherExpenseRecord); prepare(otherExpenseRecord);
List<String> validationErrors = validateImportOtherExpenseRecord(otherExpenseRecord); List<String> validationErrors = validateImportOtherExpenseRecord(otherExpenseRecord);
@@ -598,7 +598,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
if (Func.isEmpty(request.getRows())) { if (Func.isEmpty(request.getRows())) {
throw new ServiceException("请填写需要调整的费用行"); throw new ServiceException("请填写需要调整的费用行");
} }
String changeReason = requiredText(limitRemark(request.getChangeReason(), 200), "调整原因"); String changeReason = limitRemark(request.getChangeReason(), 200);
Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream() Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream()
.collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity())); .collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity()));
if (request.getRows().size() != existingMap.size()) { if (request.getRows().size() != existingMap.size()) {
@@ -26,9 +26,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
@@ -70,6 +72,7 @@ import java.util.stream.Collectors;
* @author Chill * @author Chill
*/ */
@Service @Service
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper, ProjectApply> implements IProjectApplyService { public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper, ProjectApply> implements IProjectApplyService {
@@ -109,6 +112,16 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
return projectApplyVO; return projectApplyVO;
} }
@Override
@TenantIgnore
public ProjectApplyVO publicDetail(Long id) {
ProjectApplyVO projectApplyVO = ProjectApplyWrapper.build().entityVO(loadExists(id));
normalizeOptionalFields(projectApplyVO);
projectApplyVO.setReadonly(true);
fillPublicDictOptions(projectApplyVO);
return projectApplyVO;
}
@Override @Override
public Map<String, Integer> fundRiskStats(ProjectApplyVO projectApply) { public Map<String, Integer> fundRiskStats(ProjectApplyVO projectApply) {
projectApply.setFundUseRisk(null); projectApply.setFundUseRisk(null);
@@ -878,6 +891,62 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
projectApplyVO.setReadonly(!canCurrentUserOperate(projectApplyVO)); projectApplyVO.setReadonly(!canCurrentUserOperate(projectApplyVO));
} }
/**
* 公开页没有登录态下拉字典接口无法访问这里按项目租户把文案一并返回
*/
private void fillPublicDictOptions(ProjectApplyVO projectApplyVO) {
try {
projectApplyVO.setCargoTypeOptions(loadSystemDictOptions("type_of_goods"));
projectApplyVO.setTransportTypeOptions(loadBizDictOptions(projectApplyVO.getTenantId(), "transport_type"));
projectApplyVO.setSettlementModeOptions(loadBizDictOptions(projectApplyVO.getTenantId(), "settle_method"));
} catch (Exception exception) {
log.warn("公开项目详情字典加载失败, id={}", projectApplyVO.getId(), exception);
}
}
private List<Map<String, String>> loadSystemDictOptions(String code) {
return toDictOptions(baseMapper.selectDictItems(code));
}
private List<Map<String, String>> loadBizDictOptions(String tenantId, String code) {
if (Func.isEmpty(tenantId)) {
return new ArrayList<>();
}
return toDictOptions(baseMapper.selectDictBizItems(tenantId, code));
}
private List<Map<String, String>> toDictOptions(List<Map<String, Object>> rows) {
List<Map<String, String>> options = new ArrayList<>();
if (rows == null) {
return options;
}
for (Map<String, Object> row : rows) {
String dictKey = dictColumn(row, "dictKey", "dictkey", "dict_key");
if (Func.isEmpty(dictKey) || "-1".equals(dictKey)) {
continue;
}
String dictValue = dictColumn(row, "dictValue", "dictvalue", "dict_value");
Map<String, String> option = new LinkedHashMap<>();
option.put("value", dictKey);
option.put("label", Func.isEmpty(dictValue) ? dictKey : dictValue);
options.add(option);
}
return options;
}
private String dictColumn(Map<String, Object> row, String... names) {
if (row == null) {
return "";
}
for (String name : names) {
Object value = row.get(name);
if (value != null && Func.isNotEmpty(String.valueOf(value))) {
return String.valueOf(value);
}
}
return "";
}
private boolean canCurrentUserOperate(ProjectApply projectApply) { private boolean canCurrentUserOperate(ProjectApply projectApply) {
if (AuthUtil.isAdministrator()) { if (AuthUtil.isAdministrator()) {
return true; return true;
@@ -198,6 +198,209 @@ public class ReceivablePayableDetailServiceImpl
return result; 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 @Override
public ReceivablePayableFeeDetailVO feeDetail(Long id) { public ReceivablePayableFeeDetailVO feeDetail(Long id) {
ReceivablePayableDetail detail = getExisting(id); ReceivablePayableDetail detail = getExisting(id);
@@ -890,11 +1093,68 @@ public class ReceivablePayableDetailServiceImpl
Map<String, Object> plan = resolveDefaultBillingPlan(plans, waybill.getTransportType()); Map<String, Object> plan = resolveDefaultBillingPlan(plans, waybill.getTransportType());
if (plan == null) return null; if (plan == null) return null;
if (!(plan.get("rules") instanceof List<?> rules)) 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; if (!matched) return null;
return "__matched__"; 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) { private boolean matchesRule(Map<?, ?> raw, Waybill waybill) {
Object conditionValue = raw.get("matchCondition"); Object conditionValue = raw.get("matchCondition");
if (!(conditionValue instanceof Map<?, ?> condition) || !hasConfiguredMatchCondition(condition)) return true; if (!(conditionValue instanceof Map<?, ?> condition) || !hasConfiguredMatchCondition(condition)) return true;
@@ -1387,10 +1647,10 @@ public class ReceivablePayableDetailServiceImpl
Set<List<String>> freightBillingCargoKeys = new LinkedHashSet<>(); Set<List<String>> freightBillingCargoKeys = new LinkedHashSet<>();
for (Object value : (List<?>) plan.get("rules")) { for (Object value : (List<?>) plan.get("rules")) {
if (!(value instanceof Map<?, ?> raw)) continue; if (!(value instanceof Map<?, ?> raw)) continue;
Map<String, Object> rule = new LinkedHashMap<>(); Map<String, Object> rule = toRuleMap(raw);
raw.forEach((key, item) -> rule.put(String.valueOf(key), item));
for (Waybill feeWaybill : feeWaybills(rule, waybill)) { for (Waybill feeWaybill : feeWaybills(rule, waybill)) {
if (matchOnly && !matchesRule(raw, feeWaybill)) continue; // 统一生成匹配规则内货物 + 数量单位一致按里程/按吨·公里跳过单位匹配
if (!matchesBillingRule(raw, rule, feeWaybill)) continue;
BigDecimal amount = calculateRule(rule, feeWaybill); BigDecimal amount = calculateRule(rule, feeWaybill);
if (amount == null) continue; if (amount == null) continue;
String feeItem = stringValue(rule, "feeItem", "费用"); String feeItem = stringValue(rule, "feeItem", "费用");
@@ -1446,13 +1706,25 @@ public class ReceivablePayableDetailServiceImpl
private void fillCalculatedBillingFields(ReceivablePayableCargoFee fee, Waybill feeWaybill, private void fillCalculatedBillingFields(ReceivablePayableCargoFee fee, Waybill feeWaybill,
Map<String, Object> rule) { Map<String, Object> rule) {
fee.setBillingFactor(stringValue(rule, "billingElement", "")); String element = stringValue(rule, "billingElement", "");
fee.setBillingFactor(element);
fee.setBillingType(stringValue(rule, "billingType", "")); fee.setBillingType(stringValue(rule, "billingType", ""));
fee.setTransportQuantity(measure(rule, feeWaybill)); fee.setTransportQuantity(resolveStoredTransportQuantity(rule, feeWaybill));
fee.setPriceUnit(stringValue(rule, "billingUnit", feeWaybill.getPriceUnit())); fee.setPriceUnit(stringValue(rule, "billingUnit", feeWaybill.getPriceUnit()));
fee.setUnitPrice(resolveCalculatedUnitPrice(rule, feeWaybill)); 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) { return switch (element) {
case "按体积" -> goods.stream().map(this::goodsVolume).reduce(BigDecimal.ZERO, BigDecimal::add); case "按体积" -> goods.stream().map(this::goodsVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
case "按车辆", "固定金额(整单一口价)" -> BigDecimal.ONE; case "按车辆", "固定金额(整单一口价)" -> BigDecimal.ONE;
// 按里程计费量=运单里程固定单价/区间单价/阶梯等均基于该计费量
case "按里程" -> money(waybill.getMileage()); case "按里程" -> money(waybill.getMileage());
// 按吨·公里计费量=货物重量数量 × 运单里程
case "按吨·公里" -> goodsQuantity(goods).multiply(money(waybill.getMileage())); case "按吨·公里" -> goodsQuantity(goods).multiply(money(waybill.getMileage()));
case "按数量" -> goodsQuantity(goods); case "按数量" -> goodsQuantity(goods);
default -> goodsQuantity(goods); default -> goodsQuantity(goods);
@@ -43,8 +43,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -64,7 +62,6 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl<TireReplac
private static final int REMARK_MAX_LENGTH = 500; private static final int REMARK_MAX_LENGTH = 500;
private static final int ATTACHMENTS_MAX_LENGTH = 1000; private static final int ATTACHMENTS_MAX_LENGTH = 1000;
private static final int MONEY_SCALE = 2; private static final int MONEY_SCALE = 2;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
@Override @Override
public IPage<TireReplacementRecordVO> selectTireReplacementRecordPage(IPage<TireReplacementRecordVO> page, TireReplacementRecordVO tireReplacementRecord) { public IPage<TireReplacementRecordVO> selectTireReplacementRecordPage(IPage<TireReplacementRecordVO> page, TireReplacementRecordVO tireReplacementRecord) {
@@ -144,9 +141,9 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl<TireReplac
return null; return null;
} }
try { try {
return LocalDate.parse(normalizedValue, DATE_FORMATTER); return org.springblade.common.excel.LenientDateParser.parseDate(normalizedValue, "换胎时间");
} catch (DateTimeParseException exception) { } catch (Exception exception) {
validationErrors.add("换胎时间格式不正确,请使用yyyy-MM-dd格式并填写有效日期"); validationErrors.add(exception.getMessage());
return null; return null;
} }
} }
@@ -416,9 +416,9 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
// 2. 校验运输类型枚举值 // 2. 校验运输类型枚举值
String transportType = trimToEmpty(excel.getTransportType()); String transportType = trimToEmpty(excel.getTransportType());
if (Func.isNotEmpty(transportType)) { if (Func.isNotEmpty(transportType)) {
List<String> validTransportTypes = List.of("公路整车", "公路配载/零担", "铁路运输", "水路运输", "跨境海运", "航空运输"); List<String> validTransportTypes = List.of("公路运输", "铁路运输", "水路运输", "航空运输");
if (!validTransportTypes.contains(transportType)) { if (!validTransportTypes.contains(transportType)) {
errors.add("运输类型需系统枚举值(公路整车、公路配载/零担、铁路运输、水路运输、跨境海运、航空运输)"); errors.add("运输类型需系统枚举值(公路运输、铁路运输、水路运输、航空运输)");
} }
} }
@@ -510,11 +510,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
if (Func.isEmpty(value)) { if (Func.isEmpty(value)) {
return null; return null;
} }
try { // 宽容解析接受 2026-8-22026/8/2 等写法口径见根工作区 docs/import-spec.md
return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(value);
} catch (Exception exception) { if (date == null) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD"); throw new ServiceException(fieldName + " 日期格式无法识别:" + value.trim());
} }
return date;
} }
private void validateImportLength(String value, int maxLength, String fieldName) { private void validateImportLength(String value, int maxLength, String fieldName) {
@@ -60,7 +60,6 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -1346,14 +1345,12 @@ public class TransportReconciliationServiceImpl
private LocalDateTime parseTimeNullable(String value, String field) { private LocalDateTime parseTimeNullable(String value, String field) {
if (Func.isEmpty(value)) return null; if (Func.isEmpty(value)) return null;
for (String pattern : List.of("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-M-d HH:mm:ss", "yyyy-M-d HH:mm", // 宽容解析统一委托公共解析器接受 2026-8-2 12:3 等写法口径见根工作区 docs/import-spec.md
"yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/M/d HH:mm:ss", "yyyy/M/d HH:mm")) { LocalDateTime result = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
try { return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern(pattern)); } catch (DateTimeParseException ignored) { } if (result == null) {
throw new ServiceException(field + " 日期格式无法识别:" + value.trim());
} }
for (String pattern : List.of("yyyy-MM-dd", "yyyy-M-d", "yyyy/MM/dd", "yyyy/M/d")) { return result;
try { return LocalDate.parse(value.trim(), DateTimeFormatter.ofPattern(pattern)).atStartOfDay(); } catch (DateTimeParseException ignored) { }
}
throw new ServiceException(field + "格式应为yyyy-MM-dd HH:mm:ss或yyyy-MM-dd");
} }
private String matchKey(TransportReconciliationInternal row) { private String matchKey(TransportReconciliationInternal row) {
@@ -67,6 +67,8 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
private static final int MAX_DEDUCT_POINTS = 15; private static final int MAX_DEDUCT_POINTS = 15;
private static final String PROCESSED = "已处理"; private static final String PROCESSED = "已处理";
private static final String UNPROCESSED = "未处理"; private static final String UNPROCESSED = "未处理";
private static final String CAR = "车辆";
private static final String SHIP = "船舶";
@Override @Override
public IPage<ViolationRecordVO> selectViolationRecordPage(IPage<ViolationRecordVO> page, ViolationRecordVO violationRecord) { public IPage<ViolationRecordVO> selectViolationRecordPage(IPage<ViolationRecordVO> page, ViolationRecordVO violationRecord) {
@@ -81,6 +83,7 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
prepare(violationRecord); prepare(violationRecord);
validate(violationRecord); validate(violationRecord);
validateVehicleTypeImmutable(violationRecord); validateVehicleTypeImmutable(violationRecord);
clearIrrelevantField(violationRecord);
return saveOrUpdate(violationRecord); return saveOrUpdate(violationRecord);
} }
@@ -96,11 +99,6 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
ViolationRecordImportExcel excel = data.get(index); ViolationRecordImportExcel excel = data.get(index);
try { try {
ViolationRecord violationRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, ViolationRecord.class)); ViolationRecord violationRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, ViolationRecord.class));
if ("船舶".equals(trimToEmpty(excel.getVehicleType()))) {
violationRecord.setViolationItem(excel.getViolationTypeOrItem());
} else {
violationRecord.setViolationType(excel.getViolationTypeOrItem());
}
prepare(violationRecord); prepare(violationRecord);
List<String> validationErrors = validateImportViolationRecord(violationRecord); List<String> validationErrors = validateImportViolationRecord(violationRecord);
if (Func.isNotEmpty(validationErrors)) { if (Func.isNotEmpty(validationErrors)) {
@@ -109,6 +107,7 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
continue; continue;
} }
validateVehicleTypeImmutable(violationRecord); validateVehicleTypeImmutable(violationRecord);
clearIrrelevantField(violationRecord);
violationRecordList.add(violationRecord); violationRecordList.add(violationRecord);
} catch (Exception exception) { } catch (Exception exception) {
String message = exception instanceof ServiceException ? exception.getMessage() : "导入失败"; String message = exception instanceof ServiceException ? exception.getMessage() : "导入失败";
@@ -136,6 +135,8 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getDriverName()), "驾驶人/船长不能为空"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getDriverName()), "驾驶人/船长不能为空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, "车辆".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationType()), "类型不能为空"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, "车辆".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationType()), "类型不能为空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, "船舶".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationItem()), "事项不能为空"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, "船舶".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationItem()), "事项不能为空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, SHIP.equals(violationRecord.getVehicleType()) && Func.isNotEmpty(violationRecord.getViolationType()), "船舶不适用于类型,该列应留空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, CAR.equals(violationRecord.getVehicleType()) && Func.isNotEmpty(violationRecord.getViolationItem()), "车辆不适用于事项,该列应留空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getViolationTime()), "时间不能为空"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getViolationTime()), "时间不能为空");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isNotEmpty(violationRecord.getViolationTime()) && violationRecord.getViolationTime().isAfter(LocalDateTime.now()), "时间不能超过当前时间"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isNotEmpty(violationRecord.getViolationTime()) && violationRecord.getViolationTime().isAfter(LocalDateTime.now()), "时间不能超过当前时间");
org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getLocation()), "地址不能为空"); org.springblade.common.excel.ImportFailureExcelUtil.addValidationError(validationErrors, Func.isEmpty(violationRecord.getLocation()), "地址不能为空");
@@ -183,21 +184,30 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
violationRecord.setProcessDescription(trimToEmpty(violationRecord.getProcessDescription())); violationRecord.setProcessDescription(trimToEmpty(violationRecord.getProcessDescription()));
violationRecord.setProcessResult(trimToNull(violationRecord.getProcessResult())); violationRecord.setProcessResult(trimToNull(violationRecord.getProcessResult()));
violationRecord.setAttachments(trimToNull(violationRecord.getAttachments())); violationRecord.setAttachments(trimToNull(violationRecord.getAttachments()));
if ("车辆".equals(violationRecord.getVehicleType())) { if (UNPROCESSED.equals(violationRecord.getProcessStatus())) {
violationRecord.setProcessResult(null);
}
}
/**
* 清空与车船类型不匹配的对侧字段
* <p>
* 必须在 validate 之后执行校验需要看到用户填了什么
* 若提前清空误填的内容会被静默丢弃用户无从察觉
*/
private void clearIrrelevantField(ViolationRecord violationRecord) {
if (CAR.equals(violationRecord.getVehicleType())) {
violationRecord.setViolationItem(null); violationRecord.setViolationItem(null);
} else { } else {
violationRecord.setViolationType(null); violationRecord.setViolationType(null);
} }
if (UNPROCESSED.equals(violationRecord.getProcessStatus())) {
violationRecord.setProcessResult(null);
}
} }
private void validate(ViolationRecord violationRecord) { private void validate(ViolationRecord violationRecord) {
if (Func.isEmpty(violationRecord.getVehicleType())) { if (Func.isEmpty(violationRecord.getVehicleType())) {
throw new ServiceException("车船类型不能为空"); throw new ServiceException("车船类型不能为空");
} }
if (!"车辆".equals(violationRecord.getVehicleType()) && !"船舶".equals(violationRecord.getVehicleType())) { if (!CAR.equals(violationRecord.getVehicleType()) && !SHIP.equals(violationRecord.getVehicleType())) {
throw new ServiceException("车船类型不正确"); throw new ServiceException("车船类型不正确");
} }
if (Func.isEmpty(violationRecord.getVehicleNo())) { if (Func.isEmpty(violationRecord.getVehicleNo())) {
@@ -206,12 +216,18 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
if (Func.isEmpty(violationRecord.getDriverName())) { if (Func.isEmpty(violationRecord.getDriverName())) {
throw new ServiceException("驾驶人/船长不能为空"); throw new ServiceException("驾驶人/船长不能为空");
} }
if ("车辆".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationType())) { if (CAR.equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationType())) {
throw new ServiceException("类型不能为空"); throw new ServiceException("类型不能为空");
} }
if ("船舶".equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationItem())) { if (SHIP.equals(violationRecord.getVehicleType()) && Func.isEmpty(violationRecord.getViolationItem())) {
throw new ServiceException("事项不能为空"); throw new ServiceException("事项不能为空");
} }
if (SHIP.equals(violationRecord.getVehicleType()) && Func.isNotEmpty(violationRecord.getViolationType())) {
throw new ServiceException("船舶不适用于类型,该列应留空");
}
if (CAR.equals(violationRecord.getVehicleType()) && Func.isNotEmpty(violationRecord.getViolationItem())) {
throw new ServiceException("车辆不适用于事项,该列应留空");
}
if (Func.isEmpty(violationRecord.getViolationTime())) { if (Func.isEmpty(violationRecord.getViolationTime())) {
throw new ServiceException("时间不能为空"); throw new ServiceException("时间不能为空");
} }
@@ -290,7 +306,7 @@ public class ViolationRecordServiceImpl extends BaseServiceImpl<ViolationRecordM
private String normalizeVehicleType(String vehicleType) { private String normalizeVehicleType(String vehicleType) {
String value = trimToEmpty(vehicleType); String value = trimToEmpty(vehicleType);
return value.isEmpty() ? "车辆" : value; return value.isEmpty() ? CAR : value;
} }
private String normalizeProcessStatus(String processStatus) { private String normalizeProcessStatus(String processStatus) {
@@ -42,6 +42,7 @@ import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.http.Method; import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -79,14 +80,14 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
private final VoucherImageMapper voucherImageMapper; private final VoucherImageMapper voucherImageMapper;
private final VoucherFileMapper voucherFileMapper; private final VoucherFileMapper voucherFileMapper;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final MinioClient minioClient; private final ObjectProvider<MinioClient> minioClientProvider;
private final String minioBucketName; private final String minioBucketName;
private final String minioRootDirectory; private final String minioRootDirectory;
public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService, public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService,
IWaybillService waybillService, IWaybillService waybillService,
VoucherImageMapper voucherImageMapper, VoucherFileMapper voucherFileMapper, VoucherImageMapper voucherImageMapper, VoucherFileMapper voucherFileMapper,
ApplicationEventPublisher eventPublisher, MinioClient minioClient, ApplicationEventPublisher eventPublisher, ObjectProvider<MinioClient> minioClientProvider,
@Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName,
@Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) {
this.voucherWaybillBatchMapper = voucherWaybillBatchMapper; this.voucherWaybillBatchMapper = voucherWaybillBatchMapper;
@@ -95,7 +96,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
this.voucherImageMapper = voucherImageMapper; this.voucherImageMapper = voucherImageMapper;
this.voucherFileMapper = voucherFileMapper; this.voucherFileMapper = voucherFileMapper;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.minioClient = minioClient; this.minioClientProvider = minioClientProvider;
this.minioBucketName = minioBucketName; this.minioBucketName = minioBucketName;
this.minioRootDirectory = minioRootDirectory; this.minioRootDirectory = minioRootDirectory;
} }
@@ -251,7 +252,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
List<String> candidateObjectKeys = resolvedObjectKey.equals(objectKey) List<String> candidateObjectKeys = resolvedObjectKey.equals(objectKey)
? List.of(objectKey) : List.of(resolvedObjectKey, objectKey); ? List.of(objectKey) : List.of(resolvedObjectKey, objectKey);
for (String candidateObjectKey : candidateObjectKeys) { for (String candidateObjectKey : candidateObjectKeys) {
try (InputStream source = minioClient.getObject(GetObjectArgs.builder().bucket(minioBucketName).object(candidateObjectKey).build()); try (InputStream source = minioClient().getObject(GetObjectArgs.builder().bucket(minioBucketName).object(candidateObjectKey).build());
OutputStream target = Files.newOutputStream(archivePath)) { OutputStream target = Files.newOutputStream(archivePath)) {
source.transferTo(target); source.transferTo(target);
return archivePath; return archivePath;
@@ -427,7 +428,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()); String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo());
String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName); String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName);
String contentType = contentType(fileName); String contentType = contentType(fileName);
minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) minioClient().putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey)
.stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024)
.contentType(contentType).build()); .contentType(contentType).build());
VoucherFile file = new VoucherFile(); VoucherFile file = new VoucherFile();
@@ -490,7 +491,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()); String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo());
String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName); String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName);
String contentType = contentType(fileName); String contentType = contentType(fileName);
minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) minioClient().putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey)
.stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024)
.contentType(contentType).build()); .contentType(contentType).build());
VoucherFile file = new VoucherFile(); VoucherFile file = new VoucherFile();
@@ -537,7 +538,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
String entryName = plateNo + "/" + System.currentTimeMillis() + "_" + fileName; String entryName = plateNo + "/" + System.currentTimeMillis() + "_" + fileName;
String objectKey = buildObjectKey(voucher.getId(), matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()), plateNo, entryName); String objectKey = buildObjectKey(voucher.getId(), matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()), plateNo, entryName);
try (InputStream inputStream = imageFile.getInputStream()) { try (InputStream inputStream = imageFile.getInputStream()) {
minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) minioClient().putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey)
.stream(inputStream, imageFile.getSize(), 10 * 1024 * 1024) .stream(inputStream, imageFile.getSize(), 10 * 1024 * 1024)
.contentType(Func.isEmpty(imageFile.getContentType()) ? contentType(fileName) : imageFile.getContentType()).build()); .contentType(Func.isEmpty(imageFile.getContentType()) ? contentType(fileName) : imageFile.getContentType()).build());
} }
@@ -606,7 +607,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
return Func.isEmpty(normalizedPlateNo) ? "未识别车牌" : normalizedPlateNo; return Func.isEmpty(normalizedPlateNo) ? "未识别车牌" : normalizedPlateNo;
} }
private void deleteObjectQuietly(String objectKey) { try { minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioBucketName).object(objectKey).build()); } catch (Exception exception) { log.warn("删除凭证对象失败 objectKey={}", objectKey, exception); } } private void deleteObjectQuietly(String objectKey) { try { minioClient().removeObject(RemoveObjectArgs.builder().bucket(minioBucketName).object(objectKey).build()); } catch (Exception exception) { log.warn("删除凭证对象失败 objectKey={}", objectKey, exception); } }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -838,7 +839,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
String objectKey = buildObjectKey(voucher.getId(), waybillNo, String objectKey = buildObjectKey(voucher.getId(), waybillNo,
Func.isEmpty(plateNo) ? "root" : plateNo, entryName); Func.isEmpty(plateNo) ? "root" : plateNo, entryName);
String contentType = contentType(fileName); String contentType = contentType(fileName);
minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) minioClient().putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey)
.stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024)
.contentType(contentType).build()); .contentType(contentType).build());
VoucherFile file = new VoucherFile(); VoucherFile file = new VoucherFile();
@@ -1052,6 +1053,20 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
} }
} }
/**
* 获取 MinIO 客户端
* <p>
* 未配置 file.storage.minio.endpoint 时该客户端不会被注册
* 此处给出与 {@link #validateMinioConfig()} 一致的明确提示
*/
private MinioClient minioClient() {
MinioClient minioClient = minioClientProvider.getIfAvailable();
if (minioClient == null) {
throw new ServiceException("Nacos 未配置 file.storage.minio.endpoint,凭证文件功能不可用");
}
return minioClient;
}
private String buildObjectKey(Long voucherId, String waybillNo, String plateNo, String entryName) { private String buildObjectKey(Long voucherId, String waybillNo, String plateNo, String entryName) {
String objectKey = voucherId + "/" + waybillNo + "/" + safePathPart(plateNo) + "/" + safeArchivePath(entryName); String objectKey = voucherId + "/" + waybillNo + "/" + safePathPart(plateNo) + "/" + safeArchivePath(entryName);
if (Func.isEmpty(minioRootDirectory)) { if (Func.isEmpty(minioRootDirectory)) {
@@ -1127,7 +1142,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
private String buildVoucherFileUrl(Long fileId, String objectKey) { private String buildVoucherFileUrl(Long fileId, String objectKey) {
try { try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() return minioClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET).bucket(minioBucketName).object(objectKey).expiry(1, java.util.concurrent.TimeUnit.HOURS).build()); .method(Method.GET).bucket(minioBucketName).object(objectKey).expiry(1, java.util.concurrent.TimeUnit.HOURS).build());
} catch (Exception exception) { } catch (Exception exception) {
log.warn("生成凭证文件预览地址失败 voucherFileId={}, objectKey={}", fileId, objectKey, exception); log.warn("生成凭证文件预览地址失败 voucherFileId={}, objectKey={}", fileId, objectKey, exception);
@@ -19,6 +19,7 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.WebUtil; import org.springblade.core.tool.utils.WebUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.system.cache.DictCache; import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.DictBizCache; import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.UserCache; import org.springblade.system.cache.UserCache;
@@ -51,10 +52,10 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -68,7 +69,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImportBatchMapper, WaybillImportBatch> implements IWaybillImportBatchService { public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImportBatchMapper, WaybillImportBatch> implements IWaybillImportBatchService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 批量导入状态仅保留草稿与导入完成两种。 */ /** 批量导入状态仅保留草稿与导入完成两种。 */
private static final String STATUS_DRAFT = "draft"; private static final String STATUS_DRAFT = "draft";
private static final String STATUS_COMPLETED = "completed"; private static final String STATUS_COMPLETED = "completed";
@@ -184,20 +184,36 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
List<Map<String, Object>> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows(); List<Map<String, Object>> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows();
// 逐行构建运单未落库构建失败时报出对应行号
List<Waybill> rowWaybills = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
try {
rowWaybills.add(buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft));
} catch (Exception exception) {
throw new ServiceException("" + (index + 1) + "" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
}
// 同一运单标识号的多货物行合并为一条运单各行转为货物明细
Map<Integer, Waybill> waybillByRow = new HashMap<>();
Set<Integer> skipRows = new HashSet<>();
mergeMultiCargoRows(rows, rowWaybills, waybillByRow, skipRows);
List<Waybill> waybills = new ArrayList<>(); List<Waybill> waybills = new ArrayList<>();
Map<String, List<Waybill>> loadingWaybills = new TreeMap<>(); Map<String, List<Waybill>> loadingWaybills = new TreeMap<>();
for (int index = 0; index < rows.size(); index++) { for (int index = 0; index < rows.size(); index++) {
try { if (skipRows.contains(index)) continue;
Waybill waybill = waybillByRow.getOrDefault(index, rowWaybills.get(index));
// 配载标识号必须取自行数据运单落库prepareForSave会把新建运单的 loadingNo 置空
String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号"); String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号");
Waybill waybill = buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft); try {
if (draft) waybillService.saveDraft(waybill); else waybillService.submit(waybill); if (draft) waybillService.saveDraft(waybill); else waybillService.submit(waybill);
} catch (Exception exception) {
throw new ServiceException("" + (index + 1) + "" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
waybills.add(waybill); waybills.add(waybill);
if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) { if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) {
loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill); loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill);
} }
} catch (Exception exception) {
throw new ServiceException("" + (index + 1) + "" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
} }
loadingWaybills.forEach(loadingManageService::createFromImportedWaybills); loadingWaybills.forEach(loadingManageService::createFromImportedWaybills);
batch.setWaybillCount(waybills.size()); batch.setWaybillCount(waybills.size());
@@ -218,6 +234,69 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
} }
} }
/**
* 将同一运单标识号的多货物行合并为一条运单
* 首行作为主行数值字段累加各行后续行转为货物明细追加到主行货物信息中
* merge 的行记入 skipRows落库时跳过
*/
private void mergeMultiCargoRows(List<Map<String, Object>> rows, List<Waybill> rowWaybills,
Map<Integer, Waybill> waybillByRow, Set<Integer> skipRows) {
Map<String, List<Integer>> groups = new LinkedHashMap<>();
for (int index = 0; index < rowWaybills.size(); index++) {
String relationNo = rowWaybills.get(index).getRelationNo();
if (Func.isEmpty(relationNo)) continue;
groups.computeIfAbsent(relationNo, key -> new ArrayList<>()).add(index);
}
for (List<Integer> groupRows : groups.values()) {
if (groupRows.size() <= 1) continue;
Waybill primary = rowWaybills.get(groupRows.get(0));
List<Map<String, Object>> goodsList = new ArrayList<>();
for (Integer rowIndex : groupRows) {
Waybill current = rowWaybills.get(rowIndex);
if (!rowIndex.equals(groupRows.get(0))) {
// 主行字段保留首行值数量等数值字段累加同组其余各行
mergeNumericFields(primary, current);
skipRows.add(rowIndex);
waybillByRow.remove(rowIndex);
}
goodsList.add(buildGoodsItem(rows.get(rowIndex), current));
}
primary.setGoodsJson(JsonUtil.toJson(goodsList));
}
}
/** 合并多货物行时累加数量、其他费用等可加数值字段;为空的字段跳过。 */
private void mergeNumericFields(Waybill primary, Waybill current) {
primary.setQuantity(sumNullable(primary.getQuantity(), current.getQuantity()));
primary.setOtherFeeTotal(sumNullable(primary.getOtherFeeTotal(), current.getOtherFeeTotal()));
}
private BigDecimal sumNullable(BigDecimal first, BigDecimal second) {
if (first == null) return second;
if (second == null) return first;
return first.add(second);
}
/** 由导入行构建一条货物明细,字段与运单详情货物编辑结构保持一致。 */
private Map<String, Object> buildGoodsItem(Map<String, Object> row, Waybill waybill) {
Map<String, Object> goods = new LinkedHashMap<>();
putIfNotEmpty(goods, "cargoName", waybill.getCargoName());
putIfNotEmpty(goods, "cargoType", waybill.getCargoType());
putIfNotEmpty(goods, "specification", waybill.getSpecification());
putIfNotEmpty(goods, "model", waybill.getModel());
putIfNotEmpty(goods, "packageType", stringValue(row, "packageType", "包装"));
if (waybill.getQuantity() != null) {
goods.put("quantity", waybill.getQuantity().toPlainString());
}
putIfNotEmpty(goods, "quantityUnit", waybill.getQuantityUnit());
putIfNotEmpty(goods, "remark", waybill.getRemark());
return goods;
}
private void putIfNotEmpty(Map<String, Object> goods, String key, String value) {
if (Func.isNotEmpty(value)) goods.put(key, value);
}
@Override @Override
public IPage<WaybillImportBatchVO> page(IPage<WaybillImportBatch> page, WaybillImportBatchRequest request) { public IPage<WaybillImportBatchVO> page(IPage<WaybillImportBatch> page, WaybillImportBatchRequest request) {
LambdaQueryWrapper<WaybillImportBatch> queryWrapper = Wrappers.<WaybillImportBatch>lambdaQuery() LambdaQueryWrapper<WaybillImportBatch> queryWrapper = Wrappers.<WaybillImportBatch>lambdaQuery()
@@ -386,11 +465,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
throw new ServiceException(fieldName + "不能为空"); throw new ServiceException(fieldName + "不能为空");
} }
String text = String.valueOf(value).trim(); String text = String.valueOf(value).trim();
try { // 宽容解析接受 2026-8-22026-9-1 8:0:0 等写法口径见根工作区 docs/import-spec.md
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
} catch (DateTimeParseException exception) { if (date == null) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD HH:mm:ss"); throw new ServiceException(fieldName + " 日期格式无法识别:" + text);
} }
return date;
} }
private BigDecimal defaultQuantity(BigDecimal quantity) { private BigDecimal defaultQuantity(BigDecimal quantity) {
@@ -523,6 +603,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
// 16. 同一运单标识号校验 // 16. 同一运单标识号校验
validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors); validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors);
// 17. 同一运单标识号组内一致性校验地址/配载标识号/数量单位多货物合并的前提
validateWaybillIdentifierConsistency(row, i, waybillIdentifierMap, rows, errors);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
errorMap.put(i, String.join("; ", errors)); errorMap.put(i, String.join("; ", errors));
} }
@@ -571,21 +654,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
if (!isRoadTransport(transportType)) { if (!isRoadTransport(transportType)) {
return; return;
} }
if (Func.isEmpty(vehicleNo)) { // 必填长度与格式统一为一条简短原因避免多条规则叠加导致失败原因过长
errors.add("公路运输时车牌号/航班号/船号/班列号不能为空"); // 文案保留车牌号字样导入失败明细仍能据此定位到车牌号列并标红
return; // VEHICLE_NO_PATTERN 已约束总长度为 7 8 故不再单独判断长度
} if (Func.isEmpty(vehicleNo)
if (vehicleNo.length() < 7) { || !VEHICLE_PROVINCE_PATTERN.matcher(vehicleNo).lookingAt()
errors.add("车牌号长度不能少于7位"); || !VEHICLE_NO_PATTERN.matcher(vehicleNo).matches()) {
} errors.add("车牌号校验不通过");
if (vehicleNo.length() > 8) {
errors.add("车牌号长度不能超过8位");
}
if (!VEHICLE_PROVINCE_PATTERN.matcher(vehicleNo).lookingAt()) {
errors.add("车牌号首位必须是省份简称,第二位必须是英文字母");
}
if (!VEHICLE_NO_PATTERN.matcher(vehicleNo).matches()) {
errors.add("车牌号格式不正确,应为首位省份简称、次位英文字母、总长度7或8位");
} }
} }
@@ -826,18 +901,62 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
} }
} }
/**
* 同一运单标识号组内的发货地址到货地址数量单位配载标识号必须一致
* 多货物行会合并为一条运单上述字段不一致时无法合并数量累加也要求单位相同
*/
private void validateWaybillIdentifierConsistency(Map<String, Object> row, int rowIndex,
Map<String, List<Integer>> waybillIdentifierMap, List<Map<String, Object>> allRows, List<String> errors) {
String waybillIdentifier = stringValue(row, "waybillIdentifier", "同一运单标识号");
if (Func.isEmpty(waybillIdentifier)) {
return;
}
List<Integer> sameIdentifierRows = waybillIdentifierMap.get(waybillIdentifier);
if (sameIdentifierRows == null || sameIdentifierRows.size() <= 1) {
return;
}
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "departureAddress", "发货地址", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "arrivalAddress", "到货地址", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "quantityUnit", "数量单位", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "loadingIdentifier", "配载标识号", errors);
}
/** 比较同一运单标识号组内其余行的字段值,不一致时记录错误;空值不参与比较。 */
private void compareGroupValue(int rowIndex, List<Integer> sameIdentifierRows, List<Map<String, Object>> allRows,
String field, String fieldName, List<String> errors) {
String currentValue = normalizeCompareValue(stringValue(allRows.get(rowIndex), field, fieldName));
if (Func.isEmpty(currentValue)) {
return;
}
for (Integer otherRowIndex : sameIdentifierRows) {
if (otherRowIndex <= rowIndex) {
continue;
}
String otherValue = normalizeCompareValue(stringValue(allRows.get(otherRowIndex), field, fieldName));
if (Func.isNotEmpty(otherValue) && !currentValue.equals(otherValue)) {
errors.add("同一运单标识号下," + fieldName + "必须一致");
return;
}
}
}
private String normalizeCompareValue(String value) {
return Func.isEmpty(value) ? null : value.replaceAll("\\s+", "");
}
private LocalDate parseDateForValidation(Object value, String fieldName, List<String> errors) { private LocalDate parseDateForValidation(Object value, String fieldName, List<String> errors) {
if (value == null || String.valueOf(value).isBlank()) { if (value == null || String.valueOf(value).isBlank()) {
return null; return null;
} }
String text = String.valueOf(value).trim(); String text = String.valueOf(value).trim();
try { // 宽容解析接受 2026-8-22026-9-1 8:0:0 等写法口径见根工作区 docs/import-spec.md
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
} catch (DateTimeParseException exception) { if (date == null) {
errors.add(fieldName + "格式必须为日期格式(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"); errors.add(fieldName + " 日期格式无法识别:" + text);
return null; return null;
} }
return date;
} }
private Map<String, String> loadTransportTypeOptions() { private Map<String, String> loadTransportTypeOptions() {
@@ -861,8 +980,8 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
// 字典读取失败时使用默认值 // 字典读取失败时使用默认值
} }
if (options.isEmpty()) { if (options.isEmpty()) {
List<String> defaults = List.of("公路运输", "铁路运输", "水路运输", "航空运输", // 运输方式仅支持公路铁路水路航空四种
"公路整车", "公路配载/零担", "铁路整车", "铁路零担", "水路", "航空", "多式联运", "管道运输", "其他"); List<String> defaults = List.of("公路运输", "铁路运输", "水路运输", "航空运输");
defaults.forEach(value -> options.put(value, value)); defaults.forEach(value -> options.put(value, value));
} }
return options; return options;
@@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
@@ -138,6 +139,22 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
return result; return result;
} }
@Override
@TenantIgnore
public WaybillVO publicDetail(Long id) {
if (Func.isEmpty(id)) {
throw new ServiceException("主键不能为空");
}
Waybill waybill = getById(id);
if (Func.isEmpty(waybill) || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单管理不存在");
}
WaybillVO waybillVO = WaybillWrapper.build().entityVO(waybill);
waybillVO.setReadonly(true);
fillCustomerNameFromContract(waybillVO);
return waybillVO;
}
@Override @Override
public WaybillLocateVO locateVehicle(Long id) { public WaybillLocateVO locateVehicle(Long id) {
Waybill waybill = requireWaybillWithVehicleNo(id, "无法实时定位"); Waybill waybill = requireWaybillWithVehicleNo(id, "无法实时定位");
@@ -885,7 +902,13 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
WaybillExcel excel = data.get(index); WaybillExcel excel = data.get(index);
try { try {
Waybill waybill = Objects.requireNonNull(BeanUtil.copyProperties(excel, Waybill.class)); Waybill waybill = Objects.requireNonNull(BeanUtil.copyProperties(excel, Waybill.class));
waybill.setEstimatedStartTime(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEstimatedStartTime(), "预计发货日期"));
waybill.setEstimatedEndTime(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEstimatedEndTime(), "预计完成日期"));
waybill.setStartDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getStartDate(), "开始日期"));
waybill.setEndDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEndDate(), "结束日期"));
waybill.setDataSource("批量导入"); waybill.setDataSource("批量导入");
waybill.setCreateTime(null);
waybill.setUpdateTime(null);
waybill.setCarrierJson(buildImportCarrierJson(waybill)); waybill.setCarrierJson(buildImportCarrierJson(waybill));
submit(waybill); submit(waybill);
} catch (Exception exception) { } catch (Exception exception) {
@@ -1022,7 +1045,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字"); TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字");
waybill.setMileage(mileage); waybill.setMileage(mileage);
waybill.setMileageRemark(mileageRemark); waybill.setMileageRemark(mileageRemark);
return updateById(waybill); boolean updated = updateById(waybill);
if (updated) {
receivablePayableDetailService.syncMileageFromWaybill(waybill.getId(), mileage);
}
return updated;
} }
@Override @Override
@@ -27,6 +27,7 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.vo.ContractManageVO; import org.springblade.transport.pojo.vo.ContractManageVO;
import java.math.BigDecimal;
import java.util.Objects; 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.setUpdateUserName(org.springblade.system.cache.UserCache.getUserRealName(contractManage.getUpdateUser()));
contractManageVO.setContractStageName(stageName(contractManage.getContractStage())); contractManageVO.setContractStageName(stageName(contractManage.getContractStage()));
contractManageVO.setApprovalStatusName(statusName(contractManage.getApprovalStatus())); contractManageVO.setApprovalStatusName(statusName(contractManage.getApprovalStatus()));
if (contractManageVO.getContractAmount() != null
&& contractManageVO.getContractAmount().compareTo(BigDecimal.valueOf(-1)) == 0) {
contractManageVO.setContractAmount(null);
}
return contractManageVO; return contractManageVO;
} }
@@ -32,6 +32,7 @@ import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.support.WaybillProcessSupport;
import java.math.BigDecimal;
import java.util.Objects; import java.util.Objects;
/** /**
@@ -58,9 +59,23 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
waybillVO.setBusinessStatus(displayStatus); waybillVO.setBusinessStatus(displayStatus);
waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); waybillVO.setBusinessStatusName(businessStatusName(displayStatus));
waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); 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; return waybillVO;
} }
private static boolean isSentinelMinusOne(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.valueOf(-1)) == 0;
}
public static String businessStatusName(String status) { public static String businessStatusName(String status) {
if (status == null) { if (status == null) {
return "未知"; return "未知";
+3
View File
@@ -197,6 +197,9 @@ blade:
- /wechat/** - /wechat/**
#开启错误日志入库 #开启错误日志入库
error-log: true error-log: true
#增删改操作日志自动入库(blade_log_api
operation:
enabled: true
#xss配置 #xss配置
xss: xss:
enabled: true enabled: true
+4 -1
View File
@@ -308,12 +308,15 @@ CREATE TABLE `blade_dept` (
`mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码',
`carrier_customer_id` bigint NULL DEFAULT NULL COMMENT '承运商客商档案主键', `carrier_customer_id` bigint NULL DEFAULT NULL COMMENT '承运商客商档案主键',
`is_platform_company` tinyint NOT NULL DEFAULT 0 COMMENT '是否平台公司:0否,1是', `is_platform_company` tinyint NOT NULL DEFAULT 0 COMMENT '是否平台公司:0否,1是',
`oa_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA主键',
`oa_sup_sub_com_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上级主键',
`sort` int NULL DEFAULT NULL COMMENT '排序', `sort` int NULL DEFAULT NULL COMMENT '排序',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
`status` int NULL DEFAULT 1 COMMENT '状态', `status` int NULL DEFAULT 1 COMMENT '状态',
`is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除', `is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除',
PRIMARY KEY (`id`) USING BTREE, PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_blade_dept_dept_code` (`dept_code`) UNIQUE KEY `uk_blade_dept_dept_code` (`dept_code`),
INDEX `idx_blade_dept_oa_id` (`oa_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '机构表'; ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '机构表';
-- ---------------------------- -- ----------------------------
@@ -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_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 '开户人姓名', `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 '开户行名称', `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 '银行账号', `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_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 '注册地址', `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`;
@@ -0,0 +1,6 @@
-- 机构表:记录 OA 主键与上级 OA 主键,仅用于同步后重建组织层级,不用于前端展示
ALTER TABLE `blade_dept`
ADD COLUMN `oa_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA主键' AFTER `is_platform_company`,
ADD COLUMN `oa_sup_sub_com_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上级主键' AFTER `oa_id`,
ADD INDEX `idx_blade_dept_oa_id` (`oa_id`);