30 Commits

Author SHA1 Message Date
dk
55a74815b2 feat(ai-report): 新增MinIO文件存储服务
- 添加AiReportFileConst和AiReportFileExtensionConst常量类
- 实现AiReportMinioStorageService存储服务
- 添加AiReportMinioProperties配置类
- 集成MinIO依赖到ai-report-common模块
- 配置应用yml中的ai-report桶设置
- 更新报告模型控制器使用MinIO存储服务
- 修改报告模型文件存储组件集成MinIO
- 添加相关单元测试验证功能
2026-07-14 16:38:11 +08:00
aac21e5f73 Merge remote-tracking branch 'origin/main' 2026-07-14 08:37:23 +08:00
6659df649a 添加了单独的长闪报文 2026-07-14 08:37:12 +08:00
dk
9b71dfdce3 Merge remote-tracking branch 'origin/main' 2026-07-13 22:00:49 +08:00
dk
46ae986db2 feat(storage): 集成 MinIO 存储系统并支持用户签名功能
- 添加 MinIO 配置和依赖项,包括 minio、okhttp、okio 和 kotlin 库
- 创建通用文件服务接口和实现,支持文件上传到 MinIO 并记录元数据
- 添加用户签名服务接口和数据库表结构,支持签名文件管理
- 修改用户服务以支持签名文件上传,在新增和更新用户时可上传签名文件
- 实现文件预览服务测试用例的注释调整
- 创建 SysFile 和 SysUserSignature 数据库实体及对应常量类
- 实现 MinIO 存储组件,提供文件上传、下载和删除功能
- 在用户实体中添加签名相关字段用于展示签名信息
2026-07-13 22:00:39 +08:00
周宇 蔡
6fc0d58fe4 fix(config): 修正配置文件中的命名规范和路径引用
- 将XML模板中的零序电流不平衡字段名从G_VZSEQ_UNBAN/VZSEQ_UNBAN更正为G_IZSEQ_UNBAN/IZSEQ_UNBAN
- 将默认规则文件路径从template/默认规则.txt统一更改为template/DefaultRule.txt
- 更新配置类和映射服务中对默认规则文件的引用路径
2026-07-13 10:48:56 +08:00
周宇 蔡
42911e35bb Merge branch 'dev-czy' 2026-07-10 11:20:58 +08:00
周宇 蔡
cbafe85c4a fix(mms-mapping): 修复谐波数据映射配置和偏移量计算问题
- 修改JiangSu_Config1.xml中谐波电流相关指标的命名规则
- 修改默认规则文件中相电压间谐波含有率序列的描述名称
- 添加相电压间谐波含有率序列实时数据值配置
- 更新JsonToXmlConversionService中遥测数据处理逻辑,将urcb相关标识修改为brcb
- 新增calculateOffset方法用于计算指标偏移量
- 实现adjustPathWithOffset方法根据offset调整路径中的数值格式
- 添加offset字段到MetricInfo类中用于存储偏移信息
- 在规则匹配过程中传递offset值并在生成Value节点时应用偏移调整
2026-07-10 11:20:31 +08:00
d8ae012bd2 feat(test-report): 重构台账导入功能并增加验证机制
- 新增导入临时目录、验证会话等常量配置
- 实现台账文件分步验证流程,支持先验证后导入模式
- 在控制器层新增validateLedger接口用于文件校验
- 修改importLedger接口参数结构,改为导入已验证批次
- 重构TestReportLedgerExcelParser解析器,支持独立验证和解析
- 新增验证结果对象TestReportLedgerValidateResultVO
- 实现台账文件批量存储和临时文件管理机制
- 完善文件上传、校验、导入各阶段的状态跟踪
- 优化错误提示信息,提供更详细的验证反馈
- 更新导入成功结果对象,增加批次ID和存储路径信息
2026-07-10 10:34:07 +08:00
周宇 蔡
fa1a285e04 fix(config): 修复江苏配置模板中的相角参数和波形时间标志处理
- 实现了从JSON映射文档动态替换ComtradeFile时间标志功能
- 调整了URCB相关配置的参数序列以匹配正确的数据结构
2026-07-08 13:43:10 +08:00
f5f828cd05 feat(test-report): 实现测试报告台账导入与分组生成功能
- 在.gitignore中添加各模块SQL资源目录忽略规则
- 从client-unit模块移除空的MyBatis映射文件
- 在test-report模块pom.xml中添加client-unit、test-device依赖及poi-ooxml库
- 扩展TestReportConst类添加报告生成状态、存储路径等常量定义
- 增强TestReportController实现台账模板下载、数据导入、分组管理等REST接口
- 修改TestReportMapper查询语句添加报告生成状态和数量统计字段
- 扩展TestReportParam.AddParam添加可选ID参数支持前端指定ID
- 在TestReportPO实体类中增加reportGenerateState字段存储生成状态
- 完善TestReportService接口定义台账导入、分组管理等业务方法
- 实现TestReportServiceImpl服务类中的台账导入、分组生成核心逻辑
- 添加报告ID解析、导入验证、文件处理等辅助方法实现
2026-07-03 16:05:17 +08:00
c1ad7feec2 feat(ai-report): 新增委托单位管理和报告审批功能
- 新增委托单位管理模块,支持增删改查、导入导出功能
- 实现委托单位 Excel 模板下载和数据导入功能
- 添加报告审批抽象处理器,支持审批流程日志记录
- 统一文件存储路径配置,将各模块独立路径改为统一根目录
- 新增 PQDIF 文件名存储字段,完善文件管理功能
- 修复代码中的乱码问题和错误提示信息
- 优化系统常量定义和字典配置管理
2026-07-01 13:15:57 +08:00
0ea686d3cb feat(icd): 实现标准ICD新增时自动设置自身为参照源功能
- 在添加标准ICD路径时自动填充referenceIcdId字段为自身ID
- 添加fillSelfReferenceForStandardIcd方法处理标准类型ICD的自参照逻辑
- 增加isStandardType方法判断ICD类型是否为标准类型
- 添加单元测试验证手动录入标准ICD和上游解析标准ICD的自参照功能
- 修改应用配置文件中的文件存储路径
- 新增ai-report模块及其子模块的Maven配置
- 移除数据校验服务中已废弃的单个监测点ID字段,统一使用监测点ID列表
- 更新相关实体类、参数类和返回类的数据结构定义
- 修正测试代码中的字段引用和字符编码问题
2026-06-25 07:59:08 +08:00
周宇 蔡
646b5f3283 fix(mapping): 修复配置类型判断逻辑
- 将SDI描述中的AB、BC、CA检测改为fullKey中线电压总有效值检测
- 确保正确的路径前缀添加逻辑
- 修复了条件判断的准确性问题
2026-06-23 16:44:32 +08:00
周宇 蔡
e92ed85647 Merge branch 'dev-czy' 2026-06-23 10:43:01 +08:00
周宇 蔡
5bd709560a refactor(icd-mapping): 重构ICD到XML转换服务以支持配置类型参数
- 移除generateFromIcd方法中的索引选择验证逻辑
- 在fillXmlContent方法中添加configType参数传递
- 在JsonToXmlConversionService中添加configType参数支持
- 删除convertFromJson方法的旧实现
- 更新buildXmlContentFromJson方法签名以包含configType参数
- 在applyRulesFromMapping和extractMetricsFromMapping方法中传递configType参数
- 添加针对PMS3.0配置类型的特殊处理逻辑
- 修复模板中线电压基波有效值描述文本错误
2026-06-23 10:42:01 +08:00
2b56da2134 feat(parse-pqdif): 重构PQDIF文件存储及解析功能
- 引入PqdifFileStorageService组件实现PQDIF原始文件本地存储
- 修改application.yml添加parse-pqdif.storage.path配置项
- 更新CsPqdifPath相关实体类将pqdifContent改为filePath存储路径
- 重构CsPqdifPathController中的文件上传处理逻辑
- 新增PqdifRecognizeResultVO支持PQDIF二次解析结果结构
- 实现ParsePqdifService接口支持可识别数据解析功能
- 添加PqdifSecondParseComponent进行底层解析结果转换
- 新增桌面PQDIF文件测试程序便于调试验证
- 为CsIcdPath增加referenceIcdId字段及相应测试用例
2026-06-23 08:23:15 +08:00
97b1334714 feat(icd): 完善ICD映射管理功能
- 在AuthGlobalFilter中添加稳态检验相关接口的免认证路径
- 修改CsDevTypeMapper.xml移除icdPath字段返回避免数据冗余
- 在CsIcdPathController中新增查询参照ICD列表和ICD校验详情接口
- 更新CsIcdPathMapper添加selectReferenceIcdPathList等方法实现
- 移除CsIcdPath相关实体和参数中的path字段简化数据结构
- 扩展ICD类型定义支持手动录入和上游解析的标准/非标准分类
- 重构激活标准ICD逻辑支持不同类型间的正确转换
- 新增ICD一致性校验排除规则跳过特定描述的DOI项检查
- 优化报告映射规则应用逻辑提升校验准确性
- 添加去除重复DOI项功能确保数据唯一性
2026-06-18 16:33:40 +08:00
7fb4c8e78a feat(mms-mapping): 添加ICD校验结果保存功能支持文件上传
- 在CsIcdPathController中添加POST接口支持multipart表单上传ICD文件
- 实现saveIcdCheckResultWithFile方法处理文件上传和校验结果保存
- 添加fillIcdFile方法处理ICD文件内容填充到参数对象
- 在CsIcdPathPO中将Icd_Content字段重命名为Icd并使用JsonNodeTypeHandler处理JSON转换
- 更新resultMap配置使用新的字段映射关系
- 修改ICD一致性校验服务的日志记录和校验逻辑
- 移除自动修正映射差异的功能,只保留一致性检查
- 优化测试用例验证ICD校验结果保存和文件上传功能
2026-06-16 13:25:12 +08:00
fd6e5097d7 feat(mms-mapping): 添加ICD一致性校验功能并重构设备类型管理
- 在MappingController中新增ICD一致性校验接口checkIcdJsonConsistency
- 添加IcdConsistencyCheckService服务实现ICD映射JSON一致性校验逻辑
- 添加IcdConsistencyCheckRequest和IcdConsistencyCheckResponse相关数据传输对象
- 在CsIcdPathPO中新增icdContent字段存储ICD内容字节数组
- 在CsIcdPathMapper中新增selectIcdPathList方法支持关键词搜索
- 移除设备类型相关的控制器、服务接口及实现类(MmsDeviceTypeController等)
- 更新.gitignore文件排除特定jar包路径
- 在pom.xml中添加device-types模块依赖和JNA库依赖
- 更新README.md文档添加device-types模块说明
- 重命名steady-DataView为steady-dataView模块名统一格式
2026-06-15 08:38:19 +08:00
周宇 蔡
1edee2bf12 fix(mms-mapping): 修复XML资源配置加载和JSON映射提取问题
- 删除冗余注释,优化代码可读性
- 增强extractMappingJson方法中的类型安全处理,支持字符串和对象类型的mappingJson字段
- 添加对象类型转换逻辑,确保返回正确格式的JSON字符串
2026-06-12 13:55:48 +08:00
b7b18dc325 调整parse函数解析失败的异常处理。现在不会直接抛出异常了。 2026-06-12 10:58:49 +08:00
a5a09022f2 添加了pqdif解析的基础功能,开放了一个基础解析接口。 2026-06-12 10:42:18 +08:00
4aca8ca2c4 Merge remote-tracking branch 'origin/main' 2026-06-12 10:41:11 +08:00
362bbf536f 添加了pqdif解析的基础功能,开放了一个基础解析接口。 2026-06-12 10:40:59 +08:00
周宇 蔡
89ec9e1fa3 Merge branch 'dev-czy' 2026-06-12 09:51:41 +08:00
周宇 蔡
1343d235c8 refactor(mms-mapping): 更新江苏配置模板并增强JSON到XML转换服务
- 将JiangSu_Config1.xml和JiangSu_Config2.xml中的Value标签注释格式统一
- 在JsonToXmlConversionService中改进规则匹配统计信息显示
- 添加XML模板中Value标签数量统计功能
- 实现匹配成功和未匹配Value标签的计数统计
- 增加未匹配标签的详细描述列表输出
- 新增countXmlValueTags方法用于统计XML中Value标签总数
- 新增countMatchedValueTags方法用于统计已匹配的Value标签数
- 新增findUnmatchedValueTags方法用于查找未匹配的Value标签描述
2026-06-12 09:50:51 +08:00
周宇 蔡
0cecf2d7a2 Merge branch 'dev-czy' 2026-06-12 08:53:25 +08:00
周宇 蔡
c0cf4de315 feat(mapping): 添加配置文件类型参数支持
- 在 generateXmlFromJson 方法中添加 configType 参数以支持不同配置文件
- 新增 loadXmlResources(Integer configType) 方法用于加载指定类型的XML资源
- 更新 JsonToXmlRequest 类添加 configType 属性定义
- 修改 MappingController 中的 getXmlFromJson 方法以传递配置类型参数
- 扩展 RuleBasedXmlMappingService 的 loadDefaultXmlFile 方法支持配置类型选择
- 在调试类 JsonToXmlDebugRunner 中设置默认使用配置类型2进行测试
2026-06-12 08:52:36 +08:00
212b69060c refactor(steady): 重构数据校验功能并新增PQDIF解析预留模块
- 将数据校验中的缺失率相关字段替换为数据完整性字段
- 新增数据校验任务删除功能及相应测试
- 在tools模块中添加parse-pqdif子模块作为PQDIF文件解析预留
- 更新README文档以反映新的模块结构和依赖关系
- 优化数据校验统计汇总逻辑和测试覆盖
- 在entrance模块中集成parse-pqdif依赖
- 重构数据校验服务层实现和数据对象映射
2026-06-12 08:41:11 +08:00
293 changed files with 26979 additions and 1072 deletions

6
.gitignore vendored
View File

@@ -23,6 +23,7 @@ docs/
*.ear
*.tar.gz
*.rar
!tools/parse-pqdif/lib/pqdif-native-basic-bridge-1.0.0-jar-with-dependencies.jar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
@@ -51,3 +52,8 @@ rebel.xml
/.fastRequest/collections/Root/Default Group/directory.json
/.fastRequest/collections/Root/directory.json
/.fastRequest/config/fastRequestCurrentProjectConfig.json
/ai-report/report-approval/src/main/resources/sql/report-approval/
/ai-report/test-report/src/main/resources/sql/test-report/
/ai-report/client-unit/src/main/resources/sql/client-unit/
/ai-report/report-model/src/main/resources/sql/report-model/
/ai-report/test-device/src/main/resources/sql/test-device/

View File

@@ -18,7 +18,9 @@
- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性和受影响范围检查进行验证。
- 除非用户明确要求,否则不执行任何 `mvn` 编译、打包、测试或其他构建命令。
- 系统中所有无数据项默认显示 `-`;新增或修改列表、详情、导出、汇总等展示逻辑时,若字段无值,统一按该约定处理,避免空白、`null` 或其他占位写法并存。
- 所有导出或生成文件名统一追加日期,格式为“原文件名 + `_` + `yyyyMMdd` + 原扩展名”,例如 `暂态事件列表_20260516.xlsx`;新增下载导出、临时生成或落盘保存文件时,优先复用目标模块已有文件名工具,不要在业务代码中散落手写日期拼接。
- 后端本地文件存储统一使用 `files.path` 作为根目录配置;各业务模块只能在该根目录下追加自己的固定子目录,例如 PQDIF 文件使用 `pqdif-files`,报告模板使用 `report-model`,检测设备校准报告使用 `test-device-report`,检测报告相关文件使用 `test-report`。不要再新增 `parse-pqdif.storage.path``report-model.storage.path``test-device.report.storage.path` 这类模块级根路径配置。
## 项目结构与模块划分
`CN_Tool` 是一个 Maven 多模块后端项目,根目录的 [`pom.xml`](C:/code/gitea/cn_tool/CN_Tool/pom.xml) 聚合了 `entrance``system``user``detection``tools`

View File

@@ -34,7 +34,9 @@ CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓
- `activate-tool`
- `add-data`
- `add-ledger`
- `device-types`
- `mms-mapping`
- `parse-pqdif`
- `wave-tool`
## 启动入口
@@ -43,7 +45,7 @@ CN_Tool 是一个基于 Spring Boot 的多模块后端聚合工程,当前仓
- `entrance/src/main/java/com/njcn/gather/EntranceApplication.java`
`entrance` 模块聚合了 `system``disk-monitor``dbms``deploy``user``detection``activate-tool``add-data``add-ledger``wave-tool``mms-mapping`,是当前运行时主入口。
`entrance` 模块聚合了 `system``disk-monitor``dbms``deploy``user``detection``activate-tool``add-data``add-ledger``wave-tool``device-types``mms-mapping``parse-pqdif`,是当前运行时主入口。
## 技术基线
@@ -98,8 +100,12 @@ P0 已补齐基线文档,建议按以下顺序阅读:
- 当前提供电能质量 13 张表批量补数、任务状态查询和模板规则查询能力
- `tools/add-ledger`
- 当前为数据台账工具预留空模块
- `tools/device-types`
- 负责设备类型维护、ICD 校验结果保存和 PQDIF 校验预留入口
- `tools/mms-mapping`
- 负责 ICD 文件解析与 MMS 映射数据生成能力
- `tools/parse-pqdif`
- 当前为 PQDIF 文件解析能力预留空方法骨架
- `tools/wave-tool`
- 负责波形文本解析与查看数据组装能力

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ai-report-common</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.3</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,36 @@
package com.njcn.gather.aireport.common.config;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* ai-report MinIO 配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "storage.minio")
public class AiReportMinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private Map<String, String> buckets = new LinkedHashMap<String, String>();
public String requireBucket(String bucketKey) {
String bucketName = buckets.get(bucketKey);
if (StrUtil.isBlank(bucketName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "MinIO桶配置缺失" + bucketKey);
}
return bucketName;
}
}

View File

@@ -0,0 +1,16 @@
package com.njcn.gather.aireport.common.constant;
/**
* ai-report 文件常量。
*/
public final class AiReportFileConst {
private AiReportFileConst() {
}
public static final String BUCKET_KEY_AI_REPORT = "ai-report";
public static final String BIZ_DIR_REPORT_MODEL = "report-model";
public static final String BIZ_DIR_TEST_DEVICE = "test-device";
public static final String BIZ_DIR_TEST_REPORT = "test-report";
}

View File

@@ -0,0 +1,27 @@
package com.njcn.gather.aireport.common.constant;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* ai-report 文件扩展名白名单。
*/
public final class AiReportFileExtensionConst {
private AiReportFileExtensionConst() {
}
public static final Set<String> ALLOWED_EXTENSIONS = unmodifiable("docx", "xlsx", "pdf");
public static final Set<String> WORD_EXTENSIONS = unmodifiable("docx");
public static final Set<String> EXCEL_EXTENSIONS = unmodifiable("xlsx");
public static final Set<String> PDF_EXTENSIONS = unmodifiable("pdf");
private static Set<String> unmodifiable(String... extensions) {
return Collections.unmodifiableSet(new LinkedHashSet<String>(Arrays.asList(extensions)));
}
}

View File

@@ -0,0 +1,384 @@
package com.njcn.gather.aireport.common.storage;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.common.config.AiReportMinioProperties;
import com.njcn.gather.aireport.common.constant.AiReportFileConst;
import io.minio.BucketExistsArgs;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.ListObjectsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.Result;
import io.minio.StatObjectArgs;
import io.minio.errors.ErrorResponseException;
import io.minio.messages.Item;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/**
* ai-report MinIO 存储服务。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AiReportMinioStorageService {
private final AiReportMinioProperties properties;
public StoredFile saveMultipartFile(String bizDir, String ownerId, MultipartFile file, boolean keepOriginalName) {
MultipartFile normalizedFile = requireFile(file);
String originalFileName = normalizeFileName(normalizedFile.getOriginalFilename());
String targetFileName = keepOriginalName ? sanitizeFileName(originalFileName) : buildStoredFileName(originalFileName);
String objectName = buildObjectName(bizDir, ownerId, targetFileName);
String bucketName = requireBucket();
String contentType = resolveContentType(normalizedFile.getContentType(), originalFileName);
try (InputStream inputStream = normalizedFile.getInputStream()) {
putObject(bucketName, objectName, inputStream, normalizedFile.getSize(), contentType);
return new StoredFile(bucketName, objectName, targetFileName, originalFileName, contentType, normalizedFile.getSize());
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("save multipart file to minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "保存文件失败");
}
}
public StoredFile saveInputStream(String bizDir, String ownerId, String originalFileName, InputStream inputStream,
long size, String contentType, boolean keepOriginalName) {
String normalizedFileName = normalizeFileName(originalFileName);
String targetFileName = keepOriginalName ? sanitizeFileName(normalizedFileName) : buildStoredFileName(normalizedFileName);
String objectName = buildObjectName(bizDir, ownerId, targetFileName);
String bucketName = requireBucket();
try {
putObject(bucketName, objectName, inputStream, size, resolveContentType(contentType, normalizedFileName));
return new StoredFile(bucketName, objectName, targetFileName, normalizedFileName,
resolveContentType(contentType, normalizedFileName), size);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("save input stream to minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "保存文件失败");
}
}
public StoredFile saveBytes(String bizDir, String ownerId, String originalFileName, byte[] content,
String contentType, boolean keepOriginalName) {
byte[] fileContent = content == null ? new byte[0] : content;
return saveInputStream(bizDir, ownerId, originalFileName, new ByteArrayInputStream(fileContent),
fileContent.length, contentType, keepOriginalName);
}
public StoredObject getObject(String objectName) {
if (StrUtil.isBlank(objectName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件不存在");
}
String bucketName = requireBucket();
try {
GetObjectResponse response = buildClient().getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return new StoredObject(bucketName, objectName, response);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("read file from minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public byte[] readBytes(String objectName) {
try (StoredObject storedObject = getObject(objectName);
InputStream inputStream = storedObject.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int length;
while ((length = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
return outputStream.toByteArray();
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("read file bytes from minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public Path downloadToTempFile(String objectName, String fileName) {
String normalizedFileName = normalizeFileName(fileName);
String extension = resolveExtension(normalizedFileName);
String suffix = StrUtil.isBlank(extension) ? ".tmp" : "." + extension;
try (StoredObject storedObject = getObject(objectName);
InputStream inputStream = storedObject.getInputStream()) {
Path tempFile = Files.createTempFile("ai-report-", suffix);
Files.copy(inputStream, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return tempFile;
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("download minio object to temp file failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public List<String> listFileNames(String prefix) {
List<String> objectNames = listObjectNames(prefix);
List<String> result = new ArrayList<String>();
for (String objectName : objectNames) {
int slashIndex = objectName.lastIndexOf('/');
result.add(slashIndex >= 0 ? objectName.substring(slashIndex + 1) : objectName);
}
return result;
}
public List<String> listObjectNames(String prefix) {
List<String> result = new ArrayList<String>();
if (StrUtil.isBlank(prefix)) {
return result;
}
try {
Iterable<Result<Item>> iterable = buildClient().listObjects(ListObjectsArgs.builder()
.bucket(requireBucket())
.prefix(trimPath(prefix) + "/")
.recursive(true)
.build());
for (Result<Item> itemResult : iterable) {
Item item = itemResult.get();
if (item == null || item.isDir()) {
continue;
}
String objectName = item.objectName();
if (StrUtil.isBlank(objectName)) {
continue;
}
result.add(objectName);
}
return result;
} catch (Exception exception) {
log.error("list minio objects failed, prefix={}", prefix, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public void deleteQuietly(String objectName) {
if (StrUtil.isBlank(objectName)) {
return;
}
try {
buildClient().removeObject(RemoveObjectArgs.builder()
.bucket(requireBucket())
.object(objectName)
.build());
} catch (Exception exception) {
log.warn("delete file from minio failed, objectName={}", objectName, exception);
}
}
public boolean exists(String objectName) {
if (StrUtil.isBlank(objectName)) {
return false;
}
try {
buildClient().statObject(StatObjectArgs.builder()
.bucket(requireBucket())
.object(objectName)
.build());
return true;
} catch (ErrorResponseException exception) {
String errorCode = exception.errorResponse() == null ? null : exception.errorResponse().code();
if ("NoSuchKey".equals(errorCode) || "NoSuchObject".equals(errorCode) || "NoSuchBucket".equals(errorCode)) {
return false;
}
log.error("stat minio object failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
} catch (Exception exception) {
log.error("stat minio object failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public void deleteTempFileQuietly(Path tempFile) {
if (tempFile == null) {
return;
}
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程。
}
}
private void putObject(String bucketName, String objectName, InputStream inputStream, long size, String contentType) throws Exception {
ensureBucket(buildClient(), bucketName);
buildClient().putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, size, -1)
.contentType(contentType)
.build());
}
private MultipartFile requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "上传文件不能为空");
}
return file;
}
private String requireBucket() {
validateConfig();
return properties.requireBucket(AiReportFileConst.BUCKET_KEY_AI_REPORT);
}
private void validateConfig() {
if (StrUtil.hasBlank(properties.getEndpoint(), properties.getAccessKey(), properties.getSecretKey())) {
throw new BusinessException(CommonResponseEnum.FAIL, "MinIO配置不完整");
}
}
private MinioClient buildClient() {
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKey(), properties.getSecretKey())
.build();
}
private void ensureBucket(MinioClient client, String bucketName) throws Exception {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
private String buildObjectName(String bizDir, String ownerId, String fileName) {
StringBuilder builder = new StringBuilder();
builder.append(trimPath(bizDir));
if (StrUtil.isNotBlank(ownerId)) {
builder.append("/").append(trimPath(ownerId));
}
builder.append("/").append(fileName);
return builder.toString();
}
private String buildStoredFileName(String originalFileName) {
return UUID.randomUUID().toString().replace("-", "") + "-" + sanitizeFileName(originalFileName);
}
private String sanitizeFileName(String originalFileName) {
String fileName = normalizeFileName(originalFileName);
int slashIndex = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
String normalizedFileName = slashIndex >= 0 ? fileName.substring(slashIndex + 1) : fileName;
return normalizedFileName.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private String normalizeFileName(String originalFileName) {
String fileName = StrUtil.trimToEmpty(originalFileName);
if (StrUtil.isBlank(fileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件名不能为空");
}
return fileName;
}
private String trimPath(String value) {
String normalized = StrUtil.trimToEmpty(value).replace("\\", "/");
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (StrUtil.isBlank(normalized)) {
throw new BusinessException(CommonResponseEnum.FAIL, "对象目录配置不合法");
}
return normalized;
}
private String resolveExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == fileName.length() - 1) {
return "";
}
return fileName.substring(dotIndex + 1).toLowerCase(Locale.ENGLISH);
}
private String resolveContentType(String contentType, String fileName) {
if (StrUtil.isNotBlank(contentType)) {
return contentType;
}
return MediaTypeFactory.getMediaType(fileName).map(MediaType::toString)
.orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
}
@Getter
public static class StoredFile {
private final String bucketName;
private final String objectName;
private final String storedFileName;
private final String originalFileName;
private final String contentType;
private final long fileSize;
public StoredFile(String bucketName, String objectName, String storedFileName, String originalFileName,
String contentType, long fileSize) {
this.bucketName = bucketName;
this.objectName = objectName;
this.storedFileName = storedFileName;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.fileSize = fileSize;
}
}
public static class StoredObject implements AutoCloseable {
private final String bucketName;
private final String objectName;
private final GetObjectResponse inputStream;
public StoredObject(String bucketName, String objectName, GetObjectResponse inputStream) {
this.bucketName = bucketName;
this.objectName = objectName;
this.inputStream = inputStream;
}
public String getBucketName() {
return bucketName;
}
public String getObjectName() {
return objectName;
}
public GetObjectResponse getInputStream() {
return inputStream;
}
@Override
public void close() throws Exception {
if (inputStream != null) {
inputStream.close();
}
}
}
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>client-unit</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>system</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,41 @@
package com.njcn.gather.aireport.clientunit.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.gather.aireport.clientunit.mapper.ClientUnitMapper;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitUserNameVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class ClientUnitUserNameResolver {
private final ClientUnitMapper clientUnitMapper;
public Map<String, String> resolveUserNames(Collection<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
Set<String> validIds = userIds.stream()
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (validIds.isEmpty()) {
return Collections.emptyMap();
}
List<ClientUnitUserNameVO> users = clientUnitMapper.selectUserNamesByIds(validIds);
if (users == null || users.isEmpty()) {
return Collections.emptyMap();
}
return users.stream()
.filter(user -> user != null && StrUtil.isNotBlank(user.getId()) && StrUtil.isNotBlank(user.getName()))
.collect(Collectors.toMap(ClientUnitUserNameVO::getId, ClientUnitUserNameVO::getName, (first, second) -> first));
}
}

View File

@@ -0,0 +1,112 @@
package com.njcn.gather.aireport.clientunit.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.aireport.clientunit.pojo.param.ClientUnitParam;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitImportResultVO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitVO;
import com.njcn.gather.aireport.clientunit.service.IClientUnitService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 委托单位管理接口。
*/
@Api(tags = "委托单位管理")
@RestController
@RequestMapping("/clientUnit")
@RequiredArgsConstructor
public class ClientUnitController extends BaseController {
private final IClientUnitService clientUnitService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询委托单位列表")
@PostMapping("/list")
public HttpResult<Page<ClientUnitVO>> list(@RequestBody(required = false) ClientUnitParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
clientUnitService.listClientUnits(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增委托单位")
@PostMapping("/add")
public HttpResult<Boolean> add(@RequestBody @Validated ClientUnitParam.AddParam param) {
String methodDescribe = getMethodDescribe("add");
boolean result = clientUnitService.addClientUnit(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("编辑委托单位")
@PostMapping("/update")
public HttpResult<Boolean> update(@RequestBody @Validated ClientUnitParam.UpdateParam param) {
String methodDescribe = getMethodDescribe("update");
boolean result = clientUnitService.updateClientUnit(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询委托单位详情")
@ApiImplicitParam(name = "id", value = "委托单位ID", required = true)
@GetMapping("/getById")
public HttpResult<ClientUnitVO> getById(@RequestParam("id") String id) {
String methodDescribe = getMethodDescribe("getById");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
clientUnitService.getClientUnit(id), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除委托单位")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody List<String> ids) {
String methodDescribe = getMethodDescribe("delete");
boolean result = clientUnitService.deleteClientUnits(ids);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("导出委托单位")
@PostMapping("/export")
public void export(@RequestBody(required = false) ClientUnitParam.QueryParam param) {
clientUnitService.exportClientUnits(param);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("下载委托单位导入模板")
@GetMapping("/importTemplate")
public void importTemplate() {
clientUnitService.downloadImportTemplate();
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("导入委托单位")
@PostMapping(value = "/import", consumes = {"multipart/form-data"})
public HttpResult<ClientUnitImportResultVO> importExcel(@RequestParam("file") MultipartFile file) {
String methodDescribe = getMethodDescribe("importExcel");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
clientUnitService.importClientUnits(file), methodDescribe);
}
}

View File

@@ -0,0 +1,25 @@
package com.njcn.gather.aireport.clientunit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.aireport.clientunit.pojo.po.ClientUnitPO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitUserNameVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
@Mapper
public interface ClientUnitMapper extends BaseMapper<ClientUnitPO> {
@Select({
"<script>",
"select id, name from sys_user where id in",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
List<ClientUnitUserNameVO> selectUserNamesByIds(@Param("ids") Collection<String> ids);
}

View File

@@ -0,0 +1,13 @@
package com.njcn.gather.aireport.clientunit.pojo.constant;
/**
* 委托单位业务常量。
*/
public final class ClientUnitConst {
public static final int STATUS_DELETED = 0;
public static final int STATUS_NORMAL = 1;
private ClientUnitConst() {
}
}

View File

@@ -0,0 +1,55 @@
package com.njcn.gather.aireport.clientunit.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 委托单位请求参数。
*/
public class ClientUnitParam {
@Data
@EqualsAndHashCode(callSuper = true)
public static class QueryParam extends BaseParam {
@ApiModelProperty("单位名称关键字")
private String name;
@ApiModelProperty("创建开始时间yyyy-MM-dd HH:mm:ss")
private String searchBeginTime;
@ApiModelProperty("创建结束时间yyyy-MM-dd HH:mm:ss")
private String searchEndTime;
}
@Data
public static class AddParam {
@ApiModelProperty("单位名称")
@NotBlank(message = "单位名称不能为空")
@Size(max = 32, message = "单位名称不能超过32个字符")
private String name;
@ApiModelProperty("联系人")
@Size(max = 200, message = "联系人不能超过200个字符")
private String contactName;
@ApiModelProperty("联系方式")
@Size(max = 32, message = "联系方式不能超过32个字符")
private String phonenumber;
@ApiModelProperty("地址")
@Size(max = 128, message = "地址不能超过128个字符")
private String address;
}
@Data
public static class UpdateParam extends AddParam {
@ApiModelProperty("委托单位ID")
@NotBlank(message = "委托单位ID不能为空")
private String id;
}
}

View File

@@ -0,0 +1,39 @@
package com.njcn.gather.aireport.clientunit.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 委托单位记录。
*/
@Data
@TableName("ai_clientunit")
public class ClientUnitPO implements Serializable {
private static final long serialVersionUID = 2517699918054153742L;
@TableId("id")
private String id;
@TableField("name")
private String name;
@TableField("contact_name")
private String contactName;
@TableField("phonenumber")
private String phonenumber;
@TableField("address")
private String address;
@TableField("status")
private Integer status;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,26 @@
package com.njcn.gather.aireport.clientunit.pojo.vo;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
import java.io.Serializable;
/**
* 委托单位 Excel 行对象。
*/
@Data
public class ClientUnitExcelVO implements Serializable {
private static final long serialVersionUID = 8533457988358235671L;
@Excel(name = "单位名称", width = 25)
private String name;
@Excel(name = "联系人", width = 20)
private String contactName;
@Excel(name = "联系方式", width = 20)
private String phonenumber;
@Excel(name = "地址", width = 35)
private String address;
}

View File

@@ -0,0 +1,19 @@
package com.njcn.gather.aireport.clientunit.pojo.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 委托单位导入结果。
*/
@Data
public class ClientUnitImportResultVO implements Serializable {
private static final long serialVersionUID = 509902603493571405L;
private int successCount;
private int failCount;
private List<String> failReasons = new ArrayList<String>();
}

View File

@@ -0,0 +1,9 @@
package com.njcn.gather.aireport.clientunit.pojo.vo;
import lombok.Data;
@Data
public class ClientUnitUserNameVO {
private String id;
private String name;
}

View File

@@ -0,0 +1,69 @@
package com.njcn.gather.aireport.clientunit.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import org.springframework.util.StringUtils;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 委托单位展示对象。
*/
@Data
public class ClientUnitVO implements Serializable {
private static final long serialVersionUID = 8924649462626990262L;
private static final String DEFAULT_EMPTY_DISPLAY = "-";
private String id;
private String name;
private String contactName;
private String phonenumber;
private String address;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;
public String getId() {
return defaultDisplay(id);
}
public String getName() {
return defaultDisplay(name);
}
public String getContactName() {
return defaultDisplay(contactName);
}
public String getPhonenumber() {
return defaultDisplay(phonenumber);
}
public String getAddress() {
return defaultDisplay(address);
}
public String getCreateBy() {
return defaultDisplay(createBy);
}
public String getUpdateBy() {
return defaultDisplay(updateBy);
}
private String defaultDisplay(String value) {
return StringUtils.hasText(value) ? value : DEFAULT_EMPTY_DISPLAY;
}
}

View File

@@ -0,0 +1,30 @@
package com.njcn.gather.aireport.clientunit.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.aireport.clientunit.pojo.param.ClientUnitParam;
import com.njcn.gather.aireport.clientunit.pojo.po.ClientUnitPO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitImportResultVO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface IClientUnitService extends IService<ClientUnitPO> {
Page<ClientUnitVO> listClientUnits(ClientUnitParam.QueryParam param);
boolean addClientUnit(ClientUnitParam.AddParam param);
boolean updateClientUnit(ClientUnitParam.UpdateParam param);
ClientUnitVO getClientUnit(String id);
boolean deleteClientUnits(List<String> ids);
void exportClientUnits(ClientUnitParam.QueryParam param);
void downloadImportTemplate();
ClientUnitImportResultVO importClientUnits(MultipartFile file);
}

View File

@@ -0,0 +1,437 @@
package com.njcn.gather.aireport.clientunit.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.clientunit.component.ClientUnitUserNameResolver;
import com.njcn.gather.aireport.clientunit.mapper.ClientUnitMapper;
import com.njcn.gather.aireport.clientunit.pojo.constant.ClientUnitConst;
import com.njcn.gather.aireport.clientunit.pojo.param.ClientUnitParam;
import com.njcn.gather.aireport.clientunit.pojo.po.ClientUnitPO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitExcelVO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitImportResultVO;
import com.njcn.gather.aireport.clientunit.pojo.vo.ClientUnitVO;
import com.njcn.gather.aireport.clientunit.service.IClientUnitService;
import com.njcn.gather.system.util.ExportFileNameUtil;
import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.ExcelUtil;
import com.njcn.web.utils.HttpServletUtil;
import com.njcn.web.utils.RequestUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 委托单位业务实现。
*/
@Slf4j
@Service
public class ClientUnitServiceImpl extends ServiceImpl<ClientUnitMapper, ClientUnitPO> implements IClientUnitService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String[] TEMPLATE_HEADERS = new String[]{"单位名称", "联系人", "联系方式", "地址"};
private final ClientUnitUserNameResolver clientUnitUserNameResolver;
public ClientUnitServiceImpl(ClientUnitUserNameResolver clientUnitUserNameResolver) {
this.clientUnitUserNameResolver = clientUnitUserNameResolver;
}
@Override
public Page<ClientUnitVO> listClientUnits(ClientUnitParam.QueryParam param) {
ClientUnitParam.QueryParam query = param == null ? new ClientUnitParam.QueryParam() : param;
Page<ClientUnitPO> page = this.page(
new Page<ClientUnitPO>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)),
buildQueryWrapper(query));
Page<ClientUnitVO> result = new Page<ClientUnitVO>(page.getCurrent(), page.getSize(), page.getTotal());
Map<String, String> userNameMap = resolveUserNameMap(page.getRecords());
result.setRecords(page.getRecords().stream()
.map(clientUnit -> toVO(clientUnit, userNameMap))
.collect(Collectors.toList()));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addClientUnit(ClientUnitParam.AddParam param) {
NormalizedClientUnit data = normalizeParam(param);
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
ClientUnitPO clientUnit = new ClientUnitPO();
clientUnit.setId(UUID.randomUUID().toString());
clientUnit.setName(data.getName());
clientUnit.setContactName(data.getContactName());
clientUnit.setPhonenumber(data.getPhonenumber());
clientUnit.setAddress(data.getAddress());
clientUnit.setStatus(ClientUnitConst.STATUS_NORMAL);
clientUnit.setCreateBy(userId);
clientUnit.setCreateTime(now);
clientUnit.setUpdateBy(userId);
clientUnit.setUpdateTime(now);
return this.save(clientUnit);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateClientUnit(ClientUnitParam.UpdateParam param) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位ID不能为空");
}
ClientUnitPO clientUnit = requireNormal(param.getId());
NormalizedClientUnit data = normalizeParam(param);
checkDuplicate(data.getName(), clientUnit.getId());
clientUnit.setName(data.getName());
clientUnit.setContactName(data.getContactName());
clientUnit.setPhonenumber(data.getPhonenumber());
clientUnit.setAddress(data.getAddress());
clientUnit.setUpdateBy(resolveCurrentUserId());
clientUnit.setUpdateTime(LocalDateTime.now());
return this.updateById(clientUnit);
}
@Override
public ClientUnitVO getClientUnit(String id) {
ClientUnitPO clientUnit = requireNormal(id);
return toVO(clientUnit, resolveUserNameMap(Arrays.asList(clientUnit)));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteClientUnits(List<String> ids) {
List<String> validIds = normalizeIds(ids);
if (validIds.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位ID不能为空");
}
return this.lambdaUpdate()
.set(ClientUnitPO::getStatus, ClientUnitConst.STATUS_DELETED)
.set(ClientUnitPO::getUpdateBy, resolveCurrentUserId())
.set(ClientUnitPO::getUpdateTime, LocalDateTime.now())
.in(ClientUnitPO::getId, validIds)
.eq(ClientUnitPO::getStatus, ClientUnitConst.STATUS_NORMAL)
.update();
}
@Override
public void exportClientUnits(ClientUnitParam.QueryParam param) {
List<ClientUnitExcelVO> exportRows = this.list(buildQueryWrapper(param == null ? new ClientUnitParam.QueryParam() : param))
.stream()
.map(this::toExcelVO)
.collect(Collectors.toList());
ExcelUtil.exportExcel(ExportFileNameUtil.appendToday("委托单位列表.xlsx"), "委托单位列表", ClientUnitExcelVO.class, exportRows);
}
@Override
public void downloadImportTemplate() {
HttpServletResponse response = HttpServletUtil.getResponse();
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet sheet = workbook.createSheet("委托单位导入模板");
writeTemplateHeader(workbook, sheet);
String fileName = URLEncoder.encode(ExportFileNameUtil.appendToday("委托单位导入模板.xlsx"), "UTF-8");
response.reset();
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
try (ServletOutputStream outputStream = response.getOutputStream()) {
workbook.write(outputStream);
outputStream.flush();
}
} catch (IOException exception) {
log.error("下载委托单位导入模板失败", exception);
throw new BusinessException(CommonResponseEnum.FAIL, "下载委托单位导入模板失败");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public ClientUnitImportResultVO importClientUnits(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "导入文件不能为空");
}
List<ClientUnitExcelVO> rows = readImportRows(file);
ClientUnitImportResultVO result = new ClientUnitImportResultVO();
if (rows.isEmpty()) {
result.setFailCount(0);
result.setSuccessCount(0);
return result;
}
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
Set<String> fileKeys = new HashSet<String>();
List<ClientUnitPO> saveRows = new ArrayList<ClientUnitPO>();
for (int i = 0; i < rows.size(); i++) {
int rowNumber = i + 2;
try {
NormalizedClientUnit data = normalizeExcelRow(rows.get(i));
String duplicateKey = data.getName();
if (!fileKeys.add(duplicateKey)) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件内存在重复委托单位名称");
}
checkDuplicate(data.getName(), null);
ClientUnitPO clientUnit = new ClientUnitPO();
clientUnit.setId(UUID.randomUUID().toString());
clientUnit.setName(data.getName());
clientUnit.setContactName(data.getContactName());
clientUnit.setPhonenumber(data.getPhonenumber());
clientUnit.setAddress(data.getAddress());
clientUnit.setStatus(ClientUnitConst.STATUS_NORMAL);
clientUnit.setCreateBy(userId);
clientUnit.setCreateTime(now);
clientUnit.setUpdateBy(userId);
clientUnit.setUpdateTime(now);
saveRows.add(clientUnit);
result.setSuccessCount(result.getSuccessCount() + 1);
} catch (BusinessException exception) {
result.setFailCount(result.getFailCount() + 1);
result.getFailReasons().add("" + rowNumber + "行:" + exception.getMessage());
}
}
if (!saveRows.isEmpty()) {
this.saveBatch(saveRows);
}
return result;
}
private LambdaQueryWrapper<ClientUnitPO> buildQueryWrapper(ClientUnitParam.QueryParam query) {
LambdaQueryWrapper<ClientUnitPO> wrapper = new LambdaQueryWrapper<ClientUnitPO>();
wrapper.eq(ClientUnitPO::getStatus, ClientUnitConst.STATUS_NORMAL)
.like(StrUtil.isNotBlank(query.getName()), ClientUnitPO::getName, query.getName());
if (StrUtil.isNotBlank(query.getSearchBeginTime())) {
wrapper.ge(ClientUnitPO::getCreateTime, parseDateTime(query.getSearchBeginTime(), "开始时间格式不正确"));
}
if (StrUtil.isNotBlank(query.getSearchEndTime())) {
wrapper.le(ClientUnitPO::getCreateTime, parseDateTime(query.getSearchEndTime(), "结束时间格式不正确"));
}
wrapper.orderByDesc(ClientUnitPO::getCreateTime);
return wrapper;
}
private ClientUnitPO requireNormal(String id) {
String normalizedId = trimToNull(id);
if (normalizedId == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位ID不能为空");
}
ClientUnitPO clientUnit = this.lambdaQuery()
.eq(ClientUnitPO::getId, normalizedId)
.eq(ClientUnitPO::getStatus, ClientUnitConst.STATUS_NORMAL)
.one();
if (clientUnit == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位不存在或已删除");
}
return clientUnit;
}
private void checkDuplicate(String name, String excludeId) {
LambdaQueryWrapper<ClientUnitPO> wrapper = new LambdaQueryWrapper<ClientUnitPO>();
wrapper.eq(ClientUnitPO::getName, name)
.eq(ClientUnitPO::getStatus, ClientUnitConst.STATUS_NORMAL)
.ne(StrUtil.isNotBlank(excludeId), ClientUnitPO::getId, excludeId);
if (this.count(wrapper) > 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位名称已存在");
}
}
private NormalizedClientUnit normalizeParam(ClientUnitParam.AddParam param) {
if (param == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "委托单位参数不能为空");
}
return normalizeFields(param.getName(), param.getContactName(), param.getPhonenumber(), param.getAddress());
}
private NormalizedClientUnit normalizeExcelRow(ClientUnitExcelVO row) {
if (row == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "空行");
}
return normalizeFields(row.getName(), row.getContactName(), row.getPhonenumber(), row.getAddress());
}
private NormalizedClientUnit normalizeFields(String name, String contactName, String phonenumber, String address) {
NormalizedClientUnit data = new NormalizedClientUnit();
data.setName(requireText(name, "单位名称不能为空", 32, "单位名称不能超过32个字符"));
data.setContactName(optionalText(contactName, 200, "联系人不能超过200个字符"));
data.setPhonenumber(optionalText(phonenumber, 32, "联系方式不能超过32个字符"));
data.setAddress(optionalText(address, 128, "地址不能超过128个字符"));
return data;
}
private String requireText(String value, String blankMessage, int maxLength, String lengthMessage) {
String text = trimToNull(value);
if (text == null) {
throw new BusinessException(CommonResponseEnum.FAIL, blankMessage);
}
if (text.length() > maxLength) {
throw new BusinessException(CommonResponseEnum.FAIL, lengthMessage);
}
return text;
}
private String optionalText(String value, int maxLength, String lengthMessage) {
String text = trimToNull(value);
if (text != null && text.length() > maxLength) {
throw new BusinessException(CommonResponseEnum.FAIL, lengthMessage);
}
return text;
}
private List<ClientUnitExcelVO> readImportRows(MultipartFile file) {
try (InputStream inputStream = file.getInputStream();
XSSFWorkbook workbook = new XSSFWorkbook(inputStream)) {
XSSFSheet sheet = workbook.getNumberOfSheets() == 0 ? null : workbook.getSheetAt(0);
if (sheet == null || sheet.getLastRowNum() < 1) {
return Collections.emptyList();
}
List<ClientUnitExcelVO> rows = new ArrayList<ClientUnitExcelVO>();
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (isEmptyRow(row)) {
continue;
}
ClientUnitExcelVO excelVO = new ClientUnitExcelVO();
excelVO.setName(readCell(row, 0));
excelVO.setContactName(readCell(row, 1));
excelVO.setPhonenumber(readCell(row, 2));
excelVO.setAddress(readCell(row, 3));
rows.add(excelVO);
}
return rows;
} catch (IOException exception) {
log.error("读取委托单位导入文件失败", exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取导入文件失败");
}
}
private void writeTemplateHeader(XSSFWorkbook workbook, XSSFSheet sheet) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
Row row = sheet.createRow(0);
for (int i = 0; i < TEMPLATE_HEADERS.length; i++) {
Cell cell = row.createCell(i);
cell.setCellValue(TEMPLATE_HEADERS[i]);
cell.setCellStyle(style);
sheet.setColumnWidth(i, 25 * 256);
}
}
private boolean isEmptyRow(Row row) {
if (row == null) {
return true;
}
for (int i = 0; i < TEMPLATE_HEADERS.length; i++) {
if (trimToNull(readCell(row, i)) != null) {
return false;
}
}
return true;
}
private String readCell(Row row, int index) {
Cell cell = row.getCell(index);
if (cell == null) {
return null;
}
return trimToNull(new DataFormatter().formatCellValue(cell));
}
private List<String> normalizeIds(List<String> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<String> result = new ArrayList<String>();
for (String id : ids) {
String normalizedId = trimToNull(id);
if (normalizedId != null && !result.contains(normalizedId)) {
result.add(normalizedId);
}
}
return result;
}
private LocalDateTime parseDateTime(String value, String errorMessage) {
try {
return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, errorMessage);
}
}
private ClientUnitVO toVO(ClientUnitPO clientUnit, Map<String, String> userNameMap) {
ClientUnitVO vo = new ClientUnitVO();
BeanUtil.copyProperties(clientUnit, vo);
vo.setCreateBy(resolveUserName(clientUnit.getCreateBy(), userNameMap));
vo.setUpdateBy(resolveUserName(clientUnit.getUpdateBy(), userNameMap));
return vo;
}
private ClientUnitExcelVO toExcelVO(ClientUnitPO clientUnit) {
ClientUnitExcelVO vo = new ClientUnitExcelVO();
BeanUtil.copyProperties(clientUnit, vo);
return vo;
}
private Map<String, String> resolveUserNameMap(List<ClientUnitPO> clientUnits) {
List<String> userIds = clientUnits.stream()
.flatMap(clientUnit -> Arrays.asList(clientUnit.getCreateBy(), clientUnit.getUpdateBy()).stream())
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
return clientUnitUserNameResolver.resolveUserNames(userIds);
}
private String resolveUserName(String userId, Map<String, String> userNameMap) {
if (StrUtil.isBlank(userId)) {
return null;
}
String userName = userNameMap == null ? null : userNameMap.get(userId);
return StrUtil.isNotBlank(userName) ? userName : userId;
}
private String resolveCurrentUserId() {
String userId = RequestUtil.getUserId();
return StrUtil.isBlank(userId) ? null : userId;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
@lombok.Data
private static class NormalizedClientUnit {
private String name;
private String contactName;
private String phonenumber;
private String address;
}
}

27
ai-report/pom.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>CN_Tool</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ai-report</artifactId>
<packaging>pom</packaging>
<name>ai-report</name>
<description>AI report capability aggregator.</description>
<modules>
<module>ai-report-common</module>
<module>report-model</module>
<module>client-unit</module>
<module>test-device</module>
<module>test-report</module>
<module>report-approval</module>
</modules>
</project>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>report-approval</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>report-model</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>test-report</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,41 @@
package com.njcn.gather.aireport.reportapproval.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class ReportApprovalUserNameResolver {
private final ReportApprovalMapper reportApprovalMapper;
public Map<String, String> resolveUserNames(Collection<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
Set<String> validIds = userIds.stream()
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (validIds.isEmpty()) {
return Collections.emptyMap();
}
List<ReportModelUserNameVO> users = reportApprovalMapper.selectUserNamesByIds(validIds);
if (users == null || users.isEmpty()) {
return Collections.emptyMap();
}
return users.stream()
.filter(user -> user != null && StrUtil.isNotBlank(user.getId()) && StrUtil.isNotBlank(user.getName()))
.collect(Collectors.toMap(ReportModelUserNameVO::getId, ReportModelUserNameVO::getName, (first, second) -> first));
}
}

View File

@@ -0,0 +1,71 @@
package com.njcn.gather.aireport.reportapproval.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalLogVO;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalPendingVO;
import com.njcn.gather.aireport.reportapproval.service.ReportApprovalService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "报告审批管理")
@RestController
@RequestMapping("/api/report-approval")
@RequiredArgsConstructor
public class ReportApprovalController extends BaseController {
private final ReportApprovalService reportApprovalService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询待审报告分页")
@GetMapping("/pending-page")
public HttpResult<Page<ReportApprovalPendingVO>> pendingPage(@ModelAttribute ReportApprovalParam.PendingQueryParam param) {
String methodDescribe = getMethodDescribe("pendingPage");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportApprovalService.pendingPage(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("审批通过")
@PostMapping("/pass")
public HttpResult<Boolean> pass(@RequestBody @Validated ReportApprovalParam.AuditParam param) {
String methodDescribe = getMethodDescribe("pass");
boolean result = reportApprovalService.pass(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("审批退回")
@PostMapping("/reject")
public HttpResult<Boolean> reject(@RequestBody @Validated ReportApprovalParam.AuditParam param) {
String methodDescribe = getMethodDescribe("reject");
boolean result = reportApprovalService.reject(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询审批日志分页")
@GetMapping("/log-page")
public HttpResult<Page<ReportApprovalLogVO>> logPage(@ModelAttribute ReportApprovalParam.LogQueryParam param) {
String methodDescribe = getMethodDescribe("logPage");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportApprovalService.logPage(param), methodDescribe);
}
}

View File

@@ -0,0 +1,85 @@
package com.njcn.gather.aireport.reportapproval.handler;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportapproval.component.ReportApprovalUserNameResolver;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportapproval.pojo.constant.ReportApprovalConst;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.po.ReportApprovalLogPO;
import com.njcn.web.utils.RequestUtil;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
public abstract class AbstractReportApprovalHandler implements ReportApprovalHandler {
private final ReportApprovalMapper reportApprovalMapper;
private final ReportApprovalUserNameResolver reportApprovalUserNameResolver;
protected AbstractReportApprovalHandler(ReportApprovalMapper reportApprovalMapper,
ReportApprovalUserNameResolver reportApprovalUserNameResolver) {
this.reportApprovalMapper = reportApprovalMapper;
this.reportApprovalUserNameResolver = reportApprovalUserNameResolver;
}
@Override
public boolean audit(ReportApprovalParam.AuditParam param, boolean passed) {
if (param == null || StrUtil.isBlank(param.getReportId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "审批参数不能为空");
}
String reason = trimToNull(param.getApprovalReason());
if (reason == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "审批原因不能为空");
}
return doAudit(param.getReportId(), reason, passed);
}
protected abstract boolean doAudit(String reportId, String reason, boolean passed);
protected void saveLog(String reportId, String reportName, String beforeState, String afterState,
String reason, String approverId, LocalDateTime approvalTime) {
ReportApprovalLogPO log = new ReportApprovalLogPO();
log.setId(UUID.randomUUID().toString());
log.setReportType(getReportType());
log.setReportId(reportId);
log.setReportName(reportName);
log.setApprovalResult(afterState);
log.setApprovalReason(reason);
log.setBeforeState(beforeState);
log.setAfterState(afterState);
log.setApproverId(approverId);
log.setApproverName(resolveUserName(approverId));
log.setApprovalTime(approvalTime);
log.setStatus(ReportApprovalConst.STATUS_NORMAL);
log.setCreateBy(approverId);
log.setCreateTime(approvalTime);
log.setUpdateBy(approverId);
log.setUpdateTime(approvalTime);
reportApprovalMapper.insert(log);
}
protected String resolveCurrentUserId() {
String userId = RequestUtil.getUserId();
return StrUtil.isBlank(userId) ? null : userId;
}
private String resolveUserName(String userId) {
if (StrUtil.isBlank(userId)) {
return null;
}
Map<String, String> userNameMap = reportApprovalUserNameResolver.resolveUserNames(Arrays.asList(userId));
return userNameMap.containsKey(userId) ? userNameMap.get(userId) : userId;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.aireport.reportapproval.handler;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
public interface ReportApprovalHandler {
String getReportType();
boolean audit(ReportApprovalParam.AuditParam param, boolean passed);
}

View File

@@ -0,0 +1,35 @@
package com.njcn.gather.aireport.reportapproval.handler;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class ReportApprovalHandlerRegistry {
private final Map<String, ReportApprovalHandler> handlerMap = new LinkedHashMap<String, ReportApprovalHandler>();
public ReportApprovalHandlerRegistry(List<ReportApprovalHandler> handlers) {
if (handlers == null || handlers.isEmpty()) {
return;
}
for (ReportApprovalHandler handler : handlers) {
if (handler != null && StrUtil.isNotBlank(handler.getReportType())) {
handlerMap.put(handler.getReportType(), handler);
}
}
}
public ReportApprovalHandler requireHandler(String reportType) {
ReportApprovalHandler handler = handlerMap.get(reportType);
if (handler == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "不支持的报告类型");
}
return handler;
}
}

View File

@@ -0,0 +1,58 @@
package com.njcn.gather.aireport.reportapproval.handler;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportapproval.component.ReportApprovalUserNameResolver;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportapproval.pojo.constant.ReportApprovalConst;
import com.njcn.gather.aireport.reportmodel.pojo.constant.ReportModelConst;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.service.ReportModelService;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class ReportModelApprovalHandler extends AbstractReportApprovalHandler {
private final ReportModelService reportModelService;
public ReportModelApprovalHandler(ReportApprovalMapper reportApprovalMapper,
ReportApprovalUserNameResolver reportApprovalUserNameResolver,
ReportModelService reportModelService) {
super(reportApprovalMapper, reportApprovalUserNameResolver);
this.reportModelService = reportModelService;
}
@Override
public String getReportType() {
return ReportApprovalConst.REPORT_TYPE_REPORT_MODEL;
}
@Override
protected boolean doAudit(String reportId, String reason, boolean passed) {
ReportModelPO reportModel = reportModelService.requireNormal(reportId);
if (!ReportModelConst.STATE_AUDITING.equals(reportModel.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅待审状态的报告模板允许审批");
}
String userId = resolveCurrentUserId();
LocalDateTime now = LocalDateTime.now();
String targetState = passed ? ReportModelConst.STATE_APPROVED : ReportModelConst.STATE_REJECTED;
boolean updated = reportModelService.lambdaUpdate()
.set(ReportModelPO::getState, targetState)
.set(ReportModelPO::getCheckId, userId)
.set(ReportModelPO::getCheckTime, now)
.set(ReportModelPO::getCheckResult, reason)
.set(ReportModelPO::getUpdateBy, userId)
.set(ReportModelPO::getUpdateTime, now)
.eq(ReportModelPO::getId, reportId)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.eq(ReportModelPO::getState, ReportModelConst.STATE_AUDITING)
.update();
if (!updated) {
throw new BusinessException(CommonResponseEnum.FAIL, "报告模板状态已变更,请刷新后重试");
}
saveLog(reportModel.getId(), reportModel.getName(), ReportModelConst.STATE_AUDITING, targetState, reason, userId, now);
return true;
}
}

View File

@@ -0,0 +1,59 @@
package com.njcn.gather.aireport.reportapproval.handler;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportapproval.component.ReportApprovalUserNameResolver;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportapproval.pojo.constant.ReportApprovalConst;
import com.njcn.gather.aireport.testreport.pojo.constant.TestReportConst;
import com.njcn.gather.aireport.testreport.pojo.po.TestReportPO;
import com.njcn.gather.aireport.testreport.service.TestReportService;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Component
public class TestReportApprovalHandler extends AbstractReportApprovalHandler {
private final TestReportService testReportService;
public TestReportApprovalHandler(ReportApprovalMapper reportApprovalMapper,
ReportApprovalUserNameResolver reportApprovalUserNameResolver,
TestReportService testReportService) {
super(reportApprovalMapper, reportApprovalUserNameResolver);
this.testReportService = testReportService;
}
@Override
public String getReportType() {
return ReportApprovalConst.REPORT_TYPE_TEST_REPORT;
}
@Override
protected boolean doAudit(String reportId, String reason, boolean passed) {
TestReportPO testReport = testReportService.requireNormal(reportId);
if (!TestReportConst.STATE_AUDITING.equals(testReport.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅待审状态的普测报告允许审批");
}
String userId = resolveCurrentUserId();
LocalDateTime now = LocalDateTime.now();
String targetState = passed ? TestReportConst.STATE_APPROVED : TestReportConst.STATE_REJECTED;
boolean updated = testReportService.lambdaUpdate()
.set(TestReportPO::getState, targetState)
.set(TestReportPO::getCheckId, userId)
.set(TestReportPO::getCheckTime, LocalDate.now())
.set(TestReportPO::getCheckResult, reason)
.set(TestReportPO::getUpdateBy, userId)
.set(TestReportPO::getUpdateTime, now)
.eq(TestReportPO::getId, reportId)
.eq(TestReportPO::getStatus, TestReportConst.STATUS_NORMAL)
.eq(TestReportPO::getState, TestReportConst.STATE_AUDITING)
.update();
if (!updated) {
throw new BusinessException(CommonResponseEnum.FAIL, "普测报告状态已变更,请刷新后重试");
}
saveLog(testReport.getId(), testReport.getNo(), TestReportConst.STATE_AUDITING, targetState, reason, userId, now);
return true;
}
}

View File

@@ -0,0 +1,95 @@
package com.njcn.gather.aireport.reportapproval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.po.ReportApprovalLogPO;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalLogVO;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalPendingVO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
@Mapper
public interface ReportApprovalMapper extends BaseMapper<ReportApprovalLogPO> {
@Select({
"<script>",
"select id, name from sys_user where id in",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
List<ReportModelUserNameVO> selectUserNamesByIds(@Param("ids") Collection<String> ids);
@Select({
"<script>",
"select count(1) from (",
"<if test='param.reportType == null or param.reportType == \"\" or param.reportType == \"REPORT_MODEL\"'>",
"select model.id",
"from ai_reportmodel model",
"where model.status = 1 and model.state = '02'",
"</if>",
"<if test='param.reportType == null or param.reportType == \"\"'>",
"union all",
"</if>",
"<if test='param.reportType == null or param.reportType == \"\" or param.reportType == \"TEST_REPORT\"'>",
"select report.id",
"from ai_testreport report",
"where report.status = 1 and report.state = '02'",
"</if>",
") pending",
"</script>"
})
long countPendingReports(@Param("param") ReportApprovalParam.PendingQueryParam param);
@Select({
"<script>",
"select * from (",
"<if test='param.reportType == null or param.reportType == \"\" or param.reportType == \"REPORT_MODEL\"'>",
"select 'REPORT_MODEL' as reportType, model.id as reportId, model.name as reportName, model.state,",
"model.update_by as submitterId, user.name as submitterName, model.update_time as submitTime",
"from ai_reportmodel model",
"left join sys_user user on user.id = model.update_by",
"where model.status = 1 and model.state = '02'",
"</if>",
"<if test='param.reportType == null or param.reportType == \"\"'>",
"union all",
"</if>",
"<if test='param.reportType == null or param.reportType == \"\" or param.reportType == \"TEST_REPORT\"'>",
"select 'TEST_REPORT' as reportType, report.id as reportId, report.no as reportName, report.state,",
"report.update_by as submitterId, user.name as submitterName, report.update_time as submitTime",
"from ai_testreport report",
"left join sys_user user on user.id = report.update_by",
"where report.status = 1 and report.state = '02'",
"</if>",
") pending",
"order by pending.submitTime desc",
"limit #{offset}, #{size}",
"</script>"
})
List<ReportApprovalPendingVO> selectPendingPage(@Param("offset") long offset,
@Param("size") long size,
@Param("param") ReportApprovalParam.PendingQueryParam param);
@Select({
"<script>",
"select id, report_type as reportType, report_id as reportId, report_name as reportName,",
"approval_result as approvalResult, approval_reason as approvalReason, before_state as beforeState,",
"after_state as afterState, approver_name as approverName, approval_time as approvalTime",
"from ai_report_approval_log",
"where status = 1",
"<if test='param.reportType != null and param.reportType != \"\"'>",
"and report_type = #{param.reportType}",
"</if>",
"order by approval_time desc, create_time desc",
"</script>"
})
Page<ReportApprovalLogVO> selectLogPage(Page<ReportApprovalLogVO> page,
@Param("param") ReportApprovalParam.LogQueryParam param);
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.aireport.reportapproval.pojo.constant;
public final class ReportApprovalConst {
public static final String REPORT_TYPE_REPORT_MODEL = "REPORT_MODEL";
public static final String REPORT_TYPE_TEST_REPORT = "TEST_REPORT";
public static final Integer STATUS_NORMAL = 1;
private ReportApprovalConst() {
}
}

View File

@@ -0,0 +1,42 @@
package com.njcn.gather.aireport.reportapproval.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
public class ReportApprovalParam {
@Data
public static class AuditParam {
@ApiModelProperty("报告类型")
@NotBlank(message = "报告类型不能为空")
private String reportType;
@ApiModelProperty("报告ID")
@NotBlank(message = "报告ID不能为空")
private String reportId;
@ApiModelProperty("审批原因")
@NotBlank(message = "审批原因不能为空")
@Size(max = 128, message = "审批原因不能超过128个字符")
private String approvalReason;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class PendingQueryParam extends BaseParam {
@ApiModelProperty("报告类型")
private String reportType;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class LogQueryParam extends BaseParam {
@ApiModelProperty("报告类型")
private String reportType;
}
}

View File

@@ -0,0 +1,48 @@
package com.njcn.gather.aireport.reportapproval.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("ai_report_approval_log")
public class ReportApprovalLogPO implements Serializable {
private static final long serialVersionUID = 1900859716151337132L;
@TableId("id")
private String id;
@TableField("report_type")
private String reportType;
@TableField("report_id")
private String reportId;
@TableField("report_name")
private String reportName;
@TableField("approval_result")
private String approvalResult;
@TableField("approval_reason")
private String approvalReason;
@TableField("before_state")
private String beforeState;
@TableField("after_state")
private String afterState;
@TableField("approver_id")
private String approverId;
@TableField("approver_name")
private String approverName;
@TableField("approval_time")
private LocalDateTime approvalTime;
@TableField("status")
private Integer status;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,38 @@
package com.njcn.gather.aireport.reportapproval.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ReportApprovalLogVO {
@ApiModelProperty("日志ID")
private String id;
@ApiModelProperty("报告类型")
private String reportType;
@ApiModelProperty("报告ID")
private String reportId;
@ApiModelProperty("报告名称")
private String reportName;
@ApiModelProperty("审批结果")
private String approvalResult;
@ApiModelProperty("审批原因")
private String approvalReason;
@ApiModelProperty("审批前状态")
private String beforeState;
@ApiModelProperty("审批后状态")
private String afterState;
@ApiModelProperty("审批人")
private String approverName;
@ApiModelProperty("审批时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime approvalTime;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.aireport.reportapproval.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ReportApprovalPendingVO {
@ApiModelProperty("报告类型")
private String reportType;
@ApiModelProperty("报告ID")
private String reportId;
@ApiModelProperty("报告名称")
private String reportName;
@ApiModelProperty("报告状态")
private String state;
@ApiModelProperty("提交人ID")
private String submitterId;
@ApiModelProperty("提交人")
private String submitterName;
@ApiModelProperty("提交时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime submitTime;
}

View File

@@ -0,0 +1,17 @@
package com.njcn.gather.aireport.reportapproval.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalLogVO;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalPendingVO;
public interface ReportApprovalService {
boolean pass(ReportApprovalParam.AuditParam param);
boolean reject(ReportApprovalParam.AuditParam param);
Page<ReportApprovalPendingVO> pendingPage(ReportApprovalParam.PendingQueryParam param);
Page<ReportApprovalLogVO> logPage(ReportApprovalParam.LogQueryParam param);
}

View File

@@ -0,0 +1,77 @@
package com.njcn.gather.aireport.reportapproval.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportapproval.handler.ReportApprovalHandler;
import com.njcn.gather.aireport.reportapproval.handler.ReportApprovalHandlerRegistry;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalLogVO;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalPendingVO;
import com.njcn.gather.aireport.reportapproval.service.ReportApprovalService;
import com.njcn.web.factory.PageFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
@Service
public class ReportApprovalServiceImpl implements ReportApprovalService {
private final ReportApprovalMapper reportApprovalMapper;
private final ReportApprovalHandlerRegistry reportApprovalHandlerRegistry;
public ReportApprovalServiceImpl(ReportApprovalMapper reportApprovalMapper,
ReportApprovalHandlerRegistry reportApprovalHandlerRegistry) {
this.reportApprovalMapper = reportApprovalMapper;
this.reportApprovalHandlerRegistry = reportApprovalHandlerRegistry;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean pass(ReportApprovalParam.AuditParam param) {
return audit(param, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reject(ReportApprovalParam.AuditParam param) {
return audit(param, false);
}
@Override
public Page<ReportApprovalPendingVO> pendingPage(ReportApprovalParam.PendingQueryParam param) {
ReportApprovalParam.PendingQueryParam query = param == null ? new ReportApprovalParam.PendingQueryParam() : param;
if (StrUtil.isNotBlank(query.getReportType())) {
reportApprovalHandlerRegistry.requireHandler(query.getReportType());
}
long current = PageFactory.getPageNum(query);
long size = PageFactory.getPageSize(query);
long offset = Math.max(0L, (current - 1L) * size);
long total = reportApprovalMapper.countPendingReports(query);
List<ReportApprovalPendingVO> records = total <= 0
? Collections.<ReportApprovalPendingVO>emptyList()
: reportApprovalMapper.selectPendingPage(offset, size, query);
Page<ReportApprovalPendingVO> page = new Page<ReportApprovalPendingVO>(current, size, total);
page.setRecords(records);
return page;
}
@Override
public Page<ReportApprovalLogVO> logPage(ReportApprovalParam.LogQueryParam param) {
ReportApprovalParam.LogQueryParam query = param == null ? new ReportApprovalParam.LogQueryParam() : param;
return reportApprovalMapper.selectLogPage(
new Page<ReportApprovalLogVO>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), query);
}
private boolean audit(ReportApprovalParam.AuditParam param, boolean passed) {
if (param == null || StrUtil.isBlank(param.getReportType()) || StrUtil.isBlank(param.getReportId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "审批参数不能为空");
}
ReportApprovalHandler handler = reportApprovalHandlerRegistry.requireHandler(param.getReportType());
return handler.audit(param, passed);
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.gather.aireport.reportapproval.pojo.vo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
public class ReportApprovalVOTimeFormatTest {
@Test
public void pendingAndLogTimeJsonUseStandardTwentyFourHourFormat() throws Exception {
ReportApprovalPendingVO pendingVO = new ReportApprovalPendingVO();
pendingVO.setSubmitTime(LocalDateTime.of(2026, 6, 29, 10, 0, 0));
ReportApprovalLogVO logVO = new ReportApprovalLogVO();
logVO.setApprovalTime(LocalDateTime.of(2026, 6, 29, 18, 30, 45));
ObjectMapper objectMapper = new ObjectMapper();
String pendingJson = objectMapper.writeValueAsString(pendingVO);
String logJson = objectMapper.writeValueAsString(logVO);
Assert.assertTrue(pendingJson.contains("\"submitTime\":\"2026-06-29 10:00:00\""));
Assert.assertFalse(pendingJson.contains("2026-06-29T10"));
Assert.assertTrue(logJson.contains("\"approvalTime\":\"2026-06-29 18:30:45\""));
Assert.assertFalse(logJson.contains("2026-06-29T18"));
}
}

View File

@@ -0,0 +1,75 @@
package com.njcn.gather.aireport.reportapproval.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.aireport.reportapproval.handler.ReportApprovalHandler;
import com.njcn.gather.aireport.reportapproval.handler.ReportApprovalHandlerRegistry;
import com.njcn.gather.aireport.reportapproval.mapper.ReportApprovalMapper;
import com.njcn.gather.aireport.reportapproval.pojo.constant.ReportApprovalConst;
import com.njcn.gather.aireport.reportapproval.pojo.param.ReportApprovalParam;
import com.njcn.gather.aireport.reportapproval.pojo.vo.ReportApprovalPendingVO;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ReportApprovalServiceImplTest {
@Test
public void pendingPageShouldUseDatabasePagingResult() {
ReportApprovalMapper mapper = mock(ReportApprovalMapper.class);
ReportApprovalHandlerRegistry registry = new ReportApprovalHandlerRegistry(Collections.<ReportApprovalHandler>emptyList());
ReportApprovalServiceImpl service = new ReportApprovalServiceImpl(mapper, registry);
ReportApprovalParam.PendingQueryParam param = new ReportApprovalParam.PendingQueryParam();
param.setPageNum(2);
param.setPageSize(5);
ReportApprovalPendingVO record = new ReportApprovalPendingVO();
record.setReportType(ReportApprovalConst.REPORT_TYPE_REPORT_MODEL);
record.setReportId("model-001");
record.setReportName("模板A");
record.setSubmitterId("user-001");
record.setSubmitterName("张三");
record.setSubmitTime(LocalDateTime.of(2026, 6, 29, 10, 0, 0));
List<ReportApprovalPendingVO> records = Arrays.asList(record);
when(mapper.countPendingReports(param)).thenReturn(11L);
when(mapper.selectPendingPage(5L, 5L, param)).thenReturn(records);
Page<ReportApprovalPendingVO> page = service.pendingPage(param);
Assert.assertEquals(2L, page.getCurrent());
Assert.assertEquals(5L, page.getSize());
Assert.assertEquals(11L, page.getTotal());
Assert.assertEquals(1, page.getRecords().size());
Assert.assertEquals("user-001", page.getRecords().get(0).getSubmitterId());
Assert.assertEquals("张三", page.getRecords().get(0).getSubmitterName());
verify(mapper).countPendingReports(param);
verify(mapper).selectPendingPage(5L, 5L, param);
}
@Test
public void passShouldDelegateToMatchedHandler() {
ReportApprovalMapper mapper = mock(ReportApprovalMapper.class);
ReportApprovalHandler handler = mock(ReportApprovalHandler.class);
when(handler.getReportType()).thenReturn(ReportApprovalConst.REPORT_TYPE_TEST_REPORT);
when(handler.audit(org.mockito.ArgumentMatchers.any(ReportApprovalParam.AuditParam.class), eq(true))).thenReturn(true);
ReportApprovalHandlerRegistry registry = new ReportApprovalHandlerRegistry(Arrays.asList(handler));
ReportApprovalServiceImpl service = new ReportApprovalServiceImpl(mapper, registry);
ReportApprovalParam.AuditParam param = new ReportApprovalParam.AuditParam();
param.setReportType(ReportApprovalConst.REPORT_TYPE_TEST_REPORT);
param.setReportId("report-001");
param.setApprovalReason("通过");
boolean result = service.pass(param);
Assert.assertTrue(result);
verify(handler).audit(param, true);
}
}

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>report-model</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report-common</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>comservice</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,101 @@
package com.njcn.gather.aireport.reportmodel.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.common.constant.AiReportFileConst;
import com.njcn.gather.aireport.common.constant.AiReportFileExtensionConst;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.util.Locale;
/**
* 报告模板文件 MinIO 存储服务。
*/
@Component
@RequiredArgsConstructor
public class ReportModelFileStorageService {
private final AiReportMinioStorageService minioStorageService;
public StoredFile save(MultipartFile templateFile) {
validateTemplateFile(templateFile);
AiReportMinioStorageService.StoredFile storedFile = minioStorageService.saveMultipartFile(
AiReportFileConst.BIZ_DIR_REPORT_MODEL, null, templateFile, false);
return new StoredFile(storedFile.getObjectName(), storedFile.getOriginalFileName());
}
public AiReportMinioStorageService.StoredObject getObject(String storedPath) {
return minioStorageService.getObject(storedPath);
}
public byte[] readBytes(String storedPath) {
return minioStorageService.readBytes(storedPath);
}
public Path downloadToTempFile(String storedPath, String fileName) {
return minioStorageService.downloadToTempFile(storedPath, fileName);
}
public void deleteQuietly(String storedPath) {
minioStorageService.deleteQuietly(storedPath);
}
public void deleteTempFileQuietly(Path tempFile) {
minioStorageService.deleteTempFileQuietly(tempFile);
}
public String resolveDownloadFileName(String storedPath) {
if (StrUtil.isBlank(storedPath)) {
return "report-model.docx";
}
int slashIndex = storedPath.lastIndexOf('/');
String fileName = slashIndex >= 0 ? storedPath.substring(slashIndex + 1) : storedPath;
int separatorIndex = fileName.indexOf('-');
return separatorIndex >= 0 && separatorIndex + 1 < fileName.length() ? fileName.substring(separatorIndex + 1) : fileName;
}
private void validateTemplateFile(MultipartFile templateFile) {
if (templateFile == null || templateFile.isEmpty()) {
throw new IllegalArgumentException("模板文件不能为空");
}
String fileName = templateFile.getOriginalFilename();
if (StrUtil.isBlank(fileName)) {
throw new IllegalArgumentException("模板文件名不能为空");
}
String extension = resolveExtension(fileName);
if (!AiReportFileExtensionConst.WORD_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("模板文件仅支持.docx格式");
}
}
private String resolveExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == fileName.length() - 1) {
return "";
}
return fileName.substring(dotIndex + 1).toLowerCase(Locale.ENGLISH);
}
public static class StoredFile {
private final String path;
private final String fileName;
public StoredFile(String path, String fileName) {
this.path = path;
this.fileName = fileName;
}
public String getPath() {
return path;
}
public String getFileName() {
return fileName;
}
}
}

View File

@@ -0,0 +1,41 @@
package com.njcn.gather.aireport.reportmodel.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class ReportModelUserNameResolver {
private final ReportModelMapper reportModelMapper;
public Map<String, String> resolveUserNames(Collection<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
Set<String> validIds = userIds.stream()
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (validIds.isEmpty()) {
return Collections.emptyMap();
}
List<ReportModelUserNameVO> users = reportModelMapper.selectUserNamesByIds(validIds);
if (users == null || users.isEmpty()) {
return Collections.emptyMap();
}
return users.stream()
.filter(user -> user != null && StrUtil.isNotBlank(user.getId()) && StrUtil.isNotBlank(user.getName()))
.collect(Collectors.toMap(ReportModelUserNameVO::getId, ReportModelUserNameVO::getName, (first, second) -> first));
}
}

View File

@@ -0,0 +1,171 @@
package com.njcn.gather.aireport.reportmodel.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import com.njcn.gather.aireport.reportmodel.service.ReportModelService;
import com.njcn.gather.comservice.filepreview.FilePreviewService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
/**
* Report model management endpoints.
*/
@Api(tags = "报告模板管理")
@RestController
@RequestMapping("/api/report-model")
@RequiredArgsConstructor
public class ReportModelController extends BaseController {
private final ReportModelService reportModelService;
private final ReportModelFileStorageService reportModelFileStorageService;
private final FilePreviewService filePreviewService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询报告模板列表")
@PostMapping("/list")
public HttpResult<Page<ReportModelVO>> list(@RequestBody(required = false) ReportModelParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportModelService.listReportModels(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增报告模板")
@PostMapping(value = "/add", consumes = {"multipart/form-data"})
public HttpResult<Boolean> add(@RequestPart("request") @Validated ReportModelParam.AddParam param,
@RequestPart("templateFile") MultipartFile templateFile) {
String methodDescribe = getMethodDescribe("add");
boolean result = reportModelService.addReportModel(param, templateFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("编辑报告模板")
@PostMapping(value = "/update", consumes = {"multipart/form-data"})
public HttpResult<Boolean> update(@RequestPart("request") @Validated ReportModelParam.UpdateParam param,
@RequestPart(value = "templateFile", required = false) MultipartFile templateFile) {
String methodDescribe = getMethodDescribe("update");
boolean result = reportModelService.updateReportModel(param, templateFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除报告模板")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody List<String> ids) {
String methodDescribe = getMethodDescribe("delete");
boolean result = reportModelService.deleteReportModels(ids);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询报告模板详情")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}")
public HttpResult<ReportModelVO> detail(@PathVariable("id") String id) {
String methodDescribe = getMethodDescribe("detail");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportModelService.getReportModel(id), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("下载报告模板文件")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}/download")
public ResponseEntity<InputStreamResource> download(@PathVariable("id") String id) {
ReportModelPO model = reportModelService.requireNormal(id);
try {
String fileName = resolveFileName(model);
String encodedFileName = encodeFileName(fileName);
AiReportMinioStorageService.StoredObject storedObject = reportModelFileStorageService.getObject(model.getPath());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFileName)
.body(new InputStreamResource(storedObject.getInputStream()));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取模板文件失败");
}
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("预览报告模板文件")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}/preview")
public ResponseEntity<byte[]> preview(@PathVariable("id") String id) {
ReportModelPO model = reportModelService.requireNormal(id);
String fileName = resolveFileName(model);
Path tempFile = reportModelFileStorageService.downloadToTempFile(model.getPath(), fileName);
try {
FilePreviewService.PreviewContent previewContent = filePreviewService.preview(tempFile, fileName);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename*=UTF-8''" + encodeFileName(fileName))
.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.contentType(MediaType.parseMediaType(previewContent.getContentType()))
.body(previewContent.getContent());
} finally {
reportModelFileStorageService.deleteTempFileQuietly(tempFile);
}
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("提交报告模板审核")
@PostMapping("/submit-audit")
public HttpResult<Boolean> submitAudit(@RequestBody @Validated ReportModelParam.SubmitAuditParam param) {
String methodDescribe = getMethodDescribe("submitAudit");
boolean result = reportModelService.submitAudit(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
private String encodeFileName(String fileName) {
try {
return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "编码模板文件名失败");
}
}
private String resolveFileName(ReportModelPO model) {
String fileName = model.getFileName();
if (fileName == null || fileName.trim().isEmpty()) {
return reportModelFileStorageService.resolveDownloadFileName(model.getPath());
}
return fileName;
}
}

View File

@@ -0,0 +1,25 @@
package com.njcn.gather.aireport.reportmodel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
@Mapper
public interface ReportModelMapper extends BaseMapper<ReportModelPO> {
@Select({
"<script>",
"select id, name from sys_user where id in",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
List<ReportModelUserNameVO> selectUserNamesByIds(@Param("ids") Collection<String> ids);
}

View File

@@ -0,0 +1,19 @@
package com.njcn.gather.aireport.reportmodel.pojo.constant;
/**
* Report model state constants.
*/
public final class ReportModelConst {
private ReportModelConst() {
}
public static final String STATE_EDITING = "01";
public static final String STATE_AUDITING = "02";
public static final String STATE_APPROVED = "03";
public static final String STATE_REJECTED = "04";
public static final String STATE_DELETED = "05";
public static final Integer STATUS_DELETED = 0;
public static final Integer STATUS_NORMAL = 1;
}

View File

@@ -0,0 +1,58 @@
package com.njcn.gather.aireport.reportmodel.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* Report model request parameters.
*/
public class ReportModelParam {
@Data
@EqualsAndHashCode(callSuper = true)
public static class QueryParam extends BaseParam {
@ApiModelProperty("Template name keyword")
private String name;
@ApiModelProperty("Template state")
private String state;
@ApiModelProperty("Create time begin, yyyy-MM-dd HH:mm:ss")
private String searchBeginTime;
@ApiModelProperty("Create time end, yyyy-MM-dd HH:mm:ss")
private String searchEndTime;
}
@Data
public static class AddParam {
@ApiModelProperty("Template name")
@NotBlank(message = "模板名称不能为空")
@Size(max = 32, message = "模板名称不能超过32个字符")
private String name;
}
@Data
public static class UpdateParam {
@ApiModelProperty("Template ID")
@NotBlank(message = "模板ID不能为空")
private String id;
@ApiModelProperty("Template name")
@NotBlank(message = "模板名称不能为空")
@Size(max = 32, message = "模板名称不能超过32个字符")
private String name;
}
@Data
public static class SubmitAuditParam {
@ApiModelProperty("Template ID")
@NotBlank(message = "模板ID不能为空")
private String id;
}
}

View File

@@ -0,0 +1,45 @@
package com.njcn.gather.aireport.reportmodel.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* Report model template record.
*/
@Data
@TableName("ai_reportmodel")
public class ReportModelPO implements Serializable {
private static final long serialVersionUID = -1413572272542367267L;
@TableId("id")
private String id;
@TableField("name")
private String name;
@TableField("state")
private String state;
@TableField("check_id")
private String checkId;
@TableField("check_time")
private LocalDateTime checkTime;
@TableField("check_result")
private String checkResult;
@TableField("path")
private String path;
@TableField("file_name")
private String fileName;
@TableField("status")
private Integer status;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,9 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import lombok.Data;
@Data
public class ReportModelUserNameVO {
private String id;
private String name;
}

View File

@@ -0,0 +1,108 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* Report model view object.
*/
@Data
public class ReportModelVO {
private static final String DEFAULT_EMPTY_DISPLAY = "-";
@ApiModelProperty("Template ID")
private String id;
@ApiModelProperty("Template name")
private String name;
@ApiModelProperty("Template state")
private String state;
@ApiModelProperty("Auditor ID")
private String checkId;
@ApiModelProperty("Auditor display name")
private String checkerName;
@ApiModelProperty("Audit time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime checkTime;
@ApiModelProperty("Audit comment")
private String checkResult;
@ApiModelProperty("Template file path")
private String path;
@ApiModelProperty("Template document file name")
private String fileName;
@ApiModelProperty("Logical status")
private Integer status;
@ApiModelProperty("Creator display name")
private String createBy;
@ApiModelProperty("Create time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
@ApiModelProperty("Editor display name")
private String updateBy;
@ApiModelProperty("Editor display name")
private String editorName;
@ApiModelProperty("Update time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;
public String getId() {
return defaultDisplay(id);
}
public String getName() {
return defaultDisplay(name);
}
public String getState() {
return defaultDisplay(state);
}
public String getCheckId() {
return defaultDisplay(checkId);
}
public String getCheckerName() {
return defaultDisplay(checkerName);
}
public String getCheckResult() {
return defaultDisplay(checkResult);
}
public String getPath() {
return defaultDisplay(path);
}
public String getFileName() {
return defaultDisplay(fileName);
}
public String getCreateBy() {
return defaultDisplay(createBy);
}
public String getUpdateBy() {
return defaultDisplay(updateBy);
}
public String getEditorName() {
return defaultDisplay(editorName);
}
private String defaultDisplay(String value) {
return StringUtils.hasText(value) ? value : DEFAULT_EMPTY_DISPLAY;
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.gather.aireport.reportmodel.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface ReportModelService extends IService<ReportModelPO> {
Page<ReportModelVO> listReportModels(ReportModelParam.QueryParam param);
boolean addReportModel(ReportModelParam.AddParam param, MultipartFile templateFile);
boolean updateReportModel(ReportModelParam.UpdateParam param, MultipartFile templateFile);
boolean deleteReportModels(List<String> ids);
ReportModelVO getReportModel(String id);
ReportModelPO requireNormal(String id);
boolean submitAudit(ReportModelParam.SubmitAuditParam param);
}

View File

@@ -0,0 +1,295 @@
package com.njcn.gather.aireport.reportmodel.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.component.ReportModelUserNameResolver;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.constant.ReportModelConst;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import com.njcn.gather.aireport.reportmodel.service.ReportModelService;
import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.RequestUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Report model management service.
*/
@Service
public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, ReportModelPO> implements ReportModelService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ReportModelFileStorageService reportModelFileStorageService;
private final ReportModelUserNameResolver reportModelUserNameResolver;
public ReportModelServiceImpl(ReportModelFileStorageService reportModelFileStorageService,
ReportModelUserNameResolver reportModelUserNameResolver) {
this.reportModelFileStorageService = reportModelFileStorageService;
this.reportModelUserNameResolver = reportModelUserNameResolver;
}
@Override
public Page<ReportModelVO> listReportModels(ReportModelParam.QueryParam param) {
ReportModelParam.QueryParam query = param == null ? new ReportModelParam.QueryParam() : param;
LambdaQueryWrapper<ReportModelPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.like(StrUtil.isNotBlank(query.getName()), ReportModelPO::getName, query.getName())
.eq(StrUtil.isNotBlank(query.getState()), ReportModelPO::getState, query.getState());
if (StrUtil.isNotBlank(query.getSearchBeginTime())) {
wrapper.ge(ReportModelPO::getUpdateTime, parseDateTime(query.getSearchBeginTime(), "开始时间格式不正确"));
}
if (StrUtil.isNotBlank(query.getSearchEndTime())) {
wrapper.le(ReportModelPO::getUpdateTime, parseDateTime(query.getSearchEndTime(), "结束时间格式不正确"));
}
wrapper.orderByDesc(ReportModelPO::getCreateTime);
Page<ReportModelPO> page = this.page(new Page<>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), wrapper);
Page<ReportModelVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
Map<String, String> userNameMap = resolveUserNameMap(page.getRecords());
result.setRecords(page.getRecords().stream().map(model -> toVO(model, userNameMap)).collect(Collectors.toList()));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addReportModel(ReportModelParam.AddParam param, MultipartFile templateFile) {
String name = resolveName(param == null ? null : param.getName());
checkNameUnique(name, null);
ReportModelFileStorageService.StoredFile storedFile = reportModelFileStorageService.save(templateFile);
try {
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
ReportModelPO model = new ReportModelPO();
model.setId(UUID.randomUUID().toString());
model.setName(name);
model.setState(ReportModelConst.STATE_EDITING);
model.setPath(storedFile.getPath());
model.setFileName(storedFile.getFileName());
model.setStatus(ReportModelConst.STATUS_NORMAL);
model.setCreateBy(userId);
model.setCreateTime(now);
model.setUpdateBy(userId);
model.setUpdateTime(now);
return this.save(model);
} catch (RuntimeException exception) {
reportModelFileStorageService.deleteQuietly(storedFile.getPath());
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateReportModel(ReportModelParam.UpdateParam param, MultipartFile templateFile) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = requireNormal(param.getId());
if (!allowEdit(model.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅编制或退回状态的模板允许编辑");
}
String name = resolveName(param.getName());
checkNameUnique(name, model.getId());
String oldStoredPath = model.getPath();
String newStoredPath = null;
if (templateFile != null && !templateFile.isEmpty()) {
ReportModelFileStorageService.StoredFile storedFile = reportModelFileStorageService.save(templateFile);
newStoredPath = storedFile.getPath();
model.setPath(storedFile.getPath());
model.setFileName(storedFile.getFileName());
}
try {
model.setName(name);
model.setUpdateBy(resolveCurrentUserId());
model.setUpdateTime(LocalDateTime.now());
boolean updated = this.updateById(model);
if (updated && StrUtil.isNotBlank(newStoredPath) && StrUtil.isNotBlank(oldStoredPath)
&& !StrUtil.equals(oldStoredPath, newStoredPath)) {
reportModelFileStorageService.deleteQuietly(oldStoredPath);
}
return updated;
} catch (RuntimeException exception) {
reportModelFileStorageService.deleteQuietly(newStoredPath);
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteReportModels(List<String> ids) {
if (ids == null || ids.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
List<String> validIds = ids.stream().filter(StrUtil::isNotBlank).collect(Collectors.toList());
if (validIds.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
List<ReportModelPO> models = this.lambdaQuery()
.in(ReportModelPO::getId, validIds)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.list();
boolean deleted = this.lambdaUpdate()
.set(ReportModelPO::getStatus, ReportModelConst.STATUS_DELETED)
.set(ReportModelPO::getState, ReportModelConst.STATE_DELETED)
.set(ReportModelPO::getUpdateBy, resolveCurrentUserId())
.set(ReportModelPO::getUpdateTime, LocalDateTime.now())
.in(ReportModelPO::getId, validIds)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.update();
if (deleted) {
for (ReportModelPO model : models) {
reportModelFileStorageService.deleteQuietly(model.getPath());
}
}
return deleted;
}
@Override
public ReportModelVO getReportModel(String id) {
ReportModelPO model = requireNormal(id);
return toVO(model, resolveUserNameMap(Arrays.asList(model)));
}
@Override
public ReportModelPO requireNormal(String id) {
if (StrUtil.isBlank(id)) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = this.lambdaQuery()
.eq(ReportModelPO::getId, id)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.one();
if (model == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板不存在或已删除");
}
return model;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submitAudit(ReportModelParam.SubmitAuditParam param) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = requireNormal(param.getId());
if (!ReportModelConst.STATE_EDITING.equals(model.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅编制状态的模板允许提交审核");
}
model.setState(ReportModelConst.STATE_AUDITING);
model.setCheckId(null);
model.setCheckTime(null);
model.setCheckResult(null);
model.setUpdateBy(resolveCurrentUserId());
model.setUpdateTime(LocalDateTime.now());
return this.updateById(model);
}
private boolean allowEdit(String state) {
return ReportModelConst.STATE_EDITING.equals(state) || ReportModelConst.STATE_REJECTED.equals(state);
}
private void checkNameUnique(String name, String excludeId) {
LambdaQueryWrapper<ReportModelPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ReportModelPO::getName, name)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.ne(StrUtil.isNotBlank(excludeId), ReportModelPO::getId, excludeId);
if (this.count(wrapper) > 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称已存在");
}
}
private String resolveName(String name) {
String result = trimToNull(name);
if (result == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称不能为空");
}
if (result.length() > 32) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称不能超过32个字符");
}
return result;
}
private LocalDateTime parseDateTime(String value, String errorMessage) {
if (StrUtil.isBlank(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, errorMessage);
}
}
private ReportModelVO toVO(ReportModelPO model) {
return toVO(model, resolveUserNameMap(Arrays.asList(model)));
}
private ReportModelVO toVO(ReportModelPO model, Map<String, String> userNameMap) {
ReportModelVO vo = new ReportModelVO();
BeanUtil.copyProperties(model, vo);
vo.setCreateBy(resolveUserName(model.getCreateBy(), userNameMap));
vo.setUpdateBy(resolveUserName(model.getUpdateBy(), userNameMap));
vo.setEditorName(resolveEditorName(model, userNameMap));
vo.setCheckerName(resolveUserName(model.getCheckId(), userNameMap));
return vo;
}
/**
* 列表“编辑人”优先取更新人,新增后若尚未产生独立更新记录,则回退到创建人。
*/
private Map<String, String> resolveUserNameMap(List<ReportModelPO> models) {
List<String> userIds = models.stream()
.flatMap(model -> Arrays.asList(model.getCreateBy(), model.getUpdateBy(), model.getCheckId()).stream())
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
return reportModelUserNameResolver.resolveUserNames(userIds);
}
private String resolveEditorName(ReportModelPO model, Map<String, String> userNameMap) {
if (model == null) {
return null;
}
if (StrUtil.isNotBlank(model.getUpdateBy())) {
return resolveUserName(model.getUpdateBy(), userNameMap);
}
return StrUtil.isNotBlank(model.getCreateBy()) ? resolveUserName(model.getCreateBy(), userNameMap) : null;
}
private String resolveUserName(String userId, Map<String, String> userNameMap) {
if (StrUtil.isBlank(userId)) {
return null;
}
String userName = userNameMap == null ? null : userNameMap.get(userId);
return StrUtil.isNotBlank(userName) ? userName : userId;
}
private String resolveCurrentUserId() {
String userId = RequestUtil.getUserId();
return StrUtil.isBlank(userId) ? null : userId;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.aireport.reportmodel.component;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
public class ReportModelFileStorageServiceTest {
@Test
public void saveShouldDelegateToMinioStorage() {
AiReportMinioStorageService minioStorageService = Mockito.mock(AiReportMinioStorageService.class);
ReportModelFileStorageService service = new ReportModelFileStorageService(minioStorageService);
Mockito.when(minioStorageService.saveMultipartFile(Mockito.anyString(), Mockito.isNull(),
Mockito.any(MockMultipartFile.class), Mockito.eq(false)))
.thenReturn(new AiReportMinioStorageService.StoredFile("cn-tool-ai-report",
"report-model/template-id-a_b.docx", "template-id-a_b.docx", "a_b.docx",
"application/octet-stream", 7L));
ReportModelFileStorageService.StoredFile storedFile = service.save(new MockMultipartFile(
"templateFile", "../a:b.docx", "application/octet-stream", "content".getBytes()));
Assert.assertEquals("report-model/template-id-a_b.docx", storedFile.getPath());
Assert.assertEquals("a_b.docx", storedFile.getFileName());
}
@Test(expected = IllegalArgumentException.class)
public void saveShouldRejectNonDocxFile() {
ReportModelFileStorageService service = new ReportModelFileStorageService(Mockito.mock(AiReportMinioStorageService.class));
service.save(new MockMultipartFile("templateFile", "template.pdf", "application/pdf", new byte[]{1}));
}
}

View File

@@ -0,0 +1,30 @@
package com.njcn.gather.aireport.reportmodel.component;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import org.junit.Assert;
import org.junit.Test;
import java.util.Arrays;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ReportModelUserNameResolverTest {
@Test
public void resolveUserNamesReturnsSysUserNameById() {
ReportModelMapper mapper = mock(ReportModelMapper.class);
ReportModelUserNameVO user = new ReportModelUserNameVO();
user.setId("user-001");
user.setName("张三");
when(mapper.selectUserNamesByIds(anyCollection())).thenReturn(Arrays.asList(user));
ReportModelUserNameResolver resolver = new ReportModelUserNameResolver(mapper);
Map<String, String> result = resolver.resolveUserNames(Arrays.asList("user-001"));
Assert.assertEquals("张三", result.get("user-001"));
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import org.junit.Assert;
import org.junit.Test;
public class ReportModelVODefaultDisplayTest {
@Test
public void stringFieldsUseDashWhenValueMissing() {
ReportModelVO vo = new ReportModelVO();
vo.setCheckerName(" ");
vo.setCheckResult(null);
vo.setEditorName("");
vo.setUpdateBy(" ");
vo.setFileName(null);
Assert.assertEquals("-", vo.getCheckerName());
Assert.assertEquals("-", vo.getCheckResult());
Assert.assertEquals("-", vo.getEditorName());
Assert.assertEquals("-", vo.getUpdateBy());
Assert.assertEquals("-", vo.getFileName());
}
@Test
public void stringFieldsKeepOriginalTextWhenValuePresent() {
ReportModelVO vo = new ReportModelVO();
vo.setCheckerName("审核人A");
vo.setCheckResult("审核通过");
Assert.assertEquals("审核人A", vo.getCheckerName());
Assert.assertEquals("审核通过", vo.getCheckResult());
}
}

View File

@@ -0,0 +1,23 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
public class ReportModelVOTimeFormatTest {
@Test
public void timeJsonUsesStandardTwentyFourHourFormat() throws Exception {
ReportModelVO vo = new ReportModelVO();
vo.setCreateTime(LocalDateTime.of(2026, 6, 25, 13, 19, 25));
vo.setUpdateTime(LocalDateTime.of(2026, 6, 25, 13, 20, 41));
String json = new ObjectMapper().writeValueAsString(vo);
Assert.assertTrue(json.contains("\"createTime\":\"2026-06-25 13:19:25\""));
Assert.assertTrue(json.contains("\"updateTime\":\"2026-06-25 13:20:41\""));
Assert.assertFalse(json.contains("2026-06-25T13"));
}
}

View File

@@ -0,0 +1,67 @@
package com.njcn.gather.aireport.reportmodel.service.impl;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.component.ReportModelUserNameResolver;
import com.njcn.gather.aireport.reportmodel.pojo.constant.ReportModelConst;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
import static org.mockito.Mockito.mock;
public class ReportModelServiceImplTest {
@Test
public void submitAuditShouldResetAuditFieldsWhenMoveToAuditing() {
ReportModelPO model = new ReportModelPO();
model.setId("model-001");
model.setState(ReportModelConst.STATE_EDITING);
model.setStatus(ReportModelConst.STATUS_NORMAL);
model.setCheckId("checker-001");
model.setCheckTime(LocalDateTime.of(2026, 6, 26, 9, 30, 0));
model.setCheckResult("已有审核意见");
TestableReportModelServiceImpl service = new TestableReportModelServiceImpl(model);
ReportModelParam.SubmitAuditParam param = new ReportModelParam.SubmitAuditParam();
param.setId("model-001");
boolean result = service.submitAudit(param);
Assert.assertTrue(result);
Assert.assertEquals(ReportModelConst.STATE_AUDITING, model.getState());
Assert.assertNull(model.getCheckId());
Assert.assertNull(model.getCheckTime());
Assert.assertNull(model.getCheckResult());
Assert.assertSame(model, service.getUpdatedModel());
Assert.assertNotNull(model.getUpdateTime());
}
private static class TestableReportModelServiceImpl extends ReportModelServiceImpl {
private final ReportModelPO model;
private ReportModelPO updatedModel;
private TestableReportModelServiceImpl(ReportModelPO model) {
super(mock(ReportModelFileStorageService.class), mock(ReportModelUserNameResolver.class));
this.model = model;
}
@Override
public ReportModelPO requireNormal(String id) {
return model;
}
@Override
public boolean updateById(ReportModelPO entity) {
this.updatedModel = entity;
return true;
}
private ReportModelPO getUpdatedModel() {
return updatedModel;
}
}
}

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>test-device</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report-common</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>system</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,95 @@
package com.njcn.gather.aireport.testdevice.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.common.constant.AiReportFileConst;
import com.njcn.gather.aireport.common.constant.AiReportFileExtensionConst;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.Locale;
@Component
@RequiredArgsConstructor
public class TestDeviceReportStorageService {
private static final long MAX_REPORT_SIZE = 10L * 1024L * 1024L;
private static final int MAX_ORIGINAL_NAME_LENGTH = 80;
private final AiReportMinioStorageService minioStorageService;
public String save(MultipartFile reportFile) {
if (reportFile == null || reportFile.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能为空");
}
validatePdf(reportFile.getOriginalFilename(), reportFile.getSize());
return minioStorageService.saveMultipartFile(
AiReportFileConst.BIZ_DIR_TEST_DEVICE, null, reportFile, false).getObjectName();
}
public String savePdf(String originalFilename, InputStream inputStream, long size) {
validatePdf(originalFilename, size);
return minioStorageService.saveInputStream(AiReportFileConst.BIZ_DIR_TEST_DEVICE, null,
trimFileName(originalFilename), inputStream, size, "application/pdf", false).getObjectName();
}
public AiReportMinioStorageService.StoredObject getObject(String storedPath) {
return minioStorageService.getObject(storedPath);
}
public String resolveDownloadFileName(String storedPath) {
if (StrUtil.isBlank(storedPath)) {
return "校准报告.pdf";
}
int slashIndex = storedPath.lastIndexOf('/');
String fileName = slashIndex >= 0 ? storedPath.substring(slashIndex + 1) : storedPath;
int separatorIndex = fileName.indexOf('-');
return separatorIndex >= 0 && separatorIndex + 1 < fileName.length() ? fileName.substring(separatorIndex + 1) : fileName;
}
public void deleteQuietly(String storedPath) {
minioStorageService.deleteQuietly(storedPath);
}
private void validatePdf(String originalFilename, long size) {
String fileName = trimFileName(originalFilename);
if (StrUtil.isBlank(fileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告文件名不能为空");
}
String extension = resolveExtension(fileName);
if (!AiReportFileExtensionConst.PDF_EXTENSIONS.contains(extension)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告仅支持PDF文件");
}
if (size > MAX_REPORT_SIZE) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能超过10MB");
}
}
private String trimFileName(String originalFilename) {
String fileName = originalFilename == null ? null : originalFilename.trim();
if (StrUtil.isBlank(fileName)) {
return fileName;
}
int slashIndex = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
String normalizedName = slashIndex >= 0 ? fileName.substring(slashIndex + 1) : fileName;
String sanitized = normalizedName.replaceAll("[\\\\/:*?\"<>|]", "_");
if (sanitized.length() > MAX_ORIGINAL_NAME_LENGTH) {
String suffix = sanitized.toLowerCase(Locale.ENGLISH).endsWith(".pdf") ? ".pdf" : "";
int keepLength = MAX_ORIGINAL_NAME_LENGTH - suffix.length();
sanitized = sanitized.substring(0, keepLength) + suffix;
}
return sanitized;
}
private String resolveExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == fileName.length() - 1) {
return "";
}
return fileName.substring(dotIndex + 1).toLowerCase(Locale.ENGLISH);
}
}

View File

@@ -0,0 +1,159 @@
package com.njcn.gather.aireport.testdevice.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceImportResultVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceVO;
import com.njcn.gather.aireport.testdevice.service.TestDeviceService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.net.URLEncoder;
import java.util.List;
@Api(tags = "检测设备管理")
@RestController
@RequestMapping("/api/test-device")
@RequiredArgsConstructor
public class TestDeviceController extends BaseController {
private final TestDeviceService testDeviceService;
private final TestDeviceReportStorageService reportStorageService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询检测设备列表")
@PostMapping("/list")
public HttpResult<Page<TestDeviceVO>> list(@RequestBody(required = false) TestDeviceParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
testDeviceService.listTestDevices(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增检测设备")
@PostMapping(value = "/add", consumes = {"multipart/form-data"})
public HttpResult<Boolean> add(@RequestPart("request") @Validated TestDeviceParam.AddParam param,
@RequestPart("reportFile") MultipartFile reportFile) {
String methodDescribe = getMethodDescribe("add");
boolean result = testDeviceService.addTestDevice(param, reportFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("编辑检测设备")
@PostMapping(value = "/update", consumes = {"multipart/form-data"})
public HttpResult<Boolean> update(@RequestPart("request") @Validated TestDeviceParam.UpdateParam param,
@RequestPart(value = "reportFile", required = false) MultipartFile reportFile) {
String methodDescribe = getMethodDescribe("update");
boolean result = testDeviceService.updateTestDevice(param, reportFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除检测设备")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody List<String> ids) {
String methodDescribe = getMethodDescribe("delete");
boolean result = testDeviceService.deleteTestDevices(ids);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询检测设备详情")
@ApiImplicitParam(name = "id", value = "检测设备ID", required = true)
@GetMapping("/{id}")
public HttpResult<TestDeviceVO> detail(@PathVariable("id") String id) {
String methodDescribe = getMethodDescribe("detail");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
testDeviceService.getTestDevice(id), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("下载检测设备导入模板")
@GetMapping("/template")
public void template() {
testDeviceService.downloadTemplate();
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("导入检测设备")
@PostMapping(value = "/import", consumes = {"multipart/form-data"})
public HttpResult<TestDeviceImportResultVO> importExcel(@RequestPart("file") MultipartFile file,
@RequestPart("reportZip") MultipartFile reportZip) {
String methodDescribe = getMethodDescribe("importExcel");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
testDeviceService.importTestDevices(file, reportZip), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("导出检测设备")
@GetMapping("/export")
public void export(@ModelAttribute TestDeviceParam.QueryParam param) {
testDeviceService.exportTestDevices(param);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("下载检测设备校准报告")
@ApiImplicitParam(name = "id", value = "检测设备ID", required = true)
@GetMapping("/{id}/report")
public ResponseEntity<InputStreamResource> report(@PathVariable("id") String id) {
return buildReportResponse(id, false);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("预览检测设备校准报告")
@ApiImplicitParam(name = "id", value = "检测设备ID", required = true)
@GetMapping("/{id}/report/preview")
public ResponseEntity<InputStreamResource> previewReport(@PathVariable("id") String id) {
return buildReportResponse(id, true);
}
private ResponseEntity<InputStreamResource> buildReportResponse(String id, boolean preview) {
TestDevicePO device = testDeviceService.requireNormal(id);
if (device == null || device.getValidityReport() == null || device.getValidityReport().trim().isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告文件不存在");
}
try {
String fileName = reportStorageService.resolveDownloadFileName(device.getValidityReport());
String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
AiReportMinioStorageService.StoredObject storedObject = reportStorageService.getObject(device.getValidityReport());
return ResponseEntity.ok()
.contentType(preview ? MediaType.APPLICATION_PDF : MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
(preview ? "inline" : "attachment") + "; filename*=UTF-8''" + encodedFileName)
.body(new InputStreamResource(storedObject.getInputStream()));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取校准报告失败");
}
}
}

View File

@@ -0,0 +1,61 @@
package com.njcn.gather.aireport.testdevice.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceTypeNameVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface TestDeviceMapper extends BaseMapper<TestDevicePO> {
@Select({
"<script>",
"select d.id, d.type, dt.name as typeName, d.no, d.validity_period as validityPeriod,",
"d.validity_report as validityReport, d.state, d.status, d.create_by as createBy,",
"creator.name as createByName, d.create_time as createTime, d.update_by as updateBy,",
"updater.name as updateByName, d.update_time as updateTime",
"from ai_testdevice d",
"left join cs_dev_type dt on d.type = dt.id",
"left join sys_user creator on d.create_by = creator.id",
"left join sys_user updater on d.update_by = updater.id",
"where d.status = 1",
"<if test='param.keyword != null and param.keyword != \"\"'>",
"and (d.no like concat('%', #{param.keyword}, '%') or dt.name like concat('%', #{param.keyword}, '%'))",
"</if>",
"<if test='param.searchBeginTime != null and param.searchBeginTime != \"\"'>",
"and d.create_time &gt;= #{param.searchBeginTime}",
"</if>",
"<if test='param.searchEndTime != null and param.searchEndTime != \"\"'>",
"and d.create_time &lt;= #{param.searchEndTime}",
"</if>",
"order by d.create_time desc",
"</script>"
})
Page<TestDeviceVO> selectDevicePage(Page<TestDeviceVO> page, @Param("param") TestDeviceParam.QueryParam param);
@Select({
"select d.id, d.type, dt.name as typeName, d.no, d.validity_period as validityPeriod,",
"d.validity_report as validityReport, d.state, d.status, d.create_by as createBy,",
"creator.name as createByName, d.create_time as createTime, d.update_by as updateBy,",
"updater.name as updateByName, d.update_time as updateTime",
"from ai_testdevice d",
"left join cs_dev_type dt on d.type = dt.id",
"left join sys_user creator on d.create_by = creator.id",
"left join sys_user updater on d.update_by = updater.id",
"where d.id = #{id} and d.status = 1"
})
TestDeviceVO selectDeviceDetail(@Param("id") String id);
@Select("select count(1) from cs_dev_type where id = #{typeId} and State = 1")
int countNormalDeviceType(@Param("typeId") String typeId);
@Select("select id, name from cs_dev_type where State = 1")
List<TestDeviceTypeNameVO> selectNormalDeviceTypes();
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.aireport.testdevice.pojo.constant;
public final class TestDeviceConst {
public static final String STATE_RUNNING = "01";
public static final String STATE_RETIRED = "02";
public static final Integer STATUS_NORMAL = 1;
public static final Integer STATUS_DELETED = 0;
private TestDeviceConst() {
}
}

View File

@@ -0,0 +1,49 @@
package com.njcn.gather.aireport.testdevice.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
public class TestDeviceParam {
@Data
@EqualsAndHashCode(callSuper = true)
public static class QueryParam extends BaseParam {
@ApiModelProperty("关键字,匹配设备型号名称或设备编号")
private String keyword;
@ApiModelProperty("创建开始时间yyyy-MM-dd HH:mm:ss")
private String searchBeginTime;
@ApiModelProperty("创建结束时间yyyy-MM-dd HH:mm:ss")
private String searchEndTime;
}
@Data
public static class AddParam {
@ApiModelProperty("设备型号ID")
@NotBlank(message = "设备型号不能为空")
private String type;
@ApiModelProperty("设备编号")
@NotBlank(message = "设备编号不能为空")
private String no;
@ApiModelProperty("校准有效期yyyy-MM-dd")
@NotBlank(message = "校准有效期不能为空")
private String validityPeriod;
@ApiModelProperty("设备状态01-在运02-退运")
private String state;
}
@Data
public static class UpdateParam extends AddParam {
@ApiModelProperty("检测设备ID")
@NotBlank(message = "检测设备ID不能为空")
private String id;
}
}

View File

@@ -0,0 +1,39 @@
package com.njcn.gather.aireport.testdevice.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ai_testdevice")
public class TestDevicePO implements Serializable {
private static final long serialVersionUID = 4153967564253668210L;
@TableId("id")
private String id;
@TableField("type")
private String type;
@TableField("no")
private String no;
@TableField("validity_period")
private LocalDate validityPeriod;
@TableField("validity_report")
private String validityReport;
@TableField("state")
private String state;
@TableField("status")
private Integer status;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.aireport.testdevice.pojo.vo;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
import java.io.Serializable;
@Data
public class TestDeviceExcelVO implements Serializable {
private static final long serialVersionUID = 8111740807955262764L;
@Excel(name = "设备型号", width = 25)
private String typeName;
@Excel(name = "设备编号", width = 25)
private String no;
@Excel(name = "校准有效期", width = 20)
private String validityPeriod;
@Excel(name = "设备状态", width = 15)
private String stateName;
@Excel(name = "校准报告文件名", width = 35)
private String validityReportName;
@Excel(name = "创建时间", width = 25)
private String createTime;
@Excel(name = "创建人", width = 20)
private String createByName;
}

View File

@@ -0,0 +1,13 @@
package com.njcn.gather.aireport.testdevice.pojo.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class TestDeviceImportResultVO {
private int successCount;
private int failCount;
private List<String> failReasons = new ArrayList<String>();
}

View File

@@ -0,0 +1,9 @@
package com.njcn.gather.aireport.testdevice.pojo.vo;
import lombok.Data;
@Data
public class TestDeviceTypeNameVO {
private String id;
private String name;
}

View File

@@ -0,0 +1,59 @@
package com.njcn.gather.aireport.testdevice.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class TestDeviceVO {
@ApiModelProperty("检测设备ID")
private String id;
@ApiModelProperty("设备型号ID")
private String type;
@ApiModelProperty("设备型号名称")
private String typeName;
@ApiModelProperty("设备编号")
private String no;
@ApiModelProperty("校准有效期")
@JsonFormat(pattern = "yyyy-MM-dd")
@JsonDeserialize(using = LocalDateDeserializer.class)
@JsonSerialize(using = LocalDateSerializer.class)
private LocalDate validityPeriod;
@ApiModelProperty("校准报告存储路径")
private String validityReport;
@ApiModelProperty("校准报告文件名")
private String validityReportName;
@ApiModelProperty("校准报告下载地址")
private String validityReportUrl;
@ApiModelProperty("设备状态01-在运02-退役")
private String state;
@ApiModelProperty("逻辑状态")
private Integer status;
@ApiModelProperty("创建人ID")
private String createBy;
@ApiModelProperty("创建人显示名")
private String createByName;
@ApiModelProperty("创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
@ApiModelProperty("更新人ID")
private String updateBy;
@ApiModelProperty("更新人显示名")
private String updateByName;
@ApiModelProperty("更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.aireport.testdevice.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceImportResultVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface TestDeviceService extends IService<TestDevicePO> {
Page<TestDeviceVO> listTestDevices(TestDeviceParam.QueryParam param);
boolean addTestDevice(TestDeviceParam.AddParam param, MultipartFile reportFile);
boolean updateTestDevice(TestDeviceParam.UpdateParam param, MultipartFile reportFile);
boolean deleteTestDevices(List<String> ids);
TestDeviceVO getTestDevice(String id);
TestDevicePO requireNormal(String id);
void downloadTemplate();
TestDeviceImportResultVO importTestDevices(MultipartFile file, MultipartFile reportZip);
void exportTestDevices(TestDeviceParam.QueryParam param);
}

View File

@@ -0,0 +1,676 @@
package com.njcn.gather.aireport.testdevice.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
import com.njcn.gather.aireport.testdevice.mapper.TestDeviceMapper;
import com.njcn.gather.aireport.testdevice.pojo.constant.TestDeviceConst;
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceExcelVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceImportResultVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceTypeNameVO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceVO;
import com.njcn.gather.aireport.testdevice.service.TestDeviceService;
import com.njcn.gather.system.util.ExportFileNameUtil;
import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.ExcelUtil;
import com.njcn.web.utils.HttpServletUtil;
import com.njcn.web.utils.RequestUtil;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Slf4j
@Service
@RequiredArgsConstructor
public class TestDeviceServiceImpl extends ServiceImpl<TestDeviceMapper, TestDevicePO> implements TestDeviceService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String[] TEMPLATE_HEADERS = new String[]{"设备型号", "设备编号", "校准有效期", "设备状态", "校准报告文件名"};
private static final long MAX_IMPORT_REPORT_SIZE = 10L * 1024L * 1024L;
private final TestDeviceReportStorageService reportStorageService;
@Override
public Page<TestDeviceVO> listTestDevices(TestDeviceParam.QueryParam param) {
TestDeviceParam.QueryParam query = normalizeQueryParam(param);
Page<TestDeviceVO> page = this.baseMapper.selectDevicePage(
new Page<TestDeviceVO>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), query);
page.getRecords().forEach(this::fillReportDisplayFields);
return page;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addTestDevice(TestDeviceParam.AddParam param, MultipartFile reportFile) {
NormalizedDevice data = normalizeParam(param);
checkDeviceType(data.getType());
checkNoUnique(data.getNo(), null);
String storedPath = reportStorageService.save(reportFile);
try {
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
TestDevicePO device = new TestDevicePO();
device.setId(UUID.randomUUID().toString());
device.setType(data.getType());
device.setNo(data.getNo());
device.setValidityPeriod(data.getValidityPeriod());
device.setValidityReport(storedPath);
device.setState(data.getState());
device.setStatus(TestDeviceConst.STATUS_NORMAL);
device.setCreateBy(userId);
device.setCreateTime(now);
device.setUpdateBy(userId);
device.setUpdateTime(now);
boolean saved = this.save(device);
if (!saved) {
reportStorageService.deleteQuietly(storedPath);
}
return saved;
} catch (RuntimeException exception) {
reportStorageService.deleteQuietly(storedPath);
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateTestDevice(TestDeviceParam.UpdateParam param, MultipartFile reportFile) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备ID不能为空");
}
TestDevicePO device = requireNormal(param.getId());
NormalizedDevice data = normalizeParam(param);
checkDeviceType(data.getType());
checkNoUnique(data.getNo(), device.getId());
String oldReport = device.getValidityReport();
String newReport = null;
if (reportFile != null && !reportFile.isEmpty()) {
newReport = reportStorageService.save(reportFile);
device.setValidityReport(newReport);
} else if (StrUtil.isBlank(oldReport)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能为空");
}
try {
device.setType(data.getType());
device.setNo(data.getNo());
device.setValidityPeriod(data.getValidityPeriod());
device.setState(data.getState());
device.setUpdateBy(resolveCurrentUserId());
device.setUpdateTime(LocalDateTime.now());
boolean updated = this.updateById(device);
if (!updated) {
reportStorageService.deleteQuietly(newReport);
return false;
}
if (StrUtil.isNotBlank(newReport)) {
reportStorageService.deleteQuietly(oldReport);
}
return true;
} catch (RuntimeException exception) {
reportStorageService.deleteQuietly(newReport);
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteTestDevices(List<String> ids) {
List<String> validIds = normalizeIds(ids);
if (validIds.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备ID不能为空");
}
List<TestDevicePO> devices = this.lambdaQuery()
.in(TestDevicePO::getId, validIds)
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL)
.list();
boolean deleted = this.lambdaUpdate()
.set(TestDevicePO::getStatus, TestDeviceConst.STATUS_DELETED)
.set(TestDevicePO::getUpdateBy, resolveCurrentUserId())
.set(TestDevicePO::getUpdateTime, LocalDateTime.now())
.in(TestDevicePO::getId, validIds)
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL)
.update();
if (deleted) {
for (TestDevicePO device : devices) {
reportStorageService.deleteQuietly(device.getValidityReport());
}
}
return deleted;
}
@Override
public TestDeviceVO getTestDevice(String id) {
TestDeviceVO vo = this.baseMapper.selectDeviceDetail(requireText(id, "检测设备ID不能为空"));
if (vo == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备不存在或已删除");
}
fillReportDisplayFields(vo);
return vo;
}
@Override
public TestDevicePO requireNormal(String id) {
String normalizedId = requireText(id, "检测设备ID不能为空");
TestDevicePO device = this.lambdaQuery()
.eq(TestDevicePO::getId, normalizedId)
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL)
.one();
if (device == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备不存在或已删除");
}
return device;
}
@Override
public void downloadTemplate() {
HttpServletResponse response = HttpServletUtil.getResponse();
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet sheet = workbook.createSheet("检测设备导入模板");
writeTemplateHeader(workbook, sheet);
String fileName = URLEncoder.encode(ExportFileNameUtil.appendToday("检测设备导入模板.xlsx"), "UTF-8");
response.reset();
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
try (ServletOutputStream outputStream = response.getOutputStream()) {
workbook.write(outputStream);
outputStream.flush();
}
} catch (IOException exception) {
log.error("下载检测设备导入模板失败", exception);
throw new BusinessException(CommonResponseEnum.FAIL, "下载检测设备导入模板失败");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public TestDeviceImportResultVO importTestDevices(MultipartFile file, MultipartFile reportZip) {
if (file == null || file.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "导入文件不能为空");
}
if (reportZip == null || reportZip.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告压缩包不能为空");
}
List<TestDeviceExcelVO> rows = readImportRows(file);
TestDeviceImportResultVO result = new TestDeviceImportResultVO();
if (rows.isEmpty()) {
return result;
}
ImportArchive archive = readReportZip(reportZip);
try {
Map<String, String> typeNameIdMap = loadTypeNameIdMap();
List<NormalizedImportDevice> devices = validateImportRows(rows, archive, typeNameIdMap, result);
if (result.getFailCount() > 0) {
return result;
}
List<String> savedReports = new ArrayList<String>();
try {
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
List<TestDevicePO> saveRows = new ArrayList<TestDevicePO>();
for (NormalizedImportDevice data : devices) {
String storedPath;
try (InputStream inputStream = Files.newInputStream(data.getReportPath())) {
storedPath = reportStorageService.savePdf(data.getReportName(), inputStream, Files.size(data.getReportPath()));
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取校准报告压缩包失败");
}
savedReports.add(storedPath);
TestDevicePO device = new TestDevicePO();
device.setId(UUID.randomUUID().toString());
device.setType(data.getType());
device.setNo(data.getNo());
device.setValidityPeriod(data.getValidityPeriod());
device.setValidityReport(storedPath);
device.setState(data.getState());
device.setStatus(TestDeviceConst.STATUS_NORMAL);
device.setCreateBy(userId);
device.setCreateTime(now);
device.setUpdateBy(userId);
device.setUpdateTime(now);
saveRows.add(device);
}
boolean saved = saveRows.isEmpty() || this.saveBatch(saveRows);
if (!saved) {
savedReports.forEach(reportStorageService::deleteQuietly);
return result;
}
result.setSuccessCount(saveRows.size());
return result;
} catch (RuntimeException exception) {
savedReports.forEach(reportStorageService::deleteQuietly);
throw exception;
}
} finally {
archive.cleanup();
}
}
@Override
public void exportTestDevices(TestDeviceParam.QueryParam param) {
TestDeviceParam.QueryParam query = normalizeQueryParam(param);
Page<TestDeviceVO> page = this.baseMapper.selectDevicePage(new Page<TestDeviceVO>(1, Integer.MAX_VALUE), query);
List<TestDeviceExcelVO> exportRows = page.getRecords().stream()
.peek(this::fillReportDisplayFields)
.map(this::toExcelVO)
.collect(Collectors.toList());
ExcelUtil.exportExcel(ExportFileNameUtil.appendToday("检测设备列表.xlsx"), "检测设备列表",
TestDeviceExcelVO.class, exportRows);
}
private TestDeviceParam.QueryParam normalizeQueryParam(TestDeviceParam.QueryParam param) {
TestDeviceParam.QueryParam query = param == null ? new TestDeviceParam.QueryParam() : param;
query.setKeyword(trimToNull(query.getKeyword()));
if (StrUtil.isNotBlank(query.getSearchBeginTime())) {
query.setSearchBeginTime(formatDateTime(parseDateTime(query.getSearchBeginTime(), "开始时间格式不正确")));
}
if (StrUtil.isNotBlank(query.getSearchEndTime())) {
query.setSearchEndTime(formatDateTime(parseDateTime(query.getSearchEndTime(), "结束时间格式不正确")));
}
return query;
}
private NormalizedDevice normalizeParam(TestDeviceParam.AddParam param) {
if (param == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备参数不能为空");
}
NormalizedDevice data = new NormalizedDevice();
data.setType(requireText(param.getType(), "设备型号不能为空"));
data.setNo(requireText(param.getNo(), "设备编号不能为空", 64, "设备编号不能超过64个字符"));
data.setValidityPeriod(parseDate(requireText(param.getValidityPeriod(), "校准有效期不能为空")));
data.setState(normalizeState(param.getState()));
return data;
}
private List<NormalizedImportDevice> validateImportRows(List<TestDeviceExcelVO> rows, ImportArchive archive,
Map<String, String> typeNameIdMap, TestDeviceImportResultVO result) {
Set<String> fileNos = new HashSet<String>();
List<NormalizedImportDevice> devices = new ArrayList<NormalizedImportDevice>();
for (int i = 0; i < rows.size(); i++) {
int rowNumber = i + 2;
try {
TestDeviceExcelVO row = rows.get(i);
NormalizedImportDevice device = new NormalizedImportDevice();
String typeName = requireText(row.getTypeName(), "设备型号不能为空");
String typeId = typeNameIdMap.get(typeName);
if (StrUtil.isBlank(typeId)) {
throw new BusinessException(CommonResponseEnum.FAIL, "设备型号不存在或已删除");
}
device.setType(typeId);
device.setNo(requireText(row.getNo(), "设备编号不能为空", 64, "设备编号不能超过64个字符"));
if (!fileNos.add(device.getNo())) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件内存在重复设备编号");
}
checkNoUnique(device.getNo(), null);
device.setValidityPeriod(parseDate(requireText(row.getValidityPeriod(), "校准有效期不能为空")));
device.setState(normalizeState(row.getStateName()));
device.setReportName(requireText(row.getValidityReportName(), "校准报告文件名不能为空"));
Path reportPath = archive.getReportPath(device.getReportName());
if (reportPath == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告压缩包中未找到对应PDF");
}
device.setReportPath(reportPath);
devices.add(device);
} catch (BusinessException exception) {
result.setFailCount(result.getFailCount() + 1);
result.getFailReasons().add("" + rowNumber + "行:" + exception.getMessage());
}
}
return devices;
}
private List<TestDeviceExcelVO> readImportRows(MultipartFile file) {
try (InputStream inputStream = file.getInputStream();
XSSFWorkbook workbook = new XSSFWorkbook(inputStream)) {
XSSFSheet sheet = workbook.getNumberOfSheets() == 0 ? null : workbook.getSheetAt(0);
if (sheet == null || sheet.getLastRowNum() < 1) {
return Collections.emptyList();
}
List<TestDeviceExcelVO> rows = new ArrayList<TestDeviceExcelVO>();
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (isEmptyRow(row)) {
continue;
}
TestDeviceExcelVO excelVO = new TestDeviceExcelVO();
excelVO.setTypeName(readCell(row, 0));
excelVO.setNo(readCell(row, 1));
excelVO.setValidityPeriod(readCell(row, 2));
excelVO.setStateName(readCell(row, 3));
excelVO.setValidityReportName(readCell(row, 4));
rows.add(excelVO);
}
return rows;
} catch (IOException exception) {
log.error("读取检测设备导入文件失败", exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取导入文件失败");
}
}
ImportArchive readReportZip(MultipartFile reportZip) {
Path tempDir;
try {
tempDir = Files.createTempDirectory("test-device-import-");
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取校准报告压缩包失败");
}
Map<String, Path> files = new HashMap<String, Path>();
try (ZipInputStream zipInputStream = new ZipInputStream(reportZip.getInputStream())) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
String fileName = Paths.get(entry.getName()).getFileName().toString();
if (!fileName.toLowerCase().endsWith(".pdf")) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告压缩包仅允许包含PDF文件");
}
if (files.containsKey(fileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告压缩包中存在重复文件名");
}
files.put(fileName, readZipEntryToFile(tempDir, fileName, zipInputStream));
}
return new ImportArchive(tempDir, files);
} catch (BusinessException exception) {
deleteDirectoryQuietly(tempDir);
throw exception;
} catch (IOException exception) {
deleteDirectoryQuietly(tempDir);
log.error("读取校准报告压缩包失败", exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取校准报告压缩包失败");
}
}
private Path readZipEntryToFile(Path tempDir, String fileName, ZipInputStream zipInputStream) throws IOException {
Path tempFile = tempDir.resolve(UUID.randomUUID().toString().replace("-", "") + "-" + fileName).normalize();
long bytes = 0L;
byte[] buffer = new byte[8192];
try (OutputStream outputStream = Files.newOutputStream(tempFile)) {
int length;
while ((length = zipInputStream.read(buffer)) != -1) {
bytes += length;
if (bytes > MAX_IMPORT_REPORT_SIZE) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能超过10MB");
}
outputStream.write(buffer, 0, length);
}
} catch (BusinessException exception) {
Files.deleteIfExists(tempFile);
throw exception;
} catch (IOException exception) {
Files.deleteIfExists(tempFile);
throw exception;
}
return tempFile;
}
private void deleteDirectoryQuietly(Path directory) {
if (directory == null || !Files.exists(directory)) {
return;
}
try (Stream<Path> pathStream = Files.walk(directory)) {
pathStream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 导入临时目录清理失败不影响主流程返回。
}
});
} catch (IOException ignored) {
// 导入临时目录清理失败不影响主流程返回。
}
}
private void writeTemplateHeader(XSSFWorkbook workbook, XSSFSheet sheet) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
Row row = sheet.createRow(0);
for (int i = 0; i < TEMPLATE_HEADERS.length; i++) {
Cell cell = row.createCell(i);
cell.setCellValue(TEMPLATE_HEADERS[i]);
cell.setCellStyle(style);
sheet.setColumnWidth(i, 25 * 256);
}
}
private boolean isEmptyRow(Row row) {
if (row == null) {
return true;
}
for (int i = 0; i < TEMPLATE_HEADERS.length; i++) {
if (StrUtil.isNotBlank(readCell(row, i))) {
return false;
}
}
return true;
}
private String readCell(Row row, int index) {
Cell cell = row.getCell(index);
return cell == null ? null : trimToNull(new DataFormatter().formatCellValue(cell));
}
private void fillReportDisplayFields(TestDeviceVO vo) {
if (vo == null || StrUtil.isBlank(vo.getValidityReport())) {
return;
}
vo.setValidityReportName(reportStorageService.resolveDownloadFileName(vo.getValidityReport()));
vo.setValidityReportUrl("/api/test-device/" + vo.getId() + "/report");
}
private TestDeviceExcelVO toExcelVO(TestDeviceVO vo) {
TestDeviceExcelVO excelVO = new TestDeviceExcelVO();
excelVO.setTypeName(vo.getTypeName());
excelVO.setNo(vo.getNo());
excelVO.setValidityPeriod(vo.getValidityPeriod() == null ? null : DATE_FORMATTER.format(vo.getValidityPeriod()));
excelVO.setStateName(resolveStateName(vo.getState()));
excelVO.setValidityReportName(vo.getValidityReportName());
excelVO.setCreateByName(StrUtil.isNotBlank(vo.getCreateByName()) ? vo.getCreateByName() : vo.getCreateBy());
excelVO.setCreateTime(vo.getCreateTime() == null ? null : DATE_TIME_FORMATTER.format(vo.getCreateTime()));
return excelVO;
}
private Map<String, String> loadTypeNameIdMap() {
List<TestDeviceTypeNameVO> types = this.baseMapper.selectNormalDeviceTypes();
if (types == null || types.isEmpty()) {
return Collections.emptyMap();
}
return types.stream()
.filter(type -> type != null && StrUtil.isNotBlank(type.getName()) && StrUtil.isNotBlank(type.getId()))
.collect(Collectors.toMap(TestDeviceTypeNameVO::getName, TestDeviceTypeNameVO::getId, (first, second) -> first));
}
private void checkDeviceType(String type) {
if (this.baseMapper.countNormalDeviceType(type) <= 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "设备型号不存在或已删除");
}
}
private void checkNoUnique(String no, String excludeId) {
LambdaQueryWrapper<TestDevicePO> wrapper = new LambdaQueryWrapper<TestDevicePO>();
wrapper.eq(TestDevicePO::getNo, no)
.ne(StrUtil.isNotBlank(excludeId), TestDevicePO::getId, excludeId);
if (this.count(wrapper) > 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "设备编号已存在");
}
}
private List<String> normalizeIds(List<String> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<String> result = new ArrayList<String>();
for (String id : ids) {
String normalizedId = trimToNull(id);
if (normalizedId != null && !result.contains(normalizedId)) {
result.add(normalizedId);
}
}
return result;
}
private LocalDate parseDate(String value) {
try {
return LocalDate.parse(value.trim(), DATE_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校准有效期格式不正确");
}
}
private LocalDateTime parseDateTime(String value, String errorMessage) {
try {
return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, errorMessage);
}
}
private String formatDateTime(LocalDateTime value) {
return DATE_TIME_FORMATTER.format(value);
}
private String normalizeState(String state) {
String text = trimToNull(state);
if (text == null || "在运".equals(text) || TestDeviceConst.STATE_RUNNING.equals(text)) {
return TestDeviceConst.STATE_RUNNING;
}
if ("退运".equals(text) || TestDeviceConst.STATE_RETIRED.equals(text)) {
return TestDeviceConst.STATE_RETIRED;
}
throw new BusinessException(CommonResponseEnum.FAIL, "设备状态不正确");
}
private String resolveStateName(String state) {
if (TestDeviceConst.STATE_RUNNING.equals(state)) {
return "在运";
}
if (TestDeviceConst.STATE_RETIRED.equals(state)) {
return "退运";
}
return state;
}
private String requireText(String value, String blankMessage) {
String text = trimToNull(value);
if (text == null) {
throw new BusinessException(CommonResponseEnum.FAIL, blankMessage);
}
return text;
}
private String requireText(String value, String blankMessage, int maxLength, String lengthMessage) {
String text = requireText(value, blankMessage);
if (text.length() > maxLength) {
throw new BusinessException(CommonResponseEnum.FAIL, lengthMessage);
}
return text;
}
private String resolveCurrentUserId() {
String userId = RequestUtil.getUserId();
return StrUtil.isBlank(userId) ? null : userId;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
@Data
private static class NormalizedDevice {
private String type;
private String no;
private LocalDate validityPeriod;
private String state;
}
@Data
private static class NormalizedImportDevice extends NormalizedDevice {
private String reportName;
private Path reportPath;
}
static class ImportArchive {
private final Path tempDir;
private final Map<String, Path> reportFiles;
ImportArchive(Path tempDir, Map<String, Path> reportFiles) {
this.tempDir = tempDir;
this.reportFiles = reportFiles;
}
Path getReportPath(String fileName) {
return reportFiles.get(fileName);
}
void cleanup() {
if (tempDir == null || !Files.exists(tempDir)) {
return;
}
try (Stream<Path> pathStream = Files.walk(tempDir)) {
pathStream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 导入临时文件清理失败不影响主流程返回。
}
});
} catch (IOException ignored) {
// 导入临时目录清理失败不影响主流程返回。
}
}
}
}

View File

@@ -0,0 +1,34 @@
package com.njcn.gather.aireport.testdevice.component;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
public class TestDeviceReportStorageServiceTest {
@Test
public void saveShouldDelegateToMinioStorage() {
AiReportMinioStorageService minioStorageService = Mockito.mock(AiReportMinioStorageService.class);
TestDeviceReportStorageService service = new TestDeviceReportStorageService(minioStorageService);
Mockito.when(minioStorageService.saveMultipartFile(Mockito.anyString(), Mockito.isNull(),
Mockito.any(MockMultipartFile.class), Mockito.eq(false)))
.thenReturn(new AiReportMinioStorageService.StoredFile("cn-tool-ai-report",
"test-device/report-id-report.pdf", "report-id-report.pdf", "report.pdf",
"application/pdf", 8L));
String storedPath = service.save(new MockMultipartFile("reportFile", "../report.pdf",
"application/pdf", "%PDF-1.4".getBytes()));
Assert.assertEquals("test-device/report-id-report.pdf", storedPath);
}
@Test(expected = BusinessException.class)
public void saveShouldRejectNonPdfFile() {
TestDeviceReportStorageService service = new TestDeviceReportStorageService(Mockito.mock(AiReportMinioStorageService.class));
service.save(new MockMultipartFile("reportFile", "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}));
}
}

View File

@@ -0,0 +1,43 @@
package com.njcn.gather.aireport.testdevice.controller;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.service.TestDeviceService;
import io.minio.GetObjectResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
class TestDeviceControllerTest {
private final TestDeviceService testDeviceService = Mockito.mock(TestDeviceService.class);
private final TestDeviceReportStorageService reportStorageService = Mockito.mock(TestDeviceReportStorageService.class);
@Test
void previewReportShouldReturnInlinePdfResponse() throws Exception {
TestDeviceController controller = new TestDeviceController(testDeviceService, reportStorageService);
TestDevicePO device = new TestDevicePO();
device.setId("device-1");
device.setValidityReport("20260626/report.pdf");
AiReportMinioStorageService.StoredObject storedObject = Mockito.mock(AiReportMinioStorageService.StoredObject.class);
Mockito.when(testDeviceService.requireNormal("device-1")).thenReturn(device);
Mockito.when(reportStorageService.resolveDownloadFileName("20260626/report.pdf")).thenReturn("report.pdf");
Mockito.when(reportStorageService.getObject("20260626/report.pdf")).thenReturn(storedObject);
Mockito.when(storedObject.getInputStream()).thenReturn(Mockito.mock(GetObjectResponse.class));
@SuppressWarnings("unchecked")
ResponseEntity<InputStreamResource> response = (ResponseEntity<InputStreamResource>)
ReflectionTestUtils.invokeMethod(controller, "buildReportResponse", "device-1", true);
Assertions.assertNotNull(response);
Assertions.assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
Assertions.assertEquals("inline; filename*=UTF-8''report.pdf",
response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
}
}

View File

@@ -0,0 +1,31 @@
package com.njcn.gather.aireport.testdevice.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Arrays;
class TestDeviceMapperTest {
@Test
void selectQueriesShouldContainUpdateUserNameMapping() throws Exception {
assertSelectContainsUpdateUserName("selectDevicePage");
assertSelectContainsUpdateUserName("selectDeviceDetail");
}
private void assertSelectContainsUpdateUserName(String methodName) throws Exception {
Method method = Arrays.stream(TestDeviceMapper.class.getMethods())
.filter(item -> item.getName().equals(methodName))
.findFirst()
.orElseThrow(NoSuchMethodException::new);
Select select = method.getAnnotation(Select.class);
Assertions.assertNotNull(select);
String sql = String.join(" ", select.value());
Assertions.assertTrue(sql.contains("updateByName"),
methodName + " should select updateByName");
Assertions.assertTrue(sql.contains("d.update_by = updater.id"),
methodName + " should join update user");
}
}

View File

@@ -0,0 +1,161 @@
package com.njcn.gather.aireport.testdevice.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
import com.njcn.gather.aireport.testdevice.mapper.TestDeviceMapper;
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceTypeNameVO;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Collections;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
class TestDeviceServiceImplTest {
private final TestDeviceMapper testDeviceMapper = Mockito.mock(TestDeviceMapper.class);
private final TestDeviceReportStorageService reportStorageService = Mockito.mock(TestDeviceReportStorageService.class);
@Test
void addTestDeviceShouldRejectExistingDeletedNumber() {
TestableService service = buildService();
service.countResult = 1;
Mockito.when(testDeviceMapper.countNormalDeviceType("type-1")).thenReturn(1);
TestDeviceParam.AddParam param = new TestDeviceParam.AddParam();
param.setType("type-1");
param.setNo("TD-001");
param.setValidityPeriod("2026-06-25");
BusinessException exception = Assertions.assertThrows(BusinessException.class,
() -> service.addTestDevice(param, new MockMultipartFile("reportFile", "report.pdf",
"application/pdf", "%PDF-1.4".getBytes())));
Assertions.assertEquals("设备编号已存在", exception.getMessage());
Mockito.verifyNoInteractions(reportStorageService);
}
@Test
void updateTestDeviceShouldDeleteNewReportWhenUpdateReturnsFalse() {
TestableService service = buildService();
service.countResult = 0;
service.updateResult = false;
service.requiredDevice = buildDevice("device-1", "old/report.pdf");
Mockito.when(testDeviceMapper.countNormalDeviceType("type-1")).thenReturn(1);
Mockito.when(reportStorageService.save(Mockito.any())).thenReturn("new/report.pdf");
TestDeviceParam.UpdateParam param = new TestDeviceParam.UpdateParam();
param.setId("device-1");
param.setType("type-1");
param.setNo("TD-001");
param.setValidityPeriod("2026-06-25");
boolean result = service.updateTestDevice(param,
new MockMultipartFile("reportFile", "report.pdf", "application/pdf", "%PDF-1.4".getBytes()));
Assertions.assertFalse(result);
Mockito.verify(reportStorageService).deleteQuietly("new/report.pdf");
Mockito.verify(reportStorageService, Mockito.never()).deleteQuietly("old/report.pdf");
}
@Test
void importTestDevicesShouldRejectOversizedPdfInZip() throws Exception {
TestableService service = buildService();
Mockito.when(testDeviceMapper.selectNormalDeviceTypes()).thenReturn(Collections.singletonList(buildType("type-1", "示波器")));
BusinessException exception = Assertions.assertThrows(BusinessException.class,
() -> service.importTestDevices(buildExcelFile(), buildZipFile(10 * 1024 * 1024 + 1)));
Assertions.assertEquals("校准报告不能超过10MB", exception.getMessage());
}
private TestableService buildService() {
TestableService service = new TestableService(reportStorageService);
ReflectionTestUtils.setField(service, "baseMapper", testDeviceMapper);
return service;
}
private TestDevicePO buildDevice(String id, String reportPath) {
TestDevicePO device = new TestDevicePO();
device.setId(id);
device.setType("type-1");
device.setNo("TD-001");
device.setValidityPeriod(LocalDate.of(2026, 6, 25));
device.setValidityReport(reportPath);
return device;
}
private TestDeviceTypeNameVO buildType(String id, String name) {
TestDeviceTypeNameVO type = new TestDeviceTypeNameVO();
type.setId(id);
type.setName(name);
return type;
}
private MockMultipartFile buildExcelFile() throws IOException {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("导入");
sheet.createRow(0).createCell(0).setCellValue("设备型号");
sheet.getRow(0).createCell(1).setCellValue("设备编号");
sheet.getRow(0).createCell(2).setCellValue("校准有效期");
sheet.getRow(0).createCell(3).setCellValue("设备状态");
sheet.getRow(0).createCell(4).setCellValue("校准报告文件名");
sheet.createRow(1).createCell(0).setCellValue("示波器");
sheet.getRow(1).createCell(1).setCellValue("TD-001");
sheet.getRow(1).createCell(2).setCellValue("2026-06-25");
sheet.getRow(1).createCell(3).setCellValue("在运");
sheet.getRow(1).createCell(4).setCellValue("report.pdf");
workbook.write(outputStream);
return new MockMultipartFile("file", "test-device.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", outputStream.toByteArray());
}
}
private MockMultipartFile buildZipFile(int pdfSize) throws IOException {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
zipOutputStream.putNextEntry(new ZipEntry("report.pdf"));
zipOutputStream.write(new byte[pdfSize]);
zipOutputStream.closeEntry();
zipOutputStream.finish();
return new MockMultipartFile("reportZip", "report.zip", "application/zip", outputStream.toByteArray());
}
}
private static class TestableService extends TestDeviceServiceImpl {
private int countResult;
private boolean updateResult = true;
private TestDevicePO requiredDevice;
private TestableService(TestDeviceReportStorageService reportStorageService) {
super(reportStorageService);
}
@Override
public int count(Wrapper<TestDevicePO> queryWrapper) {
return countResult;
}
@Override
public boolean updateById(TestDevicePO entity) {
return updateResult;
}
@Override
public TestDevicePO requireNormal(String id) {
return requiredDevice;
}
}
}

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>test-report</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report-common</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>comservice</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>report-model</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>client-unit</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>test-device</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>mybatis-plus</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>system</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,382 @@
package com.njcn.gather.aireport.testreport.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.testreport.pojo.constant.TestReportConst;
import lombok.Data;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class TestReportLedgerExcelParser {
private static final DataFormatter FORMATTER = new DataFormatter();
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final List<String> REQUIRED_SHEETS = Arrays.asList(
TestReportConst.LEDGER_POINT_SHEET_NAME);
private static final List<String> OPTIONAL_SHEETS = Arrays.asList(
TestReportConst.LEDGER_GUIDE_SHEET_NAME,
TestReportConst.LEDGER_DEVICE_SHEET_NAME,
TestReportConst.LEDGER_CLIENT_SHEET_NAME);
private static final List<String> POINT_HEADERS = Arrays.asList(
"分组号", "变电站", "监测点名称", "检测设备编号", "委托单位名称", "Excel附件名称", "Word附件名称");
private static final List<String> DEVICE_HEADERS = Arrays.asList(
"设备型号", "设备编号", "标准有效期", "设备状态", "标准报告文件名");
private static final List<String> CLIENT_HEADERS = Arrays.asList(
"单位名称", "联系人", "联系方式", "地址");
public ParsedLedger parse(MultipartFile summaryFile, Map<String, MultipartFile> attachments) {
String fileName = summaryFile == null ? null : StrUtil.trimToNull(summaryFile.getOriginalFilename());
Set<String> attachmentNames = attachments == null ? Collections.<String>emptySet() : attachments.keySet();
try (InputStream inputStream = summaryFile.getInputStream()) {
return parse(fileName, inputStream, attachmentNames);
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段读取台账汇总excel失败");
}
}
public ParsedLedger parse(String fileName, InputStream inputStream, Set<String> attachmentNames) {
ParsedLedger ledger = parseForValidate(fileName, inputStream);
ensureRequiredSheetsPresent(ledger);
validateReferencedAttachments(ledger.getPoints(), attachmentNames);
return ledger;
}
public ParsedLedger parseForValidate(String fileName, InputStream inputStream) {
String normalizedFileName = StrUtil.trimToNull(fileName);
if (!TestReportConst.LEDGER_SUMMARY_FILE_NAME.equals(normalizedFileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:台账汇总文件名必须为台账信息汇总.xlsx");
}
try (Workbook workbook = WorkbookFactory.create(inputStream)) {
ParsedLedger ledger = new ParsedLedger();
ledger.setSummaryFileName(TestReportConst.LEDGER_SUMMARY_FILE_NAME);
ledger.getRequiredSheets().addAll(REQUIRED_SHEETS);
collectSheets(ledger, workbook);
Sheet pointSheet = workbook.getSheet(TestReportConst.LEDGER_POINT_SHEET_NAME);
if (pointSheet != null) {
ledger.setPoints(parsePointSheet(pointSheet));
}
// 检测设备/委托单位 sheet 按需填写,仅在实际存在时才做结构校验和解析。
Sheet deviceSheet = workbook.getSheet(TestReportConst.LEDGER_DEVICE_SHEET_NAME);
if (deviceSheet != null) {
ledger.setDevices(parseDeviceSheet(deviceSheet));
}
Sheet clientSheet = workbook.getSheet(TestReportConst.LEDGER_CLIENT_SHEET_NAME);
if (clientSheet != null) {
ledger.setClients(parseClientSheet(clientSheet));
}
return ledger;
} catch (BusinessException exception) {
throw exception;
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段读取台账汇总excel失败");
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段解析台账汇总excel失败");
}
}
private void collectSheets(ParsedLedger ledger, Workbook workbook) {
for (String sheetName : REQUIRED_SHEETS) {
Sheet sheet = workbook.getSheet(sheetName);
if (sheet == null) {
ledger.getMissingSheets().add(sheetName);
continue;
}
ledger.getExistingSheets().add(sheetName);
}
for (String sheetName : OPTIONAL_SHEETS) {
if (workbook.getSheet(sheetName) != null) {
ledger.getExistingSheets().add(sheetName);
}
}
}
private void ensureRequiredSheetsPresent(ParsedLedger ledger) {
if (!ledger.getMissingSheets().isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL,
"校验文件阶段:缺少" + String.join("", ledger.getMissingSheets()) + "sheet");
}
}
private List<ParsedPointRow> parsePointSheet(Sheet sheet) {
validateHeaders(sheet, POINT_HEADERS, "监测点信息");
List<ParsedPointRow> points = new ArrayList<ParsedPointRow>();
Set<String> pointKeys = new HashSet<String>();
Set<String> excelNames = new HashSet<String>();
Set<String> wordNames = new HashSet<String>();
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (isEmptyRow(row, POINT_HEADERS.size())) {
continue;
}
ParsedPointRow point = new ParsedPointRow();
point.setRowNo(rowIndex + 1);
point.setGroupNo(parsePositiveInteger(optionalCell(row, 0), "" + (rowIndex + 1) + "行分组号必须为正整数"));
point.setSubstationName(requireCell(row, 1, rowIndex, "变电站"));
point.setPointName(requireCell(row, 2, rowIndex, "监测点名称"));
point.setTestDeviceNo(optionalCell(row, 3));
point.setClientUnitName(optionalCell(row, 4));
point.setExcelAttachmentName(requireCell(row, 5, rowIndex, "Excel附件名称"));
point.setWordAttachmentName(requireCell(row, 6, rowIndex, "Word附件名称"));
validateExtension(point.getExcelAttachmentName(), Arrays.asList(".xls", ".xlsx"),
"" + (rowIndex + 1) + "行Excel附件扩展名仅允许xls/xlsx");
validateExtension(point.getWordAttachmentName(), Arrays.asList(".doc", ".docx"),
"" + (rowIndex + 1) + "行Word附件扩展名仅允许doc/docx");
String pointKey = point.getSubstationName() + "|" + point.getPointName();
if (!pointKeys.add(pointKey)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:监测点信息中存在重复点位");
}
if (!excelNames.add(point.getExcelAttachmentName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段监测点信息中存在重复Excel附件名称");
}
if (!wordNames.add(point.getWordAttachmentName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段监测点信息中存在重复Word附件名称");
}
points.add(point);
}
if (points.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段监测点信息sheet不能为空");
}
return points;
}
private List<ParsedDeviceRow> parseDeviceSheet(Sheet sheet) {
validateHeaders(sheet, DEVICE_HEADERS, "检测设备");
List<ParsedDeviceRow> devices = new ArrayList<ParsedDeviceRow>();
Set<String> deviceNos = new HashSet<String>();
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (isEmptyRow(row, DEVICE_HEADERS.size())) {
continue;
}
ParsedDeviceRow device = new ParsedDeviceRow();
device.setRowNo(rowIndex + 1);
device.setTypeName(optionalCell(row, 0));
device.setNo(optionalCell(row, 1));
device.setValidityPeriod(parseOptionalDate(optionalCell(row, 2), rowIndex));
device.setStateName(optionalCell(row, 3));
device.setValidityReportName(optionalCell(row, 4));
if (StrUtil.isBlank(device.getNo())) {
continue;
}
if (!deviceNos.add(device.getNo())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段检测设备sheet中存在重复设备编号");
}
devices.add(device);
}
return devices;
}
private List<ParsedClientRow> parseClientSheet(Sheet sheet) {
validateHeaders(sheet, CLIENT_HEADERS, "委托单位");
List<ParsedClientRow> clients = new ArrayList<ParsedClientRow>();
Set<String> clientNames = new HashSet<String>();
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (isEmptyRow(row, CLIENT_HEADERS.size())) {
continue;
}
ParsedClientRow client = new ParsedClientRow();
client.setRowNo(rowIndex + 1);
client.setName(optionalCell(row, 0));
client.setContactName(optionalCell(row, 1));
client.setPhonenumber(optionalCell(row, 2));
client.setAddress(optionalCell(row, 3));
if (StrUtil.isBlank(client.getName())) {
continue;
}
if (!clientNames.add(client.getName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段委托单位sheet中存在重复单位名称");
}
clients.add(client);
}
return clients;
}
private void validateReferencedAttachments(List<ParsedPointRow> points, Set<String> attachmentNames) {
Set<String> referencedNames = new HashSet<String>();
for (ParsedPointRow point : points) {
if (StrUtil.isNotBlank(point.getExcelAttachmentName())) {
referencedNames.add(point.getExcelAttachmentName());
}
if (StrUtil.isNotBlank(point.getWordAttachmentName())) {
referencedNames.add(point.getWordAttachmentName());
}
}
for (ParsedPointRow point : points) {
if (!attachmentNames.contains(point.getExcelAttachmentName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:第" + point.getRowNo() + "行缺少Excel附件");
}
if (!attachmentNames.contains(point.getWordAttachmentName())) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:第" + point.getRowNo() + "行缺少Word附件");
}
}
for (String attachmentName : attachmentNames) {
if (!referencedNames.contains(attachmentName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段存在未在监测点信息sheet中引用的附件 " + attachmentName);
}
}
}
private void validateHeaders(Sheet sheet, List<String> expectedHeaders, String sheetName) {
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:" + sheetName + "sheet缺少表头");
}
for (int i = 0; i < expectedHeaders.size(); i++) {
String actualHeader = optionalCell(headerRow, i);
if (!expectedHeaders.get(i).equals(actualHeader)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:" + sheetName + "sheet表头不符合模板要求");
}
}
}
private boolean isEmptyRow(Row row, int cellCount) {
if (row == null) {
return true;
}
for (int i = 0; i < cellCount; i++) {
if (StrUtil.isNotBlank(optionalCell(row, i))) {
return false;
}
}
return true;
}
private String requireCell(Row row, int cellIndex, int rowIndex, String fieldName) {
String value = optionalCell(row, cellIndex);
if (StrUtil.isBlank(value)) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:第" + (rowIndex + 1) + "行缺少" + fieldName);
}
return value;
}
private String optionalCell(Row row, int cellIndex) {
if (row == null) {
return null;
}
Cell cell = row.getCell(cellIndex);
return cell == null ? null : StrUtil.trimToNull(FORMATTER.formatCellValue(cell));
}
private Integer parsePositiveInteger(String value, String message) {
if (StrUtil.isBlank(value)) {
return null;
}
try {
int parsed = Integer.parseInt(value);
if (parsed <= 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:" + message);
}
return parsed;
} catch (NumberFormatException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:" + message);
}
}
private void validateExtension(String fileName, List<String> validExtensions, String message) {
String lowerFileName = fileName.toLowerCase();
for (String validExtension : validExtensions) {
if (lowerFileName.endsWith(validExtension)) {
return;
}
}
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:" + message);
}
private LocalDate parseDate(String value, int rowIndex) {
try {
return LocalDate.parse(value, DATE_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL,
"校验文件阶段检测设备sheet第" + (rowIndex + 1) + "行标准有效期格式必须为yyyy-MM-dd");
}
}
private LocalDate parseOptionalDate(String value, int rowIndex) {
if (StrUtil.isBlank(value)) {
return null;
}
return parseDate(value, rowIndex);
}
@Data
public static class ParsedLedger {
private String summaryFileName;
private List<String> requiredSheets = new ArrayList<String>();
private List<String> existingSheets = new ArrayList<String>();
private List<String> missingSheets = new ArrayList<String>();
private List<ParsedPointRow> points = new ArrayList<ParsedPointRow>();
private List<ParsedDeviceRow> devices = new ArrayList<ParsedDeviceRow>();
private List<ParsedClientRow> clients = new ArrayList<ParsedClientRow>();
public Map<String, ParsedDeviceRow> toDeviceMap() {
Map<String, ParsedDeviceRow> result = new HashMap<String, ParsedDeviceRow>();
for (ParsedDeviceRow device : devices) {
result.put(device.getNo(), device);
}
return result;
}
public Map<String, ParsedClientRow> toClientMap() {
Map<String, ParsedClientRow> result = new HashMap<String, ParsedClientRow>();
for (ParsedClientRow client : clients) {
result.put(client.getName(), client);
}
return result;
}
}
@Data
public static class ParsedPointRow {
private Integer rowNo;
private Integer groupNo;
private String substationName;
private String pointName;
private String testDeviceNo;
private String clientUnitName;
private String excelAttachmentName;
private String wordAttachmentName;
}
@Data
public static class ParsedDeviceRow {
private Integer rowNo;
private String typeName;
private String no;
private LocalDate validityPeriod;
private String stateName;
private String validityReportName;
}
@Data
public static class ParsedClientRow {
private Integer rowNo;
private String name;
private String contactName;
private String phonenumber;
private String address;
}
}

View File

@@ -0,0 +1,876 @@
package com.njcn.gather.aireport.testreport.component;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.clientunit.mapper.ClientUnitMapper;
import com.njcn.gather.aireport.clientunit.pojo.constant.ClientUnitConst;
import com.njcn.gather.aireport.clientunit.pojo.po.ClientUnitPO;
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
import com.njcn.gather.aireport.testdevice.mapper.TestDeviceMapper;
import com.njcn.gather.aireport.testdevice.pojo.constant.TestDeviceConst;
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
import com.njcn.gather.aireport.testdevice.pojo.vo.TestDeviceTypeNameVO;
import com.njcn.gather.aireport.testreport.mapper.TestReportGroupReportMapper;
import com.njcn.gather.aireport.testreport.mapper.TestReportMapper;
import com.njcn.gather.aireport.testreport.mapper.TestReportPointMapper;
import com.njcn.gather.aireport.testreport.pojo.constant.TestReportConst;
import com.njcn.gather.aireport.testreport.pojo.po.TestReportGroupReportPO;
import com.njcn.gather.aireport.testreport.pojo.po.TestReportPO;
import com.njcn.gather.aireport.testreport.pojo.po.TestReportPointPO;
import com.njcn.gather.aireport.testreport.pojo.vo.TestReportLedgerImportResultVO;
import com.njcn.gather.aireport.testreport.pojo.vo.TestReportLedgerImportStageVO;
import com.njcn.gather.aireport.testreport.pojo.vo.TestReportLedgerValidateAttachmentVO;
import com.njcn.gather.aireport.testreport.pojo.vo.TestReportLedgerValidatePointVO;
import com.njcn.gather.aireport.testreport.pojo.vo.TestReportLedgerValidateResultVO;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Component
public class TestReportLedgerImportService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final String BATCH_STATUS_VALIDATED = "validated";
private final TestReportMapper testReportMapper;
private final TestReportPointMapper testReportPointMapper;
private final TestReportGroupReportMapper testReportGroupReportMapper;
private final TestDeviceMapper testDeviceMapper;
private final ClientUnitMapper clientUnitMapper;
private final TestReportLedgerExcelParser testReportLedgerExcelParser;
private final TestReportStorageService testReportStorageService;
public TestReportLedgerImportService(TestReportMapper testReportMapper,
TestReportPointMapper testReportPointMapper,
TestReportGroupReportMapper testReportGroupReportMapper,
TestDeviceMapper testDeviceMapper,
ClientUnitMapper clientUnitMapper,
TestReportLedgerExcelParser testReportLedgerExcelParser,
TestReportStorageService testReportStorageService) {
this.testReportMapper = testReportMapper;
this.testReportPointMapper = testReportPointMapper;
this.testReportGroupReportMapper = testReportGroupReportMapper;
this.testDeviceMapper = testDeviceMapper;
this.clientUnitMapper = clientUnitMapper;
this.testReportLedgerExcelParser = testReportLedgerExcelParser;
this.testReportStorageService = testReportStorageService;
}
public TestReportLedgerValidateResultVO validateLedgerSession(String sessionId, MultipartFile[] files) {
Map<String, MultipartFile> fileMap = normalizeFileMap(files);
TestReportStorageService.UploadedBatch uploadedBatch = testReportStorageService.saveValidateSessionFiles(sessionId, fileMap);
List<String> sessionFileNames = testReportStorageService.listValidateSessionFileNames(sessionId);
Set<String> sessionFileNameSet = new HashSet<String>(sessionFileNames);
TestReportLedgerValidateResultVO result = new TestReportLedgerValidateResultVO();
result.setSessionId(sessionId);
result.setTempStoragePath(uploadedBatch.getTempStoragePath());
result.setUploadedFileCount(sessionFileNames.size());
result.setCanContinueUpload(Boolean.TRUE);
result.setSummaryFileName(TestReportConst.LEDGER_SUMMARY_FILE_NAME);
result.setSummaryFilePresent(sessionFileNameSet.contains(TestReportConst.LEDGER_SUMMARY_FILE_NAME));
if (!Boolean.TRUE.equals(result.getSummaryFilePresent())) {
result.setSuccess(Boolean.FALSE);
result.getFailReasons().add("鏍¢獙鏂囦欢闃舵锛氱己灏戝彴璐︿俊鎭眹鎬?xlsx");
persistValidateSessionResult(sessionId, result);
return result;
}
try (AiReportMinioStorageService.StoredObject storedObject =
testReportStorageService.getValidateSessionObject(sessionId, TestReportConst.LEDGER_SUMMARY_FILE_NAME);
InputStream inputStream = storedObject.getInputStream()) {
TestReportLedgerExcelParser.ParsedLedger ledger = testReportLedgerExcelParser.parseForValidate(
TestReportConst.LEDGER_SUMMARY_FILE_NAME, inputStream);
Map<String, TestDevicePO> dbDeviceMap = loadExistingDevices(collectDeviceNos(ledger));
Map<String, ClientUnitPO> dbClientMap = loadExistingClients(collectClientNames(ledger));
result.getRequiredSheets().addAll(ledger.getRequiredSheets());
result.getExistingSheets().addAll(ledger.getExistingSheets());
result.getMissingSheets().addAll(ledger.getMissingSheets());
fillPointValidationResult(result, ledger, sessionFileNameSet, dbDeviceMap, dbClientMap);
result.getOrphanAttachmentNames().addAll(resolveOrphanAttachmentNames(ledger, sessionFileNameSet));
result.setSuccess(result.getMissingSheets().isEmpty() && result.getMissingAttachmentCount() != null
&& result.getMissingAttachmentCount() == 0 && !hasReferenceErrors(result.getPoints()));
if (!result.getMissingSheets().isEmpty()) {
result.getFailReasons().add("鏍¢獙鏂囦欢闃舵锛歋heet涓嶄笉榻? " + String.join("銆?", result.getMissingSheets()));
}
if (result.getMissingAttachmentCount() != null && result.getMissingAttachmentCount() > 0) {
result.getFailReasons().add("鏍¢獙鏂囦欢闃舵锛氬瓨鍦ㄧ己澶遍檮浠讹紝鍙户缁笂浼犲悗鍐嶆鏍¢獙");
}
appendReferenceFailReasons(result);
persistValidateSessionResult(sessionId, result);
return result;
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "鏍¢獙鏂囦欢闃舵锛氳鍙栧彴璐︽眹鎬籩xcel澶辫触");
}
}
public TestReportLedgerImportResultVO validateLedger(TestReportPO report, MultipartFile[] files, String userId) {
Map<String, MultipartFile> fileMap = normalizeFileMap(files);
String batchId = UUID.randomUUID().toString();
TestReportStorageService.UploadedBatch uploadedBatch = testReportStorageService.saveTempBatchFiles(report.getId(), batchId, fileMap);
try {
MultipartFile summaryFile = fileMap.get(TestReportConst.LEDGER_SUMMARY_FILE_NAME);
if (summaryFile == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:缺少台账信息汇总.xlsx");
}
TestReportLedgerExcelParser.ParsedLedger ledger = parseLedgerFromBatch(report.getId(), batchId, fileMap.keySet());
Map<String, TestDevicePO> dbDeviceMap = loadExistingDevices(collectDeviceNos(ledger));
Map<String, ClientUnitPO> dbClientMap = loadExistingClients(collectClientNames(ledger));
validatePointReferences(ledger.getPoints(), dbDeviceMap, dbClientMap, ledger.toDeviceMap(), ledger.toClientMap());
writeValidatedBatchMeta(report.getId(), batchId, uploadedBatch, ledger, userId);
return buildValidateSuccessResult(batchId, uploadedBatch.getTempStoragePath(), ledger, fileMap.size());
} catch (RuntimeException exception) {
testReportStorageService.deleteTempBatchQuietly(report.getId(), batchId);
throw exception;
}
}
public TestReportLedgerImportResultVO importLedger(TestReportPO report, String batchId, String userId) {
BatchMeta batchMeta = loadValidatedBatchMeta(report.getId(), batchId);
TestReportLedgerExcelParser.ParsedLedger ledger = parseLedgerFromBatch(report.getId(), batchId, batchMeta.toFileNameSet());
Map<String, TestDevicePO> dbDeviceMap = loadExistingDevices(collectDeviceNos(ledger));
Map<String, ClientUnitPO> dbClientMap = loadExistingClients(collectClientNames(ledger));
BaseDataSummary baseDataSummary = maintainBaseData(ledger, dbDeviceMap, dbClientMap, userId);
List<TestReportPointPO> oldPoints = loadNormalPoints(report.getId());
List<TestReportGroupReportPO> oldGroupReports = loadNormalGroupReports(report.getId());
deleteOldFiles(oldPoints, oldGroupReports, report.getData());
removeOldRows(report.getId());
String summaryStoragePath = testReportStorageService.saveLedgerSummaryFromTemp(report.getId(), batchId,
batchMeta.getSummaryFileName());
int groupCount = saveNewPoints(report.getId(), batchId, ledger.getPoints(), userId);
LocalDateTime now = LocalDateTime.now();
report.setData(buildSnapshotJson(ledger, summaryStoragePath, baseDataSummary, groupCount));
report.setReportGenerateState(TestReportConst.REPORT_GENERATE_STATE_PENDING);
report.setUpdateBy(userId);
report.setUpdateTime(now);
testReportMapper.updateById(report);
testReportStorageService.deleteTempBatchQuietly(report.getId(), batchId);
return buildImportSuccessResult(batchId, batchMeta.getTempStoragePath(), ledger, baseDataSummary,
batchMeta.getUploadedFileCount(), groupCount);
}
private TestReportLedgerExcelParser.ParsedLedger parseLedgerFromBatch(String reportId, String batchId, Collection<String> fileNames) {
try (AiReportMinioStorageService.StoredObject storedObject =
testReportStorageService.getTempBatchObject(reportId, batchId, TestReportConst.LEDGER_SUMMARY_FILE_NAME);
InputStream inputStream = storedObject.getInputStream()) {
return testReportLedgerExcelParser.parse(TestReportConst.LEDGER_SUMMARY_FILE_NAME, inputStream,
buildAttachmentNameSet(fileNames));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段读取台账汇总excel失败");
}
}
private Set<String> buildAttachmentNameSet(Collection<String> fileNames) {
Set<String> attachmentNames = new HashSet<String>();
if (fileNames == null || fileNames.isEmpty()) {
return attachmentNames;
}
for (String fileName : fileNames) {
if (StrUtil.isBlank(fileName) || TestReportConst.LEDGER_SUMMARY_FILE_NAME.equals(fileName)) {
continue;
}
attachmentNames.add(fileName);
}
return attachmentNames;
}
private void fillPointValidationResult(TestReportLedgerValidateResultVO result,
TestReportLedgerExcelParser.ParsedLedger ledger,
Set<String> sessionFileNameSet,
Map<String, TestDevicePO> dbDeviceMap,
Map<String, ClientUnitPO> dbClientMap) {
int missingAttachmentCount = 0;
Map<String, TestReportLedgerExcelParser.ParsedDeviceRow> sheetDeviceMap = ledger.toDeviceMap();
Map<String, TestReportLedgerExcelParser.ParsedClientRow> sheetClientMap = ledger.toClientMap();
for (TestReportLedgerExcelParser.ParsedPointRow pointRow : ledger.getPoints()) {
TestReportLedgerValidatePointVO pointVO = new TestReportLedgerValidatePointVO();
pointVO.setRowNo(pointRow.getRowNo());
pointVO.setSubstationName(pointRow.getSubstationName());
pointVO.setPointName(pointRow.getPointName());
pointVO.setPointKey(pointRow.getSubstationName() + "|" + pointRow.getPointName());
TestReportLedgerValidateAttachmentVO excelAttachment = buildAttachmentRow(
pointRow.getExcelAttachmentName(), "excel", sessionFileNameSet.contains(pointRow.getExcelAttachmentName()));
TestReportLedgerValidateAttachmentVO wordAttachment = buildAttachmentRow(
pointRow.getWordAttachmentName(), "word", sessionFileNameSet.contains(pointRow.getWordAttachmentName()));
pointVO.getAttachments().add(excelAttachment);
pointVO.getAttachments().add(wordAttachment);
int pointMissingCount = 0;
if (Boolean.TRUE.equals(excelAttachment.getMissing())) {
pointMissingCount++;
}
if (Boolean.TRUE.equals(wordAttachment.getMissing())) {
pointMissingCount++;
}
pointVO.setMissingAttachmentCount(pointMissingCount);
fillReferenceValidationResult(pointVO, pointRow, dbDeviceMap, dbClientMap, sheetDeviceMap, sheetClientMap);
pointVO.setComplete(pointMissingCount == 0
&& !Boolean.FALSE.equals(pointVO.getTestDeviceNoValid())
&& !Boolean.FALSE.equals(pointVO.getClientUnitNameValid()));
missingAttachmentCount += pointMissingCount;
result.getPoints().add(pointVO);
}
result.setPointCount(result.getPoints().size());
result.setMissingAttachmentCount(missingAttachmentCount);
}
private void fillReferenceValidationResult(TestReportLedgerValidatePointVO pointVO,
TestReportLedgerExcelParser.ParsedPointRow pointRow,
Map<String, TestDevicePO> dbDeviceMap,
Map<String, ClientUnitPO> dbClientMap,
Map<String, TestReportLedgerExcelParser.ParsedDeviceRow> sheetDeviceMap,
Map<String, TestReportLedgerExcelParser.ParsedClientRow> sheetClientMap) {
if (StrUtil.isBlank(pointRow.getTestDeviceNo())) {
pointVO.setTestDeviceNoValid(Boolean.TRUE);
pointVO.setTestDeviceNoMessage("检测设备编号为空,跳过校验");
} else if (dbDeviceMap.containsKey(pointRow.getTestDeviceNo()) || sheetDeviceMap.containsKey(pointRow.getTestDeviceNo())) {
pointVO.setTestDeviceNoValid(Boolean.TRUE);
pointVO.setTestDeviceNoMessage("检测设备编号校验通过");
} else {
pointVO.setTestDeviceNoValid(Boolean.FALSE);
pointVO.setTestDeviceNoMessage("" + pointRow.getRowNo() + "行检测设备编号在数据库和检测设备sheet中都不存在");
}
if (StrUtil.isBlank(pointRow.getClientUnitName())) {
pointVO.setClientUnitNameValid(Boolean.TRUE);
pointVO.setClientUnitNameMessage("委托单位名称为空,跳过校验");
} else if (dbClientMap.containsKey(pointRow.getClientUnitName()) || sheetClientMap.containsKey(pointRow.getClientUnitName())) {
pointVO.setClientUnitNameValid(Boolean.TRUE);
pointVO.setClientUnitNameMessage("委托单位名称校验通过");
} else {
pointVO.setClientUnitNameValid(Boolean.FALSE);
pointVO.setClientUnitNameMessage("" + pointRow.getRowNo() + "行委托单位名称在数据库和委托单位sheet中都不存在");
}
}
private TestReportLedgerValidateAttachmentVO buildAttachmentRow(String attachmentName, String attachmentType,
boolean uploaded) {
TestReportLedgerValidateAttachmentVO attachmentVO = new TestReportLedgerValidateAttachmentVO();
attachmentVO.setAttachmentName(attachmentName);
attachmentVO.setAttachmentType(attachmentType);
attachmentVO.setReferenced(Boolean.TRUE);
attachmentVO.setUploaded(uploaded);
attachmentVO.setMissing(!uploaded);
attachmentVO.setReuploadAllowed(Boolean.TRUE);
attachmentVO.setMessage(uploaded ? "宸蹭笂浼?" : "缂哄皯闄勪欢锛屽彲缁х画涓婁紶");
return attachmentVO;
}
private List<String> resolveOrphanAttachmentNames(TestReportLedgerExcelParser.ParsedLedger ledger, Set<String> sessionFileNameSet) {
Set<String> referencedNames = new HashSet<String>();
for (TestReportLedgerExcelParser.ParsedPointRow pointRow : ledger.getPoints()) {
if (StrUtil.isNotBlank(pointRow.getExcelAttachmentName())) {
referencedNames.add(pointRow.getExcelAttachmentName());
}
if (StrUtil.isNotBlank(pointRow.getWordAttachmentName())) {
referencedNames.add(pointRow.getWordAttachmentName());
}
}
List<String> orphanAttachmentNames = new java.util.ArrayList<String>();
for (String fileName : sessionFileNameSet) {
if (TestReportConst.LEDGER_SUMMARY_FILE_NAME.equals(fileName)) {
continue;
}
if (!referencedNames.contains(fileName)) {
orphanAttachmentNames.add(fileName);
}
}
return orphanAttachmentNames;
}
private void persistValidateSessionResult(String sessionId, TestReportLedgerValidateResultVO result) {
try {
testReportStorageService.writeValidateSessionResult(sessionId, OBJECT_MAPPER.writeValueAsString(result));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "鏍¢獙鏂囦欢闃舵锛氫繚瀛樻牎楠岀粨鏋滃け璐?");
}
}
private boolean hasReferenceErrors(List<TestReportLedgerValidatePointVO> points) {
if (points == null || points.isEmpty()) {
return false;
}
for (TestReportLedgerValidatePointVO point : points) {
if (point == null) {
continue;
}
if (Boolean.FALSE.equals(point.getTestDeviceNoValid()) || Boolean.FALSE.equals(point.getClientUnitNameValid())) {
return true;
}
}
return false;
}
private void appendReferenceFailReasons(TestReportLedgerValidateResultVO result) {
if (result == null || result.getPoints() == null || result.getPoints().isEmpty()) {
return;
}
boolean hasDeviceError = false;
boolean hasClientError = false;
for (TestReportLedgerValidatePointVO point : result.getPoints()) {
if (point == null) {
continue;
}
if (Boolean.FALSE.equals(point.getTestDeviceNoValid())) {
hasDeviceError = true;
}
if (Boolean.FALSE.equals(point.getClientUnitNameValid())) {
hasClientError = true;
}
}
if (hasDeviceError) {
result.getFailReasons().add("监测点信息中存在检测设备编号未匹配到数据库或检测设备sheet的记录");
}
if (hasClientError) {
result.getFailReasons().add("监测点信息中存在委托单位名称未匹配到数据库或委托单位sheet的记录");
}
}
private void writeValidatedBatchMeta(String reportId, String batchId, TestReportStorageService.UploadedBatch uploadedBatch,
TestReportLedgerExcelParser.ParsedLedger ledger, String userId) {
BatchMeta batchMeta = new BatchMeta();
batchMeta.setBatchId(batchId);
batchMeta.setReportId(reportId);
batchMeta.setStatus(BATCH_STATUS_VALIDATED);
batchMeta.setSummaryFileName(ledger.getSummaryFileName());
batchMeta.setTempStoragePath(uploadedBatch.getTempStoragePath());
batchMeta.setUploadedFileCount(uploadedBatch.getStoredFileMap().size());
batchMeta.setUploadedFileNames(uploadedBatch.getStoredFileMap().keySet().stream().collect(Collectors.toList()));
batchMeta.setValidatedBy(userId);
batchMeta.setValidatedTime(LocalDateTime.now().toString());
try {
testReportStorageService.writeTempBatchMeta(reportId, batchId, OBJECT_MAPPER.writeValueAsString(batchMeta));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "校验文件阶段:保存批次元数据失败");
}
}
private BatchMeta loadValidatedBatchMeta(String reportId, String batchId) {
if (StrUtil.isBlank(batchId)) {
throw new BusinessException(CommonResponseEnum.FAIL, "导入批次ID不能为空");
}
String metaText = testReportStorageService.readTempBatchMeta(reportId, batchId);
if (StrUtil.isBlank(metaText)) {
throw new BusinessException(CommonResponseEnum.FAIL, "导入批次不存在或未通过校验");
}
try {
BatchMeta batchMeta = OBJECT_MAPPER.readValue(metaText, BatchMeta.class);
if (!reportId.equals(batchMeta.getReportId()) || !BATCH_STATUS_VALIDATED.equals(batchMeta.getStatus())) {
throw new BusinessException(CommonResponseEnum.FAIL, "导入批次不存在或未通过校验");
}
return batchMeta;
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取导入批次信息失败");
}
}
private Map<String, MultipartFile> normalizeFileMap(MultipartFile[] files) {
if (files == null || files.length == 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "选择文件阶段:导入文件不能为空");
}
Map<String, MultipartFile> fileMap = new LinkedHashMap<String, MultipartFile>();
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) {
continue;
}
String fileName = normalizeFileName(file.getOriginalFilename());
if (fileName == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "选择文件阶段:文件名不能为空");
}
if (fileMap.containsKey(fileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "选择文件阶段:存在重复文件名 " + fileName);
}
fileMap.put(fileName, file);
}
if (fileMap.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "选择文件阶段:导入文件不能为空");
}
return fileMap;
}
private void validatePointReferences(List<TestReportLedgerExcelParser.ParsedPointRow> points,
Map<String, TestDevicePO> dbDeviceMap,
Map<String, ClientUnitPO> dbClientMap,
Map<String, TestReportLedgerExcelParser.ParsedDeviceRow> sheetDeviceMap,
Map<String, TestReportLedgerExcelParser.ParsedClientRow> sheetClientMap) {
for (TestReportLedgerExcelParser.ParsedPointRow point : points) {
if (StrUtil.isNotBlank(point.getTestDeviceNo())
&& !dbDeviceMap.containsKey(point.getTestDeviceNo())
&& !sheetDeviceMap.containsKey(point.getTestDeviceNo())) {
throw new BusinessException(CommonResponseEnum.FAIL,
"校验文件阶段:第" + point.getRowNo() + "行检测设备编号在数据库和检测设备sheet中都不存在");
}
if (StrUtil.isNotBlank(point.getClientUnitName())
&& !dbClientMap.containsKey(point.getClientUnitName())
&& !sheetClientMap.containsKey(point.getClientUnitName())) {
throw new BusinessException(CommonResponseEnum.FAIL,
"校验文件阶段:第" + point.getRowNo() + "行委托单位名称在数据库和委托单位sheet中都不存在");
}
}
}
private BaseDataSummary maintainBaseData(TestReportLedgerExcelParser.ParsedLedger ledger,
Map<String, TestDevicePO> dbDeviceMap,
Map<String, ClientUnitPO> dbClientMap,
String userId) {
BaseDataSummary summary = new BaseDataSummary();
Map<String, String> typeNameIdMap = loadDeviceTypeNameIdMap();
LocalDateTime now = LocalDateTime.now();
for (TestReportLedgerExcelParser.ParsedDeviceRow deviceRow : ledger.getDevices()) {
TestDevicePO existingDevice = dbDeviceMap.get(deviceRow.getNo());
if (existingDevice != null) {
summary.setDeviceReuseCount(summary.getDeviceReuseCount() + 1);
continue;
}
String typeName = normalizeFileName(deviceRow.getTypeName());
String typeId = StrUtil.isBlank(typeName) ? null : typeNameIdMap.get(typeName);
if (StrUtil.isNotBlank(typeName) && StrUtil.isBlank(typeId)) {
throw new BusinessException(CommonResponseEnum.FAIL,
"基础信息维护阶段检测设备sheet第" + deviceRow.getRowNo() + "行设备型号不存在");
}
TestDevicePO device = new TestDevicePO();
device.setId(UUID.randomUUID().toString());
device.setType(typeId);
device.setNo(deviceRow.getNo());
device.setValidityPeriod(deviceRow.getValidityPeriod());
device.setValidityReport(deviceRow.getValidityReportName());
device.setState(resolveDeviceState(deviceRow.getStateName(), deviceRow.getRowNo()));
device.setStatus(TestDeviceConst.STATUS_NORMAL);
device.setCreateBy(userId);
device.setCreateTime(now);
device.setUpdateBy(userId);
device.setUpdateTime(now);
testDeviceMapper.insert(device);
dbDeviceMap.put(device.getNo(), device);
summary.setDeviceAddedCount(summary.getDeviceAddedCount() + 1);
}
for (TestReportLedgerExcelParser.ParsedClientRow clientRow : ledger.getClients()) {
ClientUnitPO existingClient = dbClientMap.get(clientRow.getName());
if (existingClient != null) {
summary.setClientReuseCount(summary.getClientReuseCount() + 1);
continue;
}
ClientUnitPO client = new ClientUnitPO();
client.setId(UUID.randomUUID().toString());
client.setName(clientRow.getName());
client.setContactName(defaultEmpty(clientRow.getContactName()));
client.setPhonenumber(defaultEmpty(clientRow.getPhonenumber()));
client.setAddress(clientRow.getAddress());
client.setStatus(ClientUnitConst.STATUS_NORMAL);
client.setCreateBy(userId);
client.setCreateTime(now);
client.setUpdateBy(userId);
client.setUpdateTime(now);
clientUnitMapper.insert(client);
dbClientMap.put(client.getName(), client);
summary.setClientAddedCount(summary.getClientAddedCount() + 1);
}
return summary;
}
private int saveNewPoints(String reportId, String batchId, List<TestReportLedgerExcelParser.ParsedPointRow> points,
String userId) {
LocalDateTime now = LocalDateTime.now();
Set<Integer> groupNos = new HashSet<Integer>();
int sortNo = 1;
for (TestReportLedgerExcelParser.ParsedPointRow pointRow : points) {
TestReportPointPO point = new TestReportPointPO();
point.setId(UUID.randomUUID().toString());
point.setReportId(reportId);
point.setGroupNo(pointRow.getGroupNo());
point.setSubstationName(pointRow.getSubstationName());
point.setPointName(pointRow.getPointName());
point.setExcelAttachmentName(pointRow.getExcelAttachmentName());
point.setExcelStoragePath(testReportStorageService.savePointExcelFromTemp(reportId, batchId,
pointRow.getExcelAttachmentName()));
point.setSortNo(sortNo++);
point.setDeleted(TestReportConst.DELETED_NO);
point.setCreateBy(userId);
point.setCreateTime(now);
point.setUpdateBy(userId);
point.setUpdateTime(now);
testReportPointMapper.insert(point);
if (pointRow.getGroupNo() != null) {
groupNos.add(pointRow.getGroupNo());
}
}
return groupNos.size();
}
private void deleteOldFiles(List<TestReportPointPO> oldPoints, List<TestReportGroupReportPO> oldGroupReports, String data) {
for (TestReportPointPO point : oldPoints) {
testReportStorageService.deleteQuietly(point.getExcelStoragePath());
}
for (TestReportGroupReportPO groupReport : oldGroupReports) {
testReportStorageService.deleteQuietly(groupReport.getReportStoragePath());
}
testReportStorageService.deleteQuietly(resolveSummaryStoragePath(data));
}
private void removeOldRows(String reportId) {
LambdaQueryWrapper<TestReportPointPO> pointWrapper = new LambdaQueryWrapper<TestReportPointPO>();
pointWrapper.eq(TestReportPointPO::getReportId, reportId);
testReportPointMapper.delete(pointWrapper);
LambdaQueryWrapper<TestReportGroupReportPO> groupReportWrapper = new LambdaQueryWrapper<TestReportGroupReportPO>();
groupReportWrapper.eq(TestReportGroupReportPO::getReportId, reportId);
testReportGroupReportMapper.delete(groupReportWrapper);
}
private String buildSnapshotJson(TestReportLedgerExcelParser.ParsedLedger ledger, String summaryStoragePath,
BaseDataSummary summary, int groupCount) {
ObjectNode root = OBJECT_MAPPER.createObjectNode();
root.put("version", "2.0");
root.put("importMode", "summary-excel-with-base-data");
root.put("summaryFileName", ledger.getSummaryFileName());
root.put("summaryStoragePath", summaryStoragePath);
ObjectNode sheetSummary = root.putObject("sheetSummary");
sheetSummary.put("pointSheet", TestReportConst.LEDGER_POINT_SHEET_NAME);
sheetSummary.put("deviceSheetPresent", ledger.getExistingSheets().contains(TestReportConst.LEDGER_DEVICE_SHEET_NAME));
sheetSummary.put("clientSheetPresent", ledger.getExistingSheets().contains(TestReportConst.LEDGER_CLIENT_SHEET_NAME));
root.put("rowCount", ledger.getPoints().size());
root.put("pointCount", ledger.getPoints().size());
root.put("groupCount", groupCount);
ArrayNode groupSummary = root.putArray("groupSummary");
Map<Integer, Long> groupCounter = ledger.getPoints().stream()
.collect(Collectors.groupingBy(TestReportLedgerExcelParser.ParsedPointRow::getGroupNo,
LinkedHashMap::new, Collectors.counting()));
for (Map.Entry<Integer, Long> entry : groupCounter.entrySet()) {
ObjectNode groupNode = groupSummary.addObject();
if (entry.getKey() == null) {
groupNode.putNull("groupNo");
} else {
groupNode.put("groupNo", entry.getKey());
}
groupNode.put("count", entry.getValue());
}
ObjectNode baseDataSummary = root.putObject("baseDataSummary");
baseDataSummary.put("deviceAddedCount", summary.getDeviceAddedCount());
baseDataSummary.put("deviceReuseCount", summary.getDeviceReuseCount());
baseDataSummary.put("clientAddedCount", summary.getClientAddedCount());
baseDataSummary.put("clientReuseCount", summary.getClientReuseCount());
return root.toString();
}
private TestReportLedgerImportResultVO buildValidateSuccessResult(String batchId, String tempStoragePath,
TestReportLedgerExcelParser.ParsedLedger ledger,
int uploadedFileCount) {
TestReportLedgerImportResultVO result = new TestReportLedgerImportResultVO();
result.setBatchId(batchId);
result.setCurrentStage("文件校验");
result.setSuccess(Boolean.TRUE);
result.setSummaryFileName(ledger.getSummaryFileName());
result.setTempStoragePath(tempStoragePath);
result.setUploadedFileCount(uploadedFileCount);
result.setTotalFileCount(uploadedFileCount);
result.setPointCount(ledger.getPoints().size());
result.setExcelAttachmentCount(ledger.getPoints().size());
result.setWordAttachmentCount(ledger.getPoints().size());
result.getStages().add(buildStage("文件选择", "文件选择完成"));
result.getStages().add(buildStage("文件校验", "文件校验通过"));
return result;
}
private TestReportLedgerImportResultVO buildImportSuccessResult(String batchId, String tempStoragePath,
TestReportLedgerExcelParser.ParsedLedger ledger,
BaseDataSummary summary, int uploadedFileCount,
int groupCount) {
TestReportLedgerImportResultVO result = new TestReportLedgerImportResultVO();
result.setBatchId(batchId);
result.setCurrentStage("完成");
result.setSuccess(Boolean.TRUE);
result.setSummaryFileName(ledger.getSummaryFileName());
result.setTempStoragePath(tempStoragePath);
result.setUploadedFileCount(uploadedFileCount);
result.setTotalFileCount(uploadedFileCount);
result.setPointCount(ledger.getPoints().size());
result.setExcelAttachmentCount(ledger.getPoints().size());
result.setWordAttachmentCount(ledger.getPoints().size());
result.setDeviceAddedCount(summary.getDeviceAddedCount());
result.setDeviceReuseCount(summary.getDeviceReuseCount());
result.setClientAddedCount(summary.getClientAddedCount());
result.setClientReuseCount(summary.getClientReuseCount());
result.setGroupCount(groupCount);
result.getStages().add(buildStage("文件选择", "文件选择完成"));
result.getStages().add(buildStage("文件校验", "已复用已校验批次"));
result.getStages().add(buildStage("文件导入", "正式文件和点位数据已导入"));
result.getStages().add(buildStage("基础信息维护", "基础信息维护完成"));
result.getStages().add(buildStage("完成", "导入完成"));
return result;
}
private TestReportLedgerImportStageVO buildStage(String stage, String message) {
TestReportLedgerImportStageVO stageVO = new TestReportLedgerImportStageVO();
stageVO.setStage(stage);
stageVO.setSuccess(Boolean.TRUE);
stageVO.setMessage(message);
return stageVO;
}
private String resolveSummaryStoragePath(String data) {
if (StrUtil.isBlank(data)) {
return null;
}
try {
String summaryStoragePath = OBJECT_MAPPER.readTree(data).path("summaryStoragePath").asText();
return StrUtil.isBlank(summaryStoragePath) ? null : summaryStoragePath;
} catch (Exception exception) {
return null;
}
}
private List<TestReportPointPO> loadNormalPoints(String reportId) {
LambdaQueryWrapper<TestReportPointPO> wrapper = new LambdaQueryWrapper<TestReportPointPO>();
wrapper.eq(TestReportPointPO::getReportId, reportId)
.eq(TestReportPointPO::getDeleted, TestReportConst.DELETED_NO);
return testReportPointMapper.selectList(wrapper);
}
private List<TestReportGroupReportPO> loadNormalGroupReports(String reportId) {
LambdaQueryWrapper<TestReportGroupReportPO> wrapper = new LambdaQueryWrapper<TestReportGroupReportPO>();
wrapper.eq(TestReportGroupReportPO::getReportId, reportId)
.eq(TestReportGroupReportPO::getDeleted, TestReportConst.DELETED_NO);
return testReportGroupReportMapper.selectList(wrapper);
}
private Map<String, TestDevicePO> loadExistingDevices(Collection<String> deviceNos) {
if (deviceNos.isEmpty()) {
return new HashMap<String, TestDevicePO>();
}
LambdaQueryWrapper<TestDevicePO> wrapper = new LambdaQueryWrapper<TestDevicePO>();
wrapper.in(TestDevicePO::getNo, deviceNos)
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL);
return testDeviceMapper.selectList(wrapper).stream()
.collect(Collectors.toMap(TestDevicePO::getNo, item -> item, (first, second) -> first, HashMap::new));
}
private Map<String, ClientUnitPO> loadExistingClients(Collection<String> clientNames) {
if (clientNames.isEmpty()) {
return new HashMap<String, ClientUnitPO>();
}
LambdaQueryWrapper<ClientUnitPO> wrapper = new LambdaQueryWrapper<ClientUnitPO>();
wrapper.in(ClientUnitPO::getName, clientNames)
.eq(ClientUnitPO::getStatus, ClientUnitConst.STATUS_NORMAL);
return clientUnitMapper.selectList(wrapper).stream()
.collect(Collectors.toMap(ClientUnitPO::getName, item -> item, (first, second) -> first, HashMap::new));
}
private Collection<String> collectDeviceNos(TestReportLedgerExcelParser.ParsedLedger ledger) {
Set<String> deviceNos = new HashSet<String>();
for (TestReportLedgerExcelParser.ParsedPointRow point : ledger.getPoints()) {
if (StrUtil.isNotBlank(point.getTestDeviceNo())) {
deviceNos.add(point.getTestDeviceNo());
}
}
for (TestReportLedgerExcelParser.ParsedDeviceRow device : ledger.getDevices()) {
deviceNos.add(device.getNo());
}
return deviceNos;
}
private Collection<String> collectClientNames(TestReportLedgerExcelParser.ParsedLedger ledger) {
Set<String> clientNames = new HashSet<String>();
for (TestReportLedgerExcelParser.ParsedPointRow point : ledger.getPoints()) {
if (StrUtil.isNotBlank(point.getClientUnitName())) {
clientNames.add(point.getClientUnitName());
}
}
for (TestReportLedgerExcelParser.ParsedClientRow client : ledger.getClients()) {
clientNames.add(client.getName());
}
return clientNames;
}
private Map<String, String> loadDeviceTypeNameIdMap() {
List<TestDeviceTypeNameVO> typeRows = testDeviceMapper.selectNormalDeviceTypes();
if (typeRows == null || typeRows.isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> result = new HashMap<String, String>();
for (TestDeviceTypeNameVO typeRow : typeRows) {
if (typeRow != null && StrUtil.isNotBlank(typeRow.getName()) && StrUtil.isNotBlank(typeRow.getId())) {
result.put(typeRow.getName(), typeRow.getId());
}
}
return result;
}
private String resolveDeviceState(String stateName, Integer rowNo) {
String normalizedState = normalizeFileName(stateName);
if (normalizedState == null) {
return null;
}
if ("01".equals(normalizedState) || "在运".equals(normalizedState)) {
return TestDeviceConst.STATE_RUNNING;
}
if ("02".equals(normalizedState) || "退运".equals(normalizedState)) {
return TestDeviceConst.STATE_RETIRED;
}
throw new BusinessException(CommonResponseEnum.FAIL,
"基础信息维护阶段检测设备sheet第" + rowNo + "行设备状态不正确");
}
private String defaultEmpty(String value) {
return value == null ? "" : value;
}
private String normalizeFileName(String fileName) {
String normalized = fileName == null ? null : fileName.trim();
return StrUtil.isBlank(normalized) ? null : normalized;
}
private static class BaseDataSummary {
private int deviceAddedCount;
private int deviceReuseCount;
private int clientAddedCount;
private int clientReuseCount;
public int getDeviceAddedCount() {
return deviceAddedCount;
}
public void setDeviceAddedCount(int deviceAddedCount) {
this.deviceAddedCount = deviceAddedCount;
}
public int getDeviceReuseCount() {
return deviceReuseCount;
}
public void setDeviceReuseCount(int deviceReuseCount) {
this.deviceReuseCount = deviceReuseCount;
}
public int getClientAddedCount() {
return clientAddedCount;
}
public void setClientAddedCount(int clientAddedCount) {
this.clientAddedCount = clientAddedCount;
}
public int getClientReuseCount() {
return clientReuseCount;
}
public void setClientReuseCount(int clientReuseCount) {
this.clientReuseCount = clientReuseCount;
}
}
public static class BatchMeta {
private String batchId;
private String reportId;
private String status;
private String summaryFileName;
private String tempStoragePath;
private Integer uploadedFileCount;
private List<String> uploadedFileNames;
private String validatedBy;
private String validatedTime;
public Set<String> toFileNameSet() {
return uploadedFileNames == null ? Collections.<String>emptySet() : new HashSet<String>(uploadedFileNames);
}
public String getBatchId() {
return batchId;
}
public void setBatchId(String batchId) {
this.batchId = batchId;
}
public String getReportId() {
return reportId;
}
public void setReportId(String reportId) {
this.reportId = reportId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getSummaryFileName() {
return summaryFileName;
}
public void setSummaryFileName(String summaryFileName) {
this.summaryFileName = summaryFileName;
}
public String getTempStoragePath() {
return tempStoragePath;
}
public void setTempStoragePath(String tempStoragePath) {
this.tempStoragePath = tempStoragePath;
}
public Integer getUploadedFileCount() {
return uploadedFileCount;
}
public void setUploadedFileCount(Integer uploadedFileCount) {
this.uploadedFileCount = uploadedFileCount;
}
public List<String> getUploadedFileNames() {
return uploadedFileNames;
}
public void setUploadedFileNames(List<String> uploadedFileNames) {
this.uploadedFileNames = uploadedFileNames;
}
public String getValidatedBy() {
return validatedBy;
}
public void setValidatedBy(String validatedBy) {
this.validatedBy = validatedBy;
}
public String getValidatedTime() {
return validatedTime;
}
public void setValidatedTime(String validatedTime) {
this.validatedTime = validatedTime;
}
}
}

View File

@@ -0,0 +1,64 @@
package com.njcn.gather.aireport.testreport.component;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.testreport.pojo.constant.TestReportConst;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
@Component
public class TestReportLedgerTemplateService {
public byte[] buildTemplate() {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
writeGuideSheet(workbook.createSheet(TestReportConst.LEDGER_GUIDE_SHEET_NAME));
writeHeaders(workbook, workbook.createSheet(TestReportConst.LEDGER_POINT_SHEET_NAME),
Arrays.asList("分组号", "变电站", "监测点名称", "检测设备编号", "委托单位名称", "Excel附件名称", "Word附件名称"));
writeHeaders(workbook, workbook.createSheet(TestReportConst.LEDGER_DEVICE_SHEET_NAME),
Arrays.asList("设备型号", "设备编号", "标准有效期", "设备状态", "标准报告文件名"));
writeHeaders(workbook, workbook.createSheet(TestReportConst.LEDGER_CLIENT_SHEET_NAME),
Arrays.asList("单位名称", "联系人", "联系方式", "地址"));
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "下载模板失败");
}
}
private void writeGuideSheet(XSSFSheet sheet) {
Row row0 = sheet.createRow(0);
row0.createCell(0).setCellValue("1. 汇总文件名固定为台账信息汇总.xlsx");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("2. 监测点信息sheet必填检测设备/委托单位sheet按需填写");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("3. 每个监测点必须同时填写Excel附件名称和Word附件名称");
Row row3 = sheet.createRow(3);
row3.createCell(0).setCellValue("4. 检测设备编号、委托单位名称优先匹配数据库未命中时再匹配本次sheet");
sheet.setColumnWidth(0, 120 * 256);
}
private void writeHeaders(XSSFWorkbook workbook, XSSFSheet sheet, List<String> headers) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.size(); i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers.get(i));
cell.setCellStyle(style);
sheet.setColumnWidth(i, 24 * 256);
}
}
}

View File

@@ -0,0 +1,108 @@
package com.njcn.gather.aireport.testreport.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import lombok.Data;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class TestReportPointExcelParser {
private static final String NODE_INFO_SHEET_NAME = "节点信息";
public ParsedPoint parse(String fileName, InputStream inputStream) {
try (Workbook workbook = WorkbookFactory.create(inputStream)) {
Sheet sheet = workbook.getSheet(NODE_INFO_SHEET_NAME);
if (sheet == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "原始Excel缺少节点信息sheet");
}
return parseNodeInfoSheet(fileName, sheet);
} catch (BusinessException exception) {
throw exception;
} catch (IOException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取原始Excel失败");
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "解析原始Excel失败");
}
}
private ParsedPoint parseNodeInfoSheet(String fileName, Sheet sheet) {
Map<String, String> keyValueMap = new LinkedHashMap<String, String>();
DataFormatter formatter = new DataFormatter();
for (Row row : sheet) {
short lastCellNum = row.getLastCellNum();
if (lastCellNum < 2) {
continue;
}
for (int i = 0; i < lastCellNum - 1; i++) {
String key = trim(formatter.formatCellValue(row.getCell(i)));
String value = trim(formatter.formatCellValue(row.getCell(i + 1)));
if (StrUtil.isNotBlank(key) && StrUtil.isNotBlank(value) && !keyValueMap.containsKey(key)) {
keyValueMap.put(key, value);
}
}
}
ParsedPoint point = new ParsedPoint();
point.setExcelAttachmentName(fileName);
point.setSubstationName(requireAny(keyValueMap, "变电站", "厂站名称", "变电站名称"));
point.setPointName(requireAny(keyValueMap, "监测点名称", "节点名称", "监测点"));
point.setMonitorTime(optionalAny(keyValueMap, "监测时间", "录波时间", "开始时间"));
point.setVoltageLevel(optionalAny(keyValueMap, "电压等级", "电压"));
point.setPtRatio(optionalAny(keyValueMap, "PT变比", "PT"));
point.setCtRatio(optionalAny(keyValueMap, "CT变比", "CT"));
point.setBaseShortCircuitCapacity(optionalAny(keyValueMap, "基准短路容量"));
point.setMinShortCircuitCapacity(optionalAny(keyValueMap, "最小短路容量"));
point.setProtocolCapacity(optionalAny(keyValueMap, "用户协议容量"));
point.setPowerSupplyCapacity(optionalAny(keyValueMap, "供电设备容量"));
return point;
}
private String requireAny(Map<String, String> keyValueMap, String... candidateKeys) {
String value = optionalAny(keyValueMap, candidateKeys);
if (StrUtil.isBlank(value)) {
throw new BusinessException(CommonResponseEnum.FAIL, "节点信息sheet缺少必要字段");
}
return value;
}
private String optionalAny(Map<String, String> keyValueMap, String... candidateKeys) {
for (String candidateKey : candidateKeys) {
String value = keyValueMap.get(candidateKey);
if (StrUtil.isNotBlank(value)) {
return value;
}
}
return null;
}
private String trim(String value) {
return StrUtil.trimToNull(value);
}
@Data
public static class ParsedPoint {
private String excelAttachmentName;
private String substationName;
private String pointName;
private String monitorTime;
private String voltageLevel;
private String ptRatio;
private String ctRatio;
private String baseShortCircuitCapacity;
private String minShortCircuitCapacity;
private String protocolCapacity;
private String powerSupplyCapacity;
}
}

Some files were not shown because too many files have changed in this diff Show More