diff --git a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/constant/RedisConst.java b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/constant/RedisConst.java index 9561e4e..cd82dd3 100644 --- a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/constant/RedisConst.java +++ b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/constant/RedisConst.java @@ -13,4 +13,5 @@ public class RedisConst { public static final String TASK_RECORD_CNT = "TASK_RECORD_CNT"; public static final String TASK_CURRENT_CODE = "TASK_CURRENT_CODE@"; public static final String IMMEDIATELY_EXEC_TASK_TIME = "IMMEDIATELY_EXEC_TASK_TIME"; + public static final String REPORT_PUSH_LOCK = "REPORT_PUSH_LOCK@";//任务完成自动归档+报告推送 防重锁 } diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java index 4c11d4a..2700d94 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/config/RabbitMQConfig.java @@ -25,7 +25,25 @@ public class RabbitMQConfig { @Bean public Binding alarmBinding() { - return BindingBuilder.bind(alarmQueue()).to(alarmExchange()).with(YanyuanConstants.EXCHANGE); + return BindingBuilder.bind(alarmQueue()).to(alarmExchange()).with(YanyuanConstants.ROUTING_KEY); + } + + // ============================ 巡检报告上传推送 MQ ============================ + + @Bean + public TopicExchange reportExchange() { + return new TopicExchange(YanyuanConstants.REPORT_EXCHANGE, true, false); + } + + @Bean("reportUploadQueue") + public Queue reportUploadQueue() { + return new Queue(YanyuanConstants.REPORT_QUEUE, true); + } + + @Bean + public Binding reportUploadBinding() { + return BindingBuilder.bind(reportUploadQueue()).to(reportExchange()) + .with(YanyuanConstants.REPORT_ROUTING_KEY); } @Bean diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java index ec7758e..67bb27e 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/constant/YanyuanConstants.java @@ -7,4 +7,9 @@ public class YanyuanConstants { public static final String EXCHANGE = "alarm.exchange"; public static final String QUEUE = "alarm.queue"; public static final String ROUTING_KEY = "alarm.routing-key"; + + /** 巡检报告上传推送(先上报MQ,再由消费者HTTP上传外部平台) */ + public static final String REPORT_EXCHANGE = "report.exchange"; + public static final String REPORT_QUEUE = "report.upload.queue"; + public static final String REPORT_ROUTING_KEY = "report.upload.routing-key"; } diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/AlarmMessage.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/AlarmMessage.java index 5c0fd73..d3e6425 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/AlarmMessage.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/AlarmMessage.java @@ -6,6 +6,8 @@ import lombok.*; @ToString @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class AlarmMessage extends BaseEntity { private String messageId; private String messageType; diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java new file mode 100644 index 0000000..6361247 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/domain/ReportPushMessage.java @@ -0,0 +1,26 @@ +package com.inspect.api.domain; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 巡检报告上传推送消息(先上报 report.upload.queue,由消费者异步 HTTP 上传外部平台) + */ +@Data +public class ReportPushMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 报告ID(inspection_report.line_id) */ + private Long reportId; + + /** 报告文件路径(SFTP,docx) */ + private String filePath; + + /** 报告详情(reportInfo JSON 字符串,发布时组装,消费者只负责下载文件并上传) */ + private String reportInfo; + + /** 消费重试次数(上传失败后 MQ 重投递计数) */ + private int retryCount; +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java new file mode 100644 index 0000000..79506d6 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportPushPublisher.java @@ -0,0 +1,34 @@ +package com.inspect.api.service; + +import com.inspect.api.constant.YanyuanConstants; +import com.inspect.api.domain.ReportPushMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +/** + * 巡检报告上传消息发布者:报告生成后先发布到 report.exchange, + * 由 {@link ReportUploadConsumer} 异步 HTTP 上传到外部平台。 + */ +@Slf4j +@Service +public class ReportPushPublisher { + + private final RabbitTemplate rabbitTemplate; + + public ReportPushPublisher(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + public void publish(ReportPushMessage message) { + try { + rabbitTemplate.convertAndSend(YanyuanConstants.REPORT_EXCHANGE, + YanyuanConstants.REPORT_ROUTING_KEY, message); + log.info("[REPORT_PUSH_MQ] 报告上传消息已发布: reportId={}, filePath={}", + message.getReportId(), message.getFilePath()); + } catch (Exception e) { + log.error("[REPORT_PUSH_MQ] 报告上传消息发布失败: reportId={}, err: {}", + message.getReportId(), e.getMessage(), e); + } + } +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java new file mode 100644 index 0000000..0bec7a2 --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadConsumer.java @@ -0,0 +1,137 @@ +package com.inspect.api.service; + +import com.alibaba.fastjson.JSONObject; +import com.inspect.api.domain.ReportPushMessage; +import com.inspect.base.core.sftp.SftpClient; +import com.inspect.base.core.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; + +/** + * 巡检报告上传消费者:从 report.upload.queue 取消息,下载报告 docx, + * 通过 {@link ReportUploadService} HTTP 上传到外部平台(POST /jb-nessp-external/api/uploadReport)。 + *

+ * 上传失败抛出异常使消息重投递(重试次数由消息 retryCount 控制,超过 {@code mq-max-retries} 丢弃)。 + */ +@Slf4j +@Component +public class ReportUploadConsumer { + + /** 上传失败最大重试次数(超过后丢弃,避免无限重投) */ + @Value("${inspect.report-push.mq-max-retries:3}") + private int maxRetries; + + /** 报告文件(SFTP)下载重试次数/间隔(毫秒),防止异步上传尚未完成 */ + @Value("${inspect.report-push.download-retry-times:3}") + private int downloadRetryTimes; + + @Value("${inspect.report-push.download-retry-interval-millis:1000}") + private long downloadRetryIntervalMillis; + + private final ReportUploadService reportUploadService; + private final SftpClient sftpClient; + + public ReportUploadConsumer(ReportUploadService reportUploadService, SftpClient sftpClient) { + this.reportUploadService = reportUploadService; + this.sftpClient = sftpClient; + } + + @RabbitListener(queues = "#{reportUploadQueue.name}") + public void onReport(ReportPushMessage message) { + if (message == null || message.getReportId() == null) { + log.warn("[REPORT_PUSH_MQ] 报告上传消息为空,丢弃"); + return; + } + try { + boolean ok = upload(message); + if (ok) { + log.info("[REPORT_PUSH_MQ] 报告上传成功: reportId={}", message.getReportId()); + } else { + throw new RuntimeException("report upload failed, will be requeued"); + } + } catch (Exception e) { + message.setRetryCount(message.getRetryCount() + 1); + if (message.getRetryCount() > maxRetries) { + log.error("[REPORT_PUSH_MQ] 报告上传超过最大重试次数({}), 丢弃: reportId={}, err: {}", + maxRetries, message.getReportId(), e.getMessage()); + return; + } + log.warn("[REPORT_PUSH_MQ] 报告上传失败(第{}次), 消息重投递: reportId={}, err: {}", + message.getRetryCount(), message.getReportId(), e.getMessage()); + throw new RuntimeException("report upload failed, will be requeued", e); + } + } + + /** + * 下载报告 docx + HTTP 上传(reportInfo 由发布方组装好随消息传递) + */ + private boolean upload(ReportPushMessage message) throws Exception { + String filePath = message.getFilePath(); + if (StringUtils.isEmpty(filePath)) { + log.warn("[REPORT_PUSH_MQ] 报告文件路径为空, 丢弃: reportId={}", message.getReportId()); + return true; + } + byte[] fileBytes = downloadFromSftp(filePath); + if (fileBytes == null || fileBytes.length == 0) { + log.warn("[REPORT_PUSH_MQ] 下载报告文件失败: {}", filePath); + return false; + } + JSONObject reportInfo = JSONObject.parseObject(message.getReportInfo()); + if (reportInfo == null) { + log.warn("[REPORT_PUSH_MQ] reportInfo 为空, 丢弃: reportId={}", message.getReportId()); + return true; + } + String fileName = filePath.substring(filePath.lastIndexOf('/') + 1); + boolean ok = reportUploadService.uploadReport(fileBytes, fileName, reportInfo); + log.info("[REPORT_PUSH_MQ] 报告上传结果: {}, reportId={}, fileName={}", + ok, message.getReportId(), fileName); + return ok; + } + + /** + * 从 SFTP 下载报告文件(导出为异步上传,下载失败自动重试等待) + */ + private byte[] downloadFromSftp(String filePath) { + for (int i = 0; i < Math.max(1, downloadRetryTimes); i++) { + try { + final byte[][] holder = new byte[1][]; + sftpClient.downLoad(filePath, inputStream -> holder[0] = readAll(inputStream)); + if (holder[0] != null && holder[0].length > 0) { + return holder[0]; + } + log.warn("[REPORT_PUSH_MQ] 报告文件尚未就绪, 重试 {}/{}: {}", i + 1, downloadRetryTimes, filePath); + } catch (Exception e) { + log.warn("[REPORT_PUSH_MQ] 下载报告文件异常, 重试 {}/{}: {}, err: {}", + i + 1, downloadRetryTimes, filePath, e.getMessage()); + } + if (i < Math.max(1, downloadRetryTimes) - 1 && downloadRetryIntervalMillis > 0) { + try { + Thread.sleep(downloadRetryIntervalMillis); + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + break; + } + } + } + return null; + } + + private byte[] readAll(InputStream inputStream) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + try { + while ((n = inputStream.read(buf)) != -1) { + bos.write(buf, 0, n); + } + } finally { + inputStream.close(); + } + return bos.toByteArray(); + } +} diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java new file mode 100644 index 0000000..f34f5de --- /dev/null +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java @@ -0,0 +1,182 @@ +package com.inspect.api.service; + +import com.alibaba.fastjson.JSONObject; +import com.inspect.api.domain.ApiResult; +import com.inspect.api.domain.AuthTokenData; +import com.inspect.base.core.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.DataOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * 巡检报告上传服务(外部平台《巡检报告接口文档 v1.0.0》) + *

+ * 1. Joinbright-Token 复用 {@link ExtAuthService}(GET /ext_auth/getAuthToken,配置 yanyuan.auth.*) + * 2. POST {upload-url} 上传巡检报告: + * - 请求头携带 joinbright-token + * - multipart/form-data:file(报告文件) + reportInfo(报告详情 JSON 字符串) + */ +@Slf4j +@Service +public class ReportUploadService { + + /** Token 请求头名称(外部平台固定) */ + private static final String TOKEN_HEADER = "joinbright-token"; + + /** 是否启用报告上传推送 */ + @Value("${inspect.report-push.enabled:true}") + private boolean enabled; + + /** 巡检报告上传接口完整地址 */ + @Value("${inspect.report-push.upload-url:http://192.168.129.2:32799/jb-gateway/jb-nessp-external/api/uploadReport}") + private String uploadUrl; + + /** http 连接/读取超时(秒) */ + @Value("${inspect.report-push.http-timeout-seconds:60}") + private int httpTimeoutSeconds; + + private final ExtAuthService extAuthService; + + public ReportUploadService(ExtAuthService extAuthService) { + this.extAuthService = extAuthService; + } + + /** + * 获取 Joinbright-Token + */ + public String getJoinbrightToken() { + try { + ApiResult result = extAuthService.getAuthToken(); + if (result == null || !result.isSuccess() || result.getData() == null) { + log.error("[REPORT-PUSH] 获取 Joinbright-Token 失败: {}", result); + return null; + } + String token = result.getData().getAccess_token(); + log.info("[REPORT-PUSH] 获取 Joinbright-Token 成功: {}...", + StringUtils.isEmpty(token) ? "" : token.substring(0, Math.min(12, token.length()))); + return token; + } catch (Exception e) { + log.error("[REPORT-PUSH] 获取 Joinbright-Token 异常", e); + return null; + } + } + + /** + * 上传巡检报告文件及元数据到外部平台 + * + * @param fileBytes 报告文件字节(docx) + * @param fileName 报告文件名(含扩展名) + * @param reportInfo 报告详情对象(序列化为 JSON 字符串传递) + * @return 是否上传成功 + */ + public boolean uploadReport(byte[] fileBytes, String fileName, JSONObject reportInfo) { + if (!enabled) { + log.warn("[REPORT-PUSH] 报告上传未启用(inspect.report-push.enabled=false)"); + return false; + } + if (fileBytes == null || fileBytes.length == 0) { + log.warn("[REPORT-PUSH] 报告文件为空, 无法上传"); + return false; + } + if (reportInfo == null) { + log.warn("[REPORT-PUSH] reportInfo 为空, 无法上传"); + return false; + } + // 上传失败时(token可能失效/超时)重新获取token重试一次 + String token = getJoinbrightToken(); + boolean ok = doUpload(token, fileBytes, fileName, reportInfo); + if (!ok && StringUtils.isNotEmpty(token)) { + log.warn("[REPORT-PUSH] 首次上传失败, 重新获取token后重试"); + ok = doUpload(getJoinbrightToken(), fileBytes, fileName, reportInfo); + } + return ok; + } + + private boolean doUpload(String token, byte[] fileBytes, String fileName, JSONObject reportInfo) { + if (StringUtils.isEmpty(token)) { + log.error("[REPORT-PUSH] Joinbright-Token 为空, 无法上传"); + return false; + } + String boundary = "----InspectReportBoundary" + System.currentTimeMillis(); + HttpURLConnection conn = null; + try { + URL url = new URL(uploadUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setConnectTimeout(httpTimeoutSeconds * 1000); + conn.setReadTimeout(httpTimeoutSeconds * 1000); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + conn.setRequestProperty(TOKEN_HEADER, token); + + String reportInfoJson = reportInfo.toJSONString(); + try (DataOutputStream out = new DataOutputStream(conn.getOutputStream())) { + // reportInfo 字段 + out.writeBytes("--" + boundary + "\r\n"); + out.writeBytes("Content-Disposition: form-data; name=\"reportInfo\"\r\n"); + out.writeBytes("Content-Type: text/plain; charset=UTF-8\r\n\r\n"); + out.write(reportInfoJson.getBytes("UTF-8")); + out.writeBytes("\r\n"); + // file 字段 + out.writeBytes("--" + boundary + "\r\n"); + out.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n"); + out.writeBytes("Content-Type: application/octet-stream\r\n\r\n"); + try (InputStream in = new ByteArrayInputStream(fileBytes)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + } + out.writeBytes("\r\n"); + out.writeBytes("--" + boundary + "--\r\n"); + out.flush(); + } + + int responseCode = conn.getResponseCode(); + String response = readResponse(conn, responseCode); + log.info("[REPORT-PUSH] 上传响应 code: {}, body: {}", responseCode, response); + if (responseCode == 200) { + JSONObject resp = JSONObject.parseObject(response); + if (resp != null && Boolean.TRUE.equals(resp.getBoolean("success"))) { + return true; + } + log.error("[REPORT-PUSH] 上传业务失败: {}", response); + } else { + log.error("[REPORT-PUSH] 上传HTTP失败: {}, body: {}", responseCode, response); + } + return false; + } catch (Exception e) { + log.error("[REPORT-PUSH] 报告上传异常, url: {}", uploadUrl, e); + return false; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private String readResponse(HttpURLConnection conn, int responseCode) throws Exception { + InputStream stream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); + if (stream == null) { + return ""; + } + StringBuilder result = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + result.append(line); + } + } + return result.toString(); + } +} diff --git a/inspect-main/inspect-main-start/src/main/java/com/inspect/InspectStartApplication.java b/inspect-main/inspect-main-start/src/main/java/com/inspect/InspectStartApplication.java index eb0c44d..4a1927d 100644 --- a/inspect-main/inspect-main-start/src/main/java/com/inspect/InspectStartApplication.java +++ b/inspect-main/inspect-main-start/src/main/java/com/inspect/InspectStartApplication.java @@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableAsync; @EnableRyFeignClients @EnableDiscoveryClient @SpringBootApplication -@EnableAspectJAutoProxy +@EnableAspectJAutoProxy(exposeProxy = true) @EnableAsync public class InspectStartApplication { private final static Logger log = LoggerFactory.getLogger(InspectStartApplication.class); diff --git a/inspect-main/inspect-main-start/src/main/resources/application.yml b/inspect-main/inspect-main-start/src/main/resources/application.yml index b46691d..8eab957 100644 --- a/inspect-main/inspect-main-start/src/main/resources/application.yml +++ b/inspect-main/inspect-main-start/src/main/resources/application.yml @@ -64,4 +64,43 @@ sftp: port: 21 username: ftpuser password: atia2018 - isImplicit: 1 \ No newline at end of file + isImplicit: 1 + +# 盐源外部平台认证/推送(复用 inspect-main-api 的 ExtAuthService / AlarmWsConnection) +# client-id/client-secret 为现场认证凭据,请按现场配置填写(可在nacos配置中心覆盖) +yanyuan: + auth: + base-url: http://192.168.129.2:32799/jb-gateway/jb-base + ws-url: ws://192.168.129.2:32799/jb-gateway/jb-base/jb-base-socket + client-id: "" + client-secret: "" + +# 任务巡检完成后自动归档 + 生成报告 + 推送(先上报 RabbitMQ,再由消费者 HTTP 上传外部平台) +# 见《盐源新能源智慧感知平台_巡检报告接口文档 v1.0.0》:POST /jb-nessp-external/api/uploadReport +inspect: + report-push: + # 是否启用自动归档+推送 + enabled: true + # 巡检报告上传接口完整地址 + upload-url: http://192.168.129.2:32799/jb-gateway/jb-nessp-external/api/uploadReport + # http 连接/读取超时(秒) + http-timeout-seconds: 60 + # 自动归档审核人(报告发起人 username 为空时的兜底) + check-person: admin + # 自动归档巡视结论 + task-result: 巡检完成,自动归档 + # 任务状态更新事务提交等待时间(毫秒) + after-commit-delay-millis: 2000 + # 归档防重锁过期时间(秒) + lock-expire-seconds: 86400 + # 上传报告的场站ID(为空时取 basedata_station.station_no) + station-id: "" + # 上传报告的巡检类型(风机巡检/光伏巡检/线路巡检/山火巡检/机器人巡检;为空时按任务预置位类型推断) + inspect-type: "" + # 上传报告的巡检设备名称(机库名称、机器人名称、摄像头名称,可选) + inspect-device-name: "" + # 报告文件(SFTP)下载重试次数/间隔(毫秒) + download-retry-times: 10 + download-retry-interval-millis: 1000 + # 上传失败 MQ 重投递最大次数(超过后丢弃,避免无限重试) + mq-max-retries: 3 \ No newline at end of file diff --git a/inspect-main/inspect-main-task/pom.xml b/inspect-main/inspect-main-task/pom.xml index a2f28dd..1a32223 100644 --- a/inspect-main/inspect-main-task/pom.xml +++ b/inspect-main/inspect-main-task/pom.xml @@ -13,6 +13,11 @@ inspect-main-task 3.4.0 + + UTF-8 + UTF-8 + + org.springframework.boot @@ -63,10 +68,13 @@ + org.apache.poi poi-ooxml - 5.2.3 + 4.1.2 org.redisson @@ -79,5 +87,11 @@ metadata-extractor 2.18.0 + + + com.inspect + inspect-main-api + 3.4.0 + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/controller/ResultAnalysisController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/controller/ResultAnalysisController.java index 1fa2e42..2a2b265 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/controller/ResultAnalysisController.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/controller/ResultAnalysisController.java @@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONObject; import com.github.pagehelper.PageInfo; import com.inspect.analysis.domain.*; import com.inspect.analysis.service.IResultAnalysisService; +import com.inspect.analysis.service.DefectListAggregator; import com.inspect.base.core.constant.AlgConstants; import com.inspect.base.core.domain.Response; import com.inspect.base.core.enums.PointStatusEnum; @@ -884,6 +885,30 @@ public class ResultAnalysisController extends BaseController { return rspData; } + /** + * 缺陷列表查询:同一任务、点位对象和算法聚合为一条记录。 + */ + @GetMapping({"/defect/summary"}) + public TableDataInfo defectSummary(DefectListQuery query) { + logger.info("LIST_DEFECT_SUMMARY param: {}", query); + + List candidates = patrolResultService.selectDefectList(query); + logger.info("缺陷汇总原始数据 candidates size:{}",candidates == null ? 0 : candidates.size()); + List algInfos = patrolResultService.selectAlgInfo(new PatrolResult()); + List resultList = DefectListAggregator.aggregate(candidates, algInfos); + logger.info("缺陷汇总 resultList size:{}",resultList.size()); + PageDomain pageDomain = TableSupport.buildPageRequest(); + List pageList = DefectListAggregator.page( + resultList, pageDomain.getPageNum(), pageDomain.getPageSize()); + + TableDataInfo response = new TableDataInfo(); + response.setCode(200); + response.setRows(pageList); + response.setTotal(resultList.size()); + response.setMsg("查询成功"); + return response; + } + private static class GroupKey { private final String objectId; private final String algType; diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/domain/DefectListQuery.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/domain/DefectListQuery.java new file mode 100644 index 0000000..c6fe00d --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/domain/DefectListQuery.java @@ -0,0 +1,52 @@ +package com.inspect.analysis.domain; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +/** + * 缺陷列表查询条件。 + */ +@Getter +@Setter +@ToString +public class DefectListQuery { + /** 巡视任务执行 ID。 */ + private String taskPatrolledId; + /** 服务层根据主任务展开后的任务执行 ID。 */ + private List taskPatrolIds; + /** 结果来源:0-大模型,1-初筛。 */ + private String filter; + /** 算法类型。 */ + private String algType; + /** 区域 ID。 */ + private String areaId; + /** 主设备 ID。 */ + private String deviceId; + /** 点位 ID。 */ + private String pointId; + /** 任务编码。 */ + private String taskCode; + /** 点位名称。 */ + private String pointName; + /** 任务名称。 */ + private String taskName; + /** 主设备名称。 */ + private String deviceName; + /** 相机/巡检设备名称。 */ + private String channelName; + /** 系统/设备来源。 */ + private String deviceSource; + /** 点位状态。 */ + private String pointStatus; + /** 缺陷描述关键字。 */ + private String desc; + /** 开始日期,格式 yyyy-MM-dd。 */ + private String beginTime; + /** 结束日期,格式 yyyy-MM-dd。 */ + private String endTime; + /** 预置位类型:0-视频,其他-机器人/无人机;未传时按视频处理。 */ + private String posType = "0"; +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/service/DefectListAggregator.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/service/DefectListAggregator.java new file mode 100644 index 0000000..c6defbc --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/service/DefectListAggregator.java @@ -0,0 +1,205 @@ +package com.inspect.analysis.service; + +import com.inspect.analysis.domain.AlgValue; +import com.inspect.base.core.constant.AlgConstants; +import com.inspect.base.core.utils.StringUtils; +import com.inspect.partrolresult.domain.AlgInfo; +import com.inspect.task.domain.PatrolData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 将缺陷分析明细按任务、对象和算法聚合为列表展示记录。 + */ +public final class DefectListAggregator { + + private DefectListAggregator() { + } + + public static List aggregate(List source, List algInfos) { + if (source == null || source.isEmpty()) { + return new ArrayList<>(); + } + + Map algNameMap = algInfos == null ? Collections.emptyMap() : algInfos.stream() + .filter(Objects::nonNull) + .filter(item -> StringUtils.isNotEmpty(item.getAlgSubtypeCode())) + .collect(Collectors.toMap(AlgInfo::getAlgSubtypeCode, AlgInfo::getAlgSubtypeName, + (first, ignored) -> first)); + + Map> groups = new LinkedHashMap<>(); + for (PatrolData item : source) { + if (item == null || !"0".equals(item.getWarnStatus()) + || AlgConstants.CORRECTION.equals(item.getAlgType())) { + continue; + } + item.setAlgName(algNameMap.getOrDefault(item.getAlgType(), "")); + prepareItem(item); + GroupKey key = new GroupKey(item.getTaskPatrolId(), item.getObjectId(), item.getAlgType()); + groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(item); + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : groups.entrySet()) { + result.add(merge(entry.getKey(), entry.getValue())); + } + result.sort(Comparator.comparingInt(item -> statusRank(item.getResStatus()))); + return result; + } + + public static List page(List source, Integer pageNum, Integer pageSize) { + if (source == null || source.isEmpty()) { + return new ArrayList<>(); + } + int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; + int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; + long from = (long) (safePageNum - 1) * safePageSize; + if (from >= source.size()) { + return new ArrayList<>(); + } + int fromIndex = (int) from; + long to = Math.min((long) source.size(), from + safePageSize); + int toIndex = (int) to; + return source.subList(fromIndex, toIndex); + } + + private static void prepareItem(PatrolData item) { + if ("1".equals(item.getFilter()) && StringUtils.isNotEmpty(item.getImageNormalUrlPath())) { + item.setImg(item.getImageNormalUrlPath()); + } + if (item.getResValue() != null && "meter".equals(item.getAlgType())) { + String value = new AlgValue().formatValue(item.getResValue()); + if ("-1".equals(value)) { + item.setResValue("空值"); + } + } + } + + private static PatrolData merge(GroupKey key, List values) { + PatrolData result = new PatrolData(); + result.setTaskPatrolId(key.taskPatrolId); + result.setObjectId(key.objectId); + result.setAlgType(key.algType); + result.setDesc(joinDistinct(values, PatrolData::getDesc)); + + for (PatrolData item : values) { + if ("1".equals(item.getFilter())) { +// if (StringUtils.isEmpty(result.getImgAnalyse())) { +// result.setImgAnalyse(item.getImgAnalyse()); +// } + if (StringUtils.isEmpty(result.getImg())) { + result.setImg(item.getImg()); + } + } + if ("0".equals(item.getFilter())) { + if (StringUtils.isNotEmpty(item.getImgAnalyse())) { + result.setImgAnalyse(item.getImgAnalyse()); + } +// if (StringUtils.isNotEmpty(item.getImg())) { +// result.setImg(item.getImg()); +// } + } +// if (StringUtils.isEmpty(result.getImg())) { +// if (StringUtils.isNotEmpty(item.getImgAnalyse())) { +// result.setImgAnalyse(item.getImgAnalyse()); +// } +// if (StringUtils.isNotEmpty(item.getImg())) { +// result.setImg(item.getImg()); +// } +// } + } + + result.setImageNormalUrlPath(firstNonEmpty(values, PatrolData::getImageNormalUrlPath)); + result.setFilePath(firstNonEmpty(values, PatrolData::getFilePath)); + result.setAlgName(firstNonEmpty(values, PatrolData::getAlgName)); + result.setAreaName(firstNonEmpty(values, PatrolData::getAreaName)); +// result.setBrightDesc(firstNonEmpty(values, PatrolData::getBrightDesc)); +// result.setBrightImgAnalyse(firstNonEmpty(values, PatrolData::getBrightImgAnalyse)); +// result.setBrightResStatus(firstNonEmpty(values, PatrolData::getBrightResStatus)); + result.setChannelName(firstNonEmpty(values, PatrolData::getChannelName)); + result.setDataType(firstNonEmpty(values, PatrolData::getDataType)); + result.setDeviceName(firstNonEmpty(values, PatrolData::getDeviceName)); + result.setDeviceSource(firstNonEmpty(values, PatrolData::getDeviceSource)); + result.setImgType(firstNonEmpty(values, PatrolData::getImgType)); + result.setLineId(joinDistinct(values, PatrolData::getLineId)); + result.setPatrolTime(firstNonEmpty(values, PatrolData::getPatrolTime)); + result.setPointId(firstNonEmpty(values, PatrolData::getPointId)); + result.setPointName(firstNonEmpty(values, PatrolData::getPointName)); + result.setPointStatus(firstNonEmpty(values, PatrolData::getPointStatus)); + result.setRequestId(firstNonEmpty(values, PatrolData::getRequestId)); + result.setResValue(firstNonEmpty(values, PatrolData::getResValue)); + result.setResStatus(firstNonEmpty(values, PatrolData::getResStatus)); + result.setSuggestion(firstNonEmpty(values, PatrolData::getSuggestion)); + result.setWarnStatus("0"); + return result; + } + + private static String firstNonEmpty(List values, Function getter) { + return values.stream().map(getter).filter(StringUtils::isNotEmpty).findFirst().orElse(""); + } + + private static String joinDistinct(List values, Function getter) { + Set distinct = new LinkedHashSet<>(); + for (PatrolData value : values) { + String item = getter.apply(value); + if (StringUtils.isNotEmpty(item)) { + distinct.add(item); + } + } + return String.join(",", distinct); + } + + private static int statusRank(String status) { + if ("1".equals(status)) { + return 1; + } + if ("3".equals(status)) { + return 2; + } + if ("2".equals(status)) { + return 3; + } + return 10; + } + + private static final class GroupKey { + private final String taskPatrolId; + private final String objectId; + private final String algType; + + private GroupKey(String taskPatrolId, String objectId, String algType) { + this.taskPatrolId = taskPatrolId; + this.objectId = objectId; + this.algType = algType; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GroupKey)) { + return false; + } + GroupKey that = (GroupKey) other; + return Objects.equals(taskPatrolId, that.taskPatrolId) + && Objects.equals(objectId, that.objectId) + && Objects.equals(algType, that.algType); + } + + @Override + public int hashCode() { + return Objects.hash(taskPatrolId, objectId, algType); + } + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/utils/ResultAnalysisUtils.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/utils/ResultAnalysisUtils.java index ff74319..3ad7b0c 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/utils/ResultAnalysisUtils.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/analysis/utils/ResultAnalysisUtils.java @@ -508,7 +508,7 @@ public class ResultAnalysisUtils { if (!NumberUtils.isCreatable(val)) { return val; } - + // 红外算法,保留1位小数(四舍五入) if (AlgConstants.ALG_INFRARED_LIST.contains(algType)) { String formatted = BigDecimalUtil.toOneDecimal(val, 1); return shouldAddUnit ? formatted + unit : formatted; @@ -521,7 +521,7 @@ public class ResultAnalysisUtils { } return val; } - + // 所有值都加单位(如果有单位的话) return shouldAddUnit ? val + unit : val; }) .collect(Collectors.joining(",")); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/aspect/TaskStatusChangeAspect.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/aspect/TaskStatusChangeAspect.java index 34cf3ba..7128c37 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/aspect/TaskStatusChangeAspect.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/aspect/TaskStatusChangeAspect.java @@ -1,6 +1,7 @@ package com.inspect.aspect; import com.inspect.annotation.TaskStatusChange; +import com.inspect.reportpush.service.ReportAutoArchiveService; import com.inspect.task.service.IPatrolTaskService; import com.inspect.taskstatus.domain.PatrolTaskStatus; import lombok.extern.slf4j.Slf4j; @@ -18,6 +19,9 @@ public class TaskStatusChangeAspect { @Autowired private IPatrolTaskService patrolTaskService; + @Autowired + private ReportAutoArchiveService reportAutoArchiveService; + /** * 拦截带有 @TaskStatusChange 注解的方法 @@ -46,8 +50,9 @@ public class TaskStatusChangeAspect { // 执行切面逻辑 if (taskStatusChange.async()) { - log.info("异步执行区域任务合并报告,任务执行id: {}", taskStatus.getTaskPatrolledId()); - patrolTaskService.executeAsync(taskStatus); + log.info("异步执行自动归档,任务执行id: {}", taskStatus.getTaskPatrolledId()); + // 任务巡检完成自动归档 + 生成报告 + websocket 推送(盐源外部平台) + reportAutoArchiveService.onTaskCompleted(taskStatus); } else { executeSync(taskStatus); } diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/img/domain/InspectionReportImg.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/img/domain/InspectionReportImg.java index 46208aa..d73f148 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/img/domain/InspectionReportImg.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/img/domain/InspectionReportImg.java @@ -28,6 +28,9 @@ public class InspectionReportImg extends BaseEntity { name = "缩略图路径" ) private String imgSrc; + + private String imageNormalUrlPath; + @JsonFormat( pattern = "yyyy-MM-dd" ) diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java index 2ac2ce5..053aa21 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreport/domain/InspectionReport.java @@ -92,6 +92,8 @@ public class InspectionReport extends BaseEntity { private String filePath; private String taskId; private String taskResultId; + /** 位置(省份 + 地市拼接,如 四川省成都市) */ + private String location; private String areaName; private String areaType; private String deviceType; @@ -118,12 +120,12 @@ public class InspectionReport extends BaseEntity { if (this == object) return true; if (object == null || getClass() != object.getClass()) return false; InspectionReport that = (InspectionReport) object; - return Objects.equals(lineId, that.lineId) && Objects.equals(stationName, that.stationName) && Objects.equals(voltLevel, that.voltLevel) && Objects.equals(stationType, that.stationType) && Objects.equals(inspectionDate, that.inspectionDate) && Objects.equals(inspectionTaskName, that.inspectionTaskName) && Objects.equals(envInfo, that.envInfo) && Objects.equals(inspectionStartTime, that.inspectionStartTime) && Objects.equals(inspectionEndTime, that.inspectionEndTime) && Objects.equals(patrolStatistics, that.patrolStatistics) && Objects.equals(checkPerson, that.checkPerson) && Objects.equals(checkTime, that.checkTime) && Objects.equals(description, that.description) && Objects.equals(filePath, that.filePath) && Objects.equals(taskId, that.taskId) && Objects.equals(taskResultId, that.taskResultId) && Objects.equals(areaName, that.areaName) && Objects.equals(areaType, that.areaType) && Objects.equals(deviceType, that.deviceType) && Objects.equals(type, that.type) && Objects.equals(cycleStartTime, that.cycleStartTime) && Objects.equals(cycleEndTime, that.cycleEndTime) && Objects.equals(taskPatrolledId, that.taskPatrolledId) && Objects.equals(ReportDatalist, that.ReportDatalist) && Objects.equals(info, that.info); + return Objects.equals(lineId, that.lineId) && Objects.equals(stationName, that.stationName) && Objects.equals(voltLevel, that.voltLevel) && Objects.equals(stationType, that.stationType) && Objects.equals(inspectionDate, that.inspectionDate) && Objects.equals(inspectionTaskName, that.inspectionTaskName) && Objects.equals(envInfo, that.envInfo) && Objects.equals(inspectionStartTime, that.inspectionStartTime) && Objects.equals(inspectionEndTime, that.inspectionEndTime) && Objects.equals(patrolStatistics, that.patrolStatistics) && Objects.equals(checkPerson, that.checkPerson) && Objects.equals(checkTime, that.checkTime) && Objects.equals(description, that.description) && Objects.equals(filePath, that.filePath) && Objects.equals(taskId, that.taskId) && Objects.equals(taskResultId, that.taskResultId) && Objects.equals(location, that.location) && Objects.equals(areaName, that.areaName) && Objects.equals(areaType, that.areaType) && Objects.equals(deviceType, that.deviceType) && Objects.equals(type, that.type) && Objects.equals(cycleStartTime, that.cycleStartTime) && Objects.equals(cycleEndTime, that.cycleEndTime) && Objects.equals(taskPatrolledId, that.taskPatrolledId) && Objects.equals(ReportDatalist, that.ReportDatalist) && Objects.equals(info, that.info); } @Override public int hashCode() { - return Objects.hash(lineId, stationName, voltLevel, stationType, inspectionDate, inspectionTaskName, envInfo, inspectionStartTime, inspectionEndTime, patrolStatistics, checkPerson, checkTime, description, filePath, taskId, taskResultId, areaName, areaType, deviceType, type, cycleStartTime, cycleEndTime, taskPatrolledId, ReportDatalist, info); + return Objects.hash(lineId, stationName, voltLevel, stationType, inspectionDate, inspectionTaskName, envInfo, inspectionStartTime, inspectionEndTime, patrolStatistics, checkPerson, checkTime, description, filePath, taskId, taskResultId, location, areaName, areaType, deviceType, type, cycleStartTime, cycleEndTime, taskPatrolledId, ReportDatalist, info); } @Override @@ -145,6 +147,7 @@ public class InspectionReport extends BaseEntity { ", filePath='" + filePath + '\'' + ", taskId='" + taskId + '\'' + ", taskResultId='" + taskResultId + '\'' + + ", location='" + location + '\'' + ", areaName='" + areaName + '\'' + ", areaType='" + areaType + '\'' + ", deviceType='" + deviceType + '\'' + diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreportdata/domain/InspectionReportData.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreportdata/domain/InspectionReportData.java index 3a40037..3597643 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/insreportdata/domain/InspectionReportData.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/insreportdata/domain/InspectionReportData.java @@ -58,6 +58,11 @@ public class InspectionReportData extends BaseEntity { name = "巡视结果" ) private String inspectionResults; + + @Excel( + name = "巡视读数" + ) + private String inspectionValue; @Excel( name = "点位状态:正常、异常、待人工确 认三种状态" ) diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/message/MessageUtils.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/message/MessageUtils.java index 31339bc..7b89677 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/message/MessageUtils.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/message/MessageUtils.java @@ -235,6 +235,8 @@ public class MessageUtils { return "summary"; case "缺陷": return "defect"; + case "本次巡检未发现缺陷项": + return "No defects were found during this inspection."; case"分析失败": return "analysis failed"; default: diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/mapper/PatrolResultMapper.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/mapper/PatrolResultMapper.java index f8ae020..97e1f68 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/mapper/PatrolResultMapper.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/mapper/PatrolResultMapper.java @@ -1,6 +1,7 @@ package com.inspect.partrolresult.mapper; import com.inspect.analysis.domain.ResultAnalysis; +import com.inspect.analysis.domain.DefectListQuery; import com.inspect.partrolresult.domain.*; import com.inspect.statistics.domain.TaskStatisticsConfig; import com.inspect.system.base.openDomain.BasedataEqpBookMoMain; @@ -34,6 +35,8 @@ public interface PatrolResultMapper { List selectPatrolDataResultByTaskCodeV2(ResultAnalysis resultAnalysis); + List selectDefectList(DefectListQuery query); + List selectPatrolDataResultByTaskCodeList(PatrolTaskStatus patrolTaskStatus); List selectBasedataEqpBookList(BasedataEqpBookMoMain basedataEqpBookMoMain); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/IPatrolResultService.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/IPatrolResultService.java index ee14a13..5b3a40c 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/IPatrolResultService.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/IPatrolResultService.java @@ -1,6 +1,7 @@ package com.inspect.partrolresult.service; import com.inspect.analysis.domain.ResultAnalysis; +import com.inspect.analysis.domain.DefectListQuery; import com.inspect.insreport.domain.InspectionReport; import com.inspect.partrolresult.domain.*; import com.inspect.resultmain.domain.PatrolTaskResultMain; @@ -30,6 +31,8 @@ public interface IPatrolResultService { List selectPatrolDataResultByTaskCodeV2(ResultAnalysis resultAnalysis); + List selectDefectList(DefectListQuery query); + List selectPatrolDataResultByTaskCodeList(PatrolTaskStatus patrolTaskStatus); List selectBasedataEqpBookList(BasedataEqpBookMoMain basedataEqpbookMomain); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java index e7de160..9e52a3f 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/partrolresult/service/impl/PatrolResultServiceImpl.java @@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.inspect.analysis.domain.AlgValue; import com.inspect.analysis.domain.AnalyseResPoint; +import com.inspect.analysis.domain.DefectListQuery; import com.inspect.analysis.domain.ResultAnalysis; import com.inspect.analysis.mapper.ResultAnalysisMapper; import com.inspect.analysis.utils.ResultAnalysisUtils; @@ -147,6 +148,11 @@ public class PatrolResultServiceImpl implements IPatrolResultService { return this.patrolResultMapper.selectPatrolDataResultByTaskCodeV3(resultAnalysis); } + @Override + public List selectDefectList(DefectListQuery query) { + return patrolResultMapper.selectDefectList(query); + } + public List selectPatrolDataResultByTaskCodeList(PatrolTaskStatus patrolTaskStatus) { return patrolResultMapper.selectPatrolDataResultByTaskCodeList(patrolTaskStatus); } @@ -528,6 +534,8 @@ public class PatrolResultServiceImpl implements IPatrolResultService { String stationName = stationMap.get("station_name"); String stationType = stationMap.get("station_type"); String voltLevel = stationMap.get("volt_level"); + String cityName = stationMap.get("city_name"); // 地市 + String provinceName = stationMap.get("province_name"); // 省份 startTime = PrintUtil.useTime("导出报告:查询站点信息", startTime); AjaxResult ajaxResult = feignBasedataAreaService.evnList(); @@ -778,6 +786,8 @@ public class PatrolResultServiceImpl implements IPatrolResultService { report.setVoltLevel(StringUtils.isEmpty(voltLevel) ? voltage : voltLevel); report.setFilter("0"); report.setStationType(stationType); + // 位置 = 省份 + 地市(如 四川省成都市) + report.setLocation(buildLocation(provinceName, cityName)); inspectionReportMapper.insertInspectionReport(report); // 为每个报告生成数据 batchInsertReportData_shaoxing(String.valueOf(report.getLineId()), patrolResultRefs, basePointAreaInfoList, lineIds); @@ -791,6 +801,18 @@ public class PatrolResultServiceImpl implements IPatrolResultService { return reportIds; } + /** 拼接报告位置:省份 + 地市(如 四川省成都市;缺省时只显示存在的部分) */ + private String buildLocation(String provinceName, String cityName) { + StringBuilder sb = new StringBuilder(); + if (StringUtils.isNotEmpty(provinceName)) { + sb.append(provinceName); + } + if (StringUtils.isNotEmpty(cityName)) { + sb.append(cityName); + } + return sb.toString(); + } + @Transactional public List saveReportV2(PatrolTaskResultMain resultMain, List resultList) { long startTime = System.currentTimeMillis(); @@ -1321,31 +1343,33 @@ public class PatrolResultServiceImpl implements IPatrolResultService { reportData.setTaskName(resultTaskName); reportData.setTaskPatrolledId(taskPatrolledId); reportData.setTaskResultId(String.valueOf(mainId)); - if ("1".equals(patrolResultRef.getResultType()) && validAlgTypes.contains(patrolResultRef.getAlgType())) { - reportData.setInspectionResults(patrolResultRef.getValue()); - } else { - reportData.setInspectionResults(patrolResultRef.getDescription()); - } +// if ("1".equals(patrolResultRef.getResultType()) && validAlgTypes.contains(patrolResultRef.getAlgType())) { +// reportData.setInspectionResults(patrolResultRef.getValue()); +// } else { +// reportData.setInspectionResults(patrolResultRef.getDescription()); +// } + reportData.setInspectionResults(patrolResultRef.getDescription()); + reportData.setInspectionValue(patrolResultRef.getValue()); reportData.setAlgName(patrolResultRef.getAlgName()); - String threshold = patrolResultRef.getThreshold(); - if (StringUtils.isBlank(threshold)) { - threshold = ""; - } - if ("1".equals(patrolResultRef.getResultType()) && threshold.contains("sub_53")) { - reportData.setInspectionResults(patrolResultRef.getValue()); - } - - if ("1".equals(patrolResultRef.getResultType()) && (threshold.contains("sub_52") || threshold.contains("sub_53")) - && !"0".equals(patrolResultRef.getValue()) - && !"1".equals(patrolResultRef.getValue()) - && !"-1".equals(patrolResultRef.getValue()) - && !"check image".equals(patrolResultRef.getValue())) { - reportData.setInspectionResults(patrolResultRef.getValue()); - } +// String threshold = patrolResultRef.getThreshold(); +// if (StringUtils.isBlank(threshold)) { +// threshold = ""; +// } +// if ("1".equals(patrolResultRef.getResultType()) && threshold.contains("sub_53")) { +// reportData.setInspectionResults(patrolResultRef.getValue()); +// } +// +// if ("1".equals(patrolResultRef.getResultType()) && (threshold.contains("sub_52") || threshold.contains("sub_53")) +// && !"0".equals(patrolResultRef.getValue()) +// && !"1".equals(patrolResultRef.getValue()) +// && !"-1".equals(patrolResultRef.getValue()) +// && !"check image".equals(patrolResultRef.getValue())) { +// reportData.setInspectionResults(patrolResultRef.getValue()); +// } // 给读数增加单位 - String resValue = resultAnalysisUtils.addUnit2Value(reportData.getInspectionResults(), patrolResultRef.getAlgType(), patrolResultRef.getUnit()); - reportData.setInspectionResults(resValue); + String resValue = resultAnalysisUtils.addUnit2Value(reportData.getInspectionValue(), patrolResultRef.getAlgType(), patrolResultRef.getUnit()); + reportData.setInspectionValue(resValue); String pointStatus = patrolResultRef.getPointStatus(); String resultType = patrolResultRef.getResultType(); @@ -1395,16 +1419,20 @@ public class PatrolResultServiceImpl implements IPatrolResultService { //List results = JSONObject.parseObject(resultContent).getJSONArray("resultList").toJavaList(resultList.class); //String imgUrl = results.get(0).getResults().get(0).getResImageUrl(); AnalyseResPoint analyseResPoint = JSONObject.parseObject(resultContent, AnalyseResPoint.class); - String imgUrl = analyseResPoint.getResImageUrl(); + String resImageUrl = analyseResPoint.getResImageUrl(); + String imageNormalUrlPath = analyseResPoint.getImageNormalUrlPath(); - if (StringUtils.isEmpty(imgUrl)) { - imgUrl = patrolResultRef.getFilePath().split(StringUtils.COMMA)[0]; + if (StringUtils.isEmpty(resImageUrl)) { + resImageUrl = patrolResultRef.getFilePath().split(StringUtils.COMMA)[0]; + } + if (StringUtils.isEmpty(imageNormalUrlPath)) { + imageNormalUrlPath = patrolResultRef.getFilePath().split(StringUtils.COMMA)[0]; } - InspectionReportImg reportImg = new InspectionReportImg(); reportImg.setReportInfoId(reportDataId); - reportImg.setImg(imgUrl); - reportImg.setImgSrc(imgUrl); + reportImg.setImg(resImageUrl); + reportImg.setImgSrc(patrolResultRef.getFilePath().split(StringUtils.COMMA)[0]); + reportImg.setImageNormalUrlPath(imageNormalUrlPath); reportImg.setImgType("0"); reportImg.setCreatTime(new Date()); reportImages.add(reportImg); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java new file mode 100644 index 0000000..bc6034b --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportfont/ReportFontEmbedder.java @@ -0,0 +1,281 @@ +package com.inspect.reportfont; + +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ooxml.POIXMLDocumentPart; +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.openxml4j.opc.PackageRelationship; +import org.apache.poi.openxml4j.opc.PackagingURIHelper; +import org.apache.poi.openxml4j.opc.TargetMode; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFSettings; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLanguage; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSettings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; + +/** + * 动环巡检报告 Word 字体嵌入。 + *

+ * 原理(Word 的"嵌入字体"机制,让查看方电脑无需安装字体): + *

    + *
  1. docx 包内的字体"注册表"是 {@code word/fontTable.xml}:按字体名(w:name)列出字体, + * 每个条目通过 {@code embedRegular/embedBold} 属性(r:id)引用一个字体部件;
  2. + *
  3. 字体部件({@code word/fonts/*.odttf})是混淆后的字体数据——按 OOXML §17.8.1, + * 字体前 32 字节与混淆 key 异或,key 由条目中的 {@code fontKey}(GUID)推导; + * Word 打开文档时按同样算法还原后使用;
  4. + *
  5. 正文 run 只写字体名(w:eastAsia),Word 按名字查 fontTable 命中嵌入字体。
  6. + *
+ * 实现要点: + * + */ +@Slf4j +@Component +public class ReportFontEmbedder { + + // ============================ 部件/关系类型常量 ============================ + private static final String REL_FONT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; + private static final String REL_FONT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable"; + private static final String REL_STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; + private static final String REL_THEME = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"; + private static final String REL_WEB_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings"; + + private static final String CT_FONT_TABLE = "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"; + private static final String CT_FONT = "application/vnd.openxmlformats-officedocument.obfuscatedFont"; + private static final String CT_STYLES = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"; + private static final String CT_THEME = "application/vnd.openxmlformats-officedocument.theme+xml"; + private static final String CT_WEB_SETTINGS = "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"; + + /** 模板文件所在 classpath 目录 */ + private static final String TEMPLATE_DIR = "report/wordparts/"; + + /** + * 报告用到的 5 个嵌入字体部件(与 fontTable 模板的条目一一对应): + * 部件名 -> {字体文件, 混淆 key}。字体文件位于 classpath fonts/ 或外部目录。 + * 注意:黑体在模板中有 regular/bold/italic 三个样式条目,均引用同一份黑体; + * 方正小标宋、仿宋各只嵌 bold(模板如此,标题/表头均加粗使用)。 + */ + private static final List EMBEDDED_FONTS = Arrays.asList( + new EmbeddedFont("font1", "simhei.ttf", "DAF739B1-18F1-4906-820A-C106608C2B73"), + new EmbeddedFont("font2", "simhei.ttf", "3039BACA-5140-4C4C-8AFB-F7B61E374105"), + new EmbeddedFont("font3", "simhei.ttf", "73410ACE-E9FA-46ED-BA3D-9ECB6C9FEF4E"), + new EmbeddedFont("font4", "方正小标宋简体.ttf", "4D5F1B72-2D28-435A-B7BC-848B95F30EBB"), + new EmbeddedFont("font5", "simfang.ttf", "818E8C25-A7E2-4610-9234-360D3B3511E5")); + + /** + * 外部字体目录({@code report.donghuan.fonts-dir}), + * 留空则从 classpath {@code fonts/} 加载(随 jar 打包)。 + */ + @Value("${report.donghuan.fonts-dir:}") + private String fontsDir; + + /** 一个待嵌入字体:部件名 + 字体文件 + 混淆 key(fontKey) */ + private static final class EmbeddedFont { + final String partName; + final String fileName; + final String fontKey; + + EmbeddedFont(String partName, String fileName, String fontKey) { + this.partName = partName; + this.fileName = fileName; + this.fontKey = fontKey; + } + } + + /** + * 把报告所需字体嵌入 docx(在生成正文内容前调用)。 + * 字体缺失时不阻断归档,报告退化为不带嵌入字体。 + */ + public void embed(XWPFDocument doc) { + try { + OPCPackage pkg = doc.getPackage(); + PackagePart documentPart = pkg.getPart(PackagingURIHelper.createPartName("/word/document.xml")); + + // 1. fontTable.xml(静态模板,含字体注册信息) + addPart(pkg, "/word/fontTable.xml", CT_FONT_TABLE, "fontTable.xml"); + // 2. 字体部件 font1..font5.odttf,关系挂到 fontTable 上(rId 按序分配,与模板一致) + embedFontParts(pkg, pkg.getPart(PackagingURIHelper.createPartName("/word/fontTable.xml"))); + // 3. 补齐 Word 常规部件(styles/theme/webSettings,缺了 Word 不解析嵌入字体) + addPart(pkg, "/word/styles.xml", CT_STYLES, "styles.xml"); + addPart(pkg, "/word/theme/theme1.xml", CT_THEME, "theme1.xml"); + addPart(pkg, "/word/webSettings.xml", CT_WEB_SETTINGS, "webSettings.xml"); + // 4. document.xml 引用 fontTable/styles/theme/webSettings + addDocumentRelations(documentPart, pkg); + // 5. settings.xml 写入嵌入字体标志 + addSettingsFlags(doc); + log.info("[FONT_EMBED] 报告字体嵌入完成"); + } catch (Exception e) { + log.error("[FONT_EMBED] 字体嵌入失败,本次报告不带嵌入字体", e); + } + } + + /** 从模板目录创建部件并写入内容,同时挂到 document.xml */ + private PackagePart addPart(OPCPackage pkg, String partName, String contentType, String templateName) throws Exception { + PackagePart part = pkg.createPart(PackagingURIHelper.createPartName(partName), contentType); + byte[] bytes = readTemplate(templateName); + if (bytes != null) { + try (OutputStream os = part.getOutputStream()) { + os.write(bytes); + } + } + log.info("[FONT_EMBED] 已创建部件: {} ({} bytes)", partName, bytes == null ? 0 : bytes.length); + return part; + } + + /** 把 document.xml 与 fontTable/styles/theme/webSettings 建立关系 */ + private void addDocumentRelations(PackagePart documentPart, OPCPackage pkg) throws Exception { + documentPart.addRelationship( + PackagingURIHelper.createPartName("/word/fontTable.xml"), TargetMode.INTERNAL, REL_FONT_TABLE); + documentPart.addRelationship( + PackagingURIHelper.createPartName("/word/styles.xml"), TargetMode.INTERNAL, REL_STYLES); + documentPart.addRelationship( + PackagingURIHelper.createPartName("/word/theme/theme1.xml"), TargetMode.INTERNAL, REL_THEME); + documentPart.addRelationship( + PackagingURIHelper.createPartName("/word/webSettings.xml"), TargetMode.INTERNAL, REL_WEB_SETTINGS); + } + + /** + * 依次创建 font1..font5.odttf 部件(完整字体按固定 key 混淆), + * 并把 font 关系挂到 fontTable 部件(addRelationship 按序生成 rId1..rId5, + * 与静态模板中的引用一一对应)。 + */ + private void embedFontParts(OPCPackage pkg, PackagePart fontTablePart) throws Exception { + for (EmbeddedFont font : EMBEDDED_FONTS) { + byte[] fontBytes = loadFontBytes(font.fileName); + if (fontBytes == null) { + log.warn("[FONT_EMBED] 字体文件缺失,跳过部件 {}: {}", font.partName, font.fileName); + continue; + } + PackagePart fontPart = pkg.createPart( + PackagingURIHelper.createPartName("/word/fonts/" + font.partName + ".odttf"), CT_FONT); + try (OutputStream os = fontPart.getOutputStream()) { + os.write(obfuscateFont(fontBytes, font.fontKey)); + } + fontTablePart.addRelationship(fontPart.getPartName(), TargetMode.INTERNAL, REL_FONT); + log.info("[FONT_EMBED] 已嵌入字体部件: {} <- {} ({} bytes)", font.partName, font.fileName, fontBytes.length); + } + } + + /** + * 在 settings.xml 写入嵌入字体标志,Word 靠它识别"文档带嵌入字体": + * {@code }、{@code }、 + * {@code }。 + *

+ * 注意:POI 4.1.2 保存 settings 时从内部缓存的 CTSettings 写回(直接改部件内容会被覆盖), + * 且不能整体替换缓存对象(类型不兼容),因此反射取出缓存的 CTSettings 用其 API 添加标志。 + */ + private void addSettingsFlags(XWPFDocument doc) { + try { + for (POIXMLDocumentPart part : doc.getRelations()) { + if (part instanceof XWPFSettings) { + java.lang.reflect.Field ctField = XWPFSettings.class.getDeclaredField("ctSettings"); + ctField.setAccessible(true); + CTSettings ctSettings = (CTSettings) ctField.get(part); + if (!ctSettings.isSetEmbedTrueTypeFonts()) { + ctSettings.addNewEmbedTrueTypeFonts(); + } + if (!ctSettings.isSetSaveSubsetFonts()) { + ctSettings.addNewSaveSubsetFonts(); + } + if (!ctSettings.isSetThemeFontLang()) { + CTLanguage lang = ctSettings.addNewThemeFontLang(); + lang.setVal("en-US"); + lang.setEastAsia("zh-CN"); + } + log.info("[FONT_EMBED] settings.xml 已写入嵌入字体标志"); + return; + } + } + log.warn("[FONT_EMBED] 未找到 settings 部件,跳过嵌入字体标志"); + } catch (Exception e) { + log.warn("[FONT_EMBED] 写入 settings.xml 嵌入字体标志失败", e); + } + } + + /** 读取 classpath 模板内容(report/wordparts/) */ + private byte[] readTemplate(String name) { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(TEMPLATE_DIR + name)) { + if (is == null) { + log.warn("[FONT_EMBED] 模板缺失: {}", TEMPLATE_DIR + name); + return null; + } + return readAll(is); + } catch (Exception e) { + log.warn("[FONT_EMBED] 读取模板失败: {}", name, e); + return null; + } + } + + /** + * 加载字体文件:优先外部目录(report.donghuan.fonts-dir), + * 其次 classpath {@code fonts/}(随 jar 打包)。 + */ + private byte[] loadFontBytes(String fileName) { + try { + if (fontsDir != null && !fontsDir.isEmpty()) { + Path fontPath = Paths.get(fontsDir, fileName); + if (Files.exists(fontPath)) { + return Files.readAllBytes(fontPath); + } + } + try (InputStream is = getClass().getClassLoader().getResourceAsStream("fonts/" + fileName)) { + if (is != null) { + return readAll(is); + } + } + log.warn("[FONT_EMBED] 字体文件不存在: {} (fontsDir={}, classpath=fonts/)", fileName, fontsDir); + } catch (Exception e) { + log.warn("[FONT_EMBED] 读取字体文件失败: {}", fileName, e); + } + return null; + } + + /** + * OOXML §17.8.1 字体混淆(ODTTF):字体前 32 字节与混淆 key 异或。 + * key 由 fontKey(GUID 去掉横线、32 位十六进制)每 2 字符转 1 字节后整体反转得到。 + * Word 打开文档时按同样算法还原字体。 + */ + private byte[] obfuscateFont(byte[] fontBytes, String fontKey) { + byte[] out = fontBytes.clone(); + byte[] key = buildObfuscationKey(fontKey); + int len = Math.min(32, out.length); + for (int i = 0; i < len; i++) { + out[i] ^= key[i % 16]; + } + return out; + } + + /** 由 fontKey(GUID)生成 16 字节混淆 key:十六进制转字节后整体反转 */ + private byte[] buildObfuscationKey(String fontKey) { + String hex = fontKey.replace("-", ""); + byte[] key = new byte[16]; + for (int i = 0; i < 16; i++) { + key[i] = (byte) Integer.parseInt(hex.substring((15 - i) * 2, (16 - i) * 2), 16); + } + return key; + } + + private byte[] readAll(InputStream is) throws java.io.IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) != -1) { + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java new file mode 100644 index 0000000..c250944 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/config/ReportPushProperties.java @@ -0,0 +1,43 @@ +package com.inspect.reportpush.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 任务巡检完成自动归档 + 生成报告 + websocket 推送配置 + *

+ * 对应 application.yml 中 {@code inspect.report-push.*} 配置块 + * (可在 nacos 配置中心按现场覆盖)。 + * + * @author inspect + */ +@Data +@Component +@ConfigurationProperties(prefix = "inspect.report-push") +public class ReportPushProperties { + + /** 是否启用自动归档 + 推送 */ + private boolean enabled = true; + + /** 自动归档审核人 */ + private String checkPerson = "admin"; + + /** 自动归档巡视结论 */ + private String taskResult = "巡检完成,自动归档"; + + /** 任务状态更新事务提交等待时间(毫秒) */ + private long afterCommitDelayMillis = 2000; + + /** 归档防重锁过期时间(秒) */ + private long lockExpireSeconds = 86400; + + /** 上传报告的场站ID(为空时取 basedata_station.station_no) */ + private String stationId = ""; + + /** 上传报告的巡检类型(风机巡检/光伏巡检/线路巡检/山火巡检/机器人巡检) */ + private String inspectType = "动环智能巡检"; + + /** 上传报告的巡检设备名称(机库名称、机器人名称、摄像头名称,可选) */ + private String inspectDeviceName = ""; +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java new file mode 100644 index 0000000..6cd7215 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/controller/ReportPushController.java @@ -0,0 +1,64 @@ +package com.inspect.reportpush.controller; + +import com.alibaba.fastjson.JSONObject; +import com.inspect.api.service.ReportUploadService; +import com.inspect.base.core.utils.DateUtils; +import com.inspect.base.core.utils.StringUtils; +import com.inspect.base.core.web.controller.BaseController; +import com.inspect.base.core.web.domain.AjaxResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * 报告推送调试接口(对接外部平台-盐源新能源智慧感知平台《巡检报告接口文档》) + *

    + *
  • GET /reportpush/getToken 获取 Joinbright-Token(复用 ExtAuthService,验证认证连通性)
  • + *
  • GET /reportpush/test 上传一条测试报告(验证 /uploadReport 上传接口连通性)
  • + *
+ */ +@Slf4j +@RestController +@RequestMapping("/reportpush") +public class ReportPushController extends BaseController { + + @Autowired + private ReportUploadService reportUploadService; + + /** + * 获取 Joinbright-Token(调试用) + */ + @GetMapping("/getToken") + public AjaxResult getToken() { + String token = reportUploadService.getJoinbrightToken(); + return StringUtils.isNotEmpty(token) ? AjaxResult.success(token) : AjaxResult.error("获取Joinbright-Token失败"); + } + + /** + * 上传测试报告(调试用) + */ + @GetMapping("/test") + public AjaxResult test(String message) { + byte[] dummyFile = "inspect report upload test".getBytes(StandardCharsets.UTF_8); + String now = DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, new Date()); + JSONObject reportInfo = new JSONObject(); + reportInfo.put("id", "TEST-" + System.currentTimeMillis()); + reportInfo.put("name", "测试报告"); + reportInfo.put("fileType", "docx"); + reportInfo.put("stationId", ""); + reportInfo.put("stationName", "测试电站"); + reportInfo.put("inspectTime", now); + reportInfo.put("createTime", now); + reportInfo.put("inspectType", "机器人巡检"); + reportInfo.put("inspectContent", StringUtils.isEmpty(message) ? "巡检报告上传接口联调测试" : message); + reportInfo.put("taskId", "TEST"); + reportInfo.put("taskName", "测试任务"); + boolean ok = reportUploadService.uploadReport(dummyFile, "test_report.docx", reportInfo); + return ok ? AjaxResult.success("上传成功") : AjaxResult.error("上传失败, 请查看日志"); + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java new file mode 100644 index 0000000..b58b19e --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportAutoArchiveService.java @@ -0,0 +1,190 @@ +package com.inspect.reportpush.service; + +import com.inspect.base.core.constant.RedisConst; +import com.inspect.base.core.enums.TaskStatus; +import com.inspect.base.core.enums.TaskType; +import com.inspect.base.core.utils.StringUtils; +import com.inspect.base.redis.service.RedisService; +import com.inspect.analysis.domain.ResultAnalysis; +import com.inspect.analysis.service.IResultAnalysisService; +import com.inspect.reportpush.config.ReportPushProperties; +import com.inspect.resultmain.controller.DonghuanReportExportController; +import com.inspect.resultmain.domain.PatrolTaskResultMain; +import com.inspect.resultmain.service.IPatrolTaskResultMainService; +import com.inspect.taskstatus.domain.PatrolTaskStatus; +import com.inspect.taskstatus.service.IPatrolTaskStatusService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 任务巡检完成 → 自动归档 + 生成报告(动环 Word) → websocket 推送 + *

+ * 由 {@code TaskStatusChangeAspect} 在任务状态变更为「已完成(1)」时异步触发, + * 仅处理主任务/普通任务(子任务由主任务统一归档), + * 通过 Redis 防重锁避免重复归档。 + * + * @author inspect + */ +@Slf4j +@Service +public class ReportAutoArchiveService { + + private final ReportPushProperties properties; + private final RedisService redisService; + private final IPatrolTaskStatusService patrolTaskStatusService; + private final IPatrolTaskResultMainService patrolTaskResultMainService; + private final IResultAnalysisService resultAnalysisService; + private final DonghuanReportExportController donghuanReportExportController; + + public ReportAutoArchiveService(ReportPushProperties properties, + RedisService redisService, + IPatrolTaskStatusService patrolTaskStatusService, + IPatrolTaskResultMainService patrolTaskResultMainService, + IResultAnalysisService resultAnalysisService, + DonghuanReportExportController donghuanReportExportController) { + this.properties = properties; + this.redisService = redisService; + this.patrolTaskStatusService = patrolTaskStatusService; + this.patrolTaskResultMainService = patrolTaskResultMainService; + this.resultAnalysisService = resultAnalysisService; + this.donghuanReportExportController = donghuanReportExportController; + } + + /** + * 任务状态变更为已完成时触发(异步)。 + * + * @param taskStatus 变更后的任务状态(切面入参) + */ + @Async + public void onTaskCompleted(PatrolTaskStatus taskStatus) { + try { + if (!properties.isEnabled()) { + log.info("[AUTO_ARCHIVE] 未启用 inspect.report-push.enabled,跳过自动归档: taskPatrolledId={}", + taskStatus == null ? null : taskStatus.getTaskPatrolledId()); + return; + } + if (taskStatus == null || StringUtils.isEmpty(taskStatus.getTaskPatrolledId())) { + log.warn("[AUTO_ARCHIVE] 任务状态参数为空,跳过自动归档"); + return; + } + // 子任务不单独归档,由主任务统一归档 + if (TaskType.SUB.getCode().equals(taskStatus.getTaskType())) { + log.debug("[AUTO_ARCHIVE] 子任务不触发归档: {}", taskStatus.getTaskPatrolledId()); + return; + } + + // 归档单元:主任务(联合任务)或普通任务自身 + PatrolTaskStatus mainTask = patrolTaskStatusService.selectPatrolTaskStatusMain(taskStatus.getTaskPatrolledId()); + String archiveTaskPatrolledId = mainTask != null + ? mainTask.getTaskPatrolledId() : taskStatus.getTaskPatrolledId(); + if (StringUtils.isEmpty(archiveTaskPatrolledId)) { + log.warn("[AUTO_ARCHIVE] 无法确定归档任务ID,跳过: {}", taskStatus.getTaskPatrolledId()); + return; + } + + // 防重锁:同一归档单元只允许一次自动归档 + String lockKey = RedisConst.REPORT_PUSH_LOCK + archiveTaskPatrolledId; + Boolean locked = redisService.redisTemplate.opsForValue() + .setIfAbsent(lockKey, archiveTaskPatrolledId, + properties.getLockExpireSeconds(), TimeUnit.SECONDS); + if (!Boolean.TRUE.equals(locked)) { + log.info("[AUTO_ARCHIVE] 任务 {} 已在归档/推送中(防重锁),跳过", archiveTaskPatrolledId); + return; + } + log.info("[AUTO_ARCHIVE] 获取归档防重锁: {}", lockKey); + + // 等待任务状态事务提交、结果数据落库 + long waitMillis = properties.getAfterCommitDelayMillis(); + if (waitMillis > 0) { + try { + Thread.sleep(waitMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + releaseLock(lockKey); + return; + } + } + + // 二次校验:任务仍为已完成状态 + PatrolTaskStatus freshTask = patrolTaskStatusService.selectPatrolTaskStatusByTaskPatrolledId(archiveTaskPatrolledId); + if (freshTask == null || !TaskStatus.DONE.getCode().equals(freshTask.getTaskState())) { + log.info("[AUTO_ARCHIVE] 任务 {} 当前状态 {},非已完成,跳过自动归档", + archiveTaskPatrolledId, freshTask == null ? "null" : freshTask.getTaskState()); + releaseLock(lockKey); + return; + } + + // 二次校验:已归档(file_status=1)的任务不重复归档 + PatrolTaskResultMain existMain = patrolTaskResultMainService + .selectPatrolTaskResultMainByTaskPatrolledId(archiveTaskPatrolledId); + if (existMain != null && "1".equals(existMain.getFileStatus())) { + log.info("[AUTO_ARCHIVE] 任务 {} 已归档(fileStatus=1),跳过", archiveTaskPatrolledId); + releaseLock(lockKey); + return; + } + + // 归档范围:主任务 + 其下所有子任务(联合任务) + List taskPatrolledIds = new ArrayList<>(); + taskPatrolledIds.add(archiveTaskPatrolledId); + List subTasks = patrolTaskStatusService.selectPatrolTaskStatusListByMainId(archiveTaskPatrolledId); + if (subTasks != null) { + for (PatrolTaskStatus subTask : subTasks) { + if (StringUtils.isNotEmpty(subTask.getTaskPatrolledId())) { + taskPatrolledIds.add(subTask.getTaskPatrolledId()); + } + } + } + + // 触发归档(动环 Word 报告),报告生成后由 exportDonghuanReport 回调推送 + PatrolTaskResultMain resultMain = new PatrolTaskResultMain(); + resultMain.setTaskPatrolledIds(taskPatrolledIds); + resultMain.setCheckPerson(properties.getCheckPerson()); + resultMain.setTaskResult(properties.getTaskResult()); + log.info("[AUTO_ARCHIVE] 触发自动归档: taskPatrolledIds={}, checkPerson={}, taskResult={}", + taskPatrolledIds, resultMain.getCheckPerson(), resultMain.getTaskResult()); + donghuanReportExportController.updateInfo_donghuan(resultMain); + } catch (Exception e) { + log.error("[AUTO_ARCHIVE] 自动归档异常: taskPatrolledId={}", + taskStatus == null ? null : taskStatus.getTaskPatrolledId(), e); + } + } + + /** + * 校验任务(含子任务)是否存在缺陷数据(初筛缺陷 + 大模型缺陷) + */ + private boolean hasDefect(List taskPatrolledIds) { + try { + Map abnormalMap = resultAnalysisService.selectTaskAbnormalDataBatch(taskPatrolledIds); + if (abnormalMap == null || abnormalMap.isEmpty()) { + return false; + } + int csQxNum = 0, aiQxNum = 0; + for (ResultAnalysis analysis : abnormalMap.values()) { + if (analysis == null) { + continue; + } + csQxNum += analysis.getCsQxNum(); + aiQxNum += analysis.getAiQxNum(); + } + return csQxNum + aiQxNum > 0; + } catch (Exception e) { + log.error("[AUTO_ARCHIVE] 查询缺陷数据异常,按存在缺陷处理: taskPatrolledIds={}", taskPatrolledIds, e); + return true; + } + } + + private void releaseLock(String lockKey) { + try { + redisService.deleteObject(lockKey); + } catch (Exception e) { + log.warn("[AUTO_ARCHIVE] 释放防重锁失败: {}", lockKey, e); + } + } + +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java new file mode 100644 index 0000000..75d741e --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/reportpush/service/ReportPushService.java @@ -0,0 +1,148 @@ +package com.inspect.reportpush.service; + +import com.alibaba.fastjson.JSONObject; +import com.inspect.api.domain.ReportPushMessage; +import com.inspect.api.service.ReportPushPublisher; +import com.inspect.base.core.utils.DateUtils; +import com.inspect.base.core.utils.StringUtils; +import com.inspect.insreport.domain.InspectionReport; +import com.inspect.partrolresult.mapper.PatrolResultMapper; +import com.inspect.reportpush.config.ReportPushProperties; +import com.inspect.task.mapper.PatrolTaskMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; + +/** + * 巡检报告推送服务(先上报 RabbitMQ,由 inspect-main-api 的 + * {@link com.inspect.api.service.ReportUploadConsumer} 异步 HTTP 上传到外部平台, + * 见《盐源新能源智慧感知平台_巡检报告接口文档 v1.0.0》)。 + *

+ * 报告归档(Word)生成完毕后,组装 reportInfo 并发布 report.upload.queue 消息, + * 消费者下载报告 docx 后 POST /jb-nessp-external/api/uploadReport 上传 + * (multipart/form-data:file + reportInfo,请求头携带 joinbright-token)。 + * + * @author inspect + */ +@Slf4j +@Service +public class ReportPushService { + + private final ReportPushProperties properties; + private final ReportPushPublisher reportPushPublisher; + private final PatrolTaskMapper patrolTaskMapper; + private final PatrolResultMapper patrolResultMapper; + + public ReportPushService(ReportPushProperties properties, + ReportPushPublisher reportPushPublisher, + PatrolTaskMapper patrolTaskMapper, + PatrolResultMapper patrolResultMapper) { + this.properties = properties; + this.reportPushPublisher = reportPushPublisher; + this.patrolTaskMapper = patrolTaskMapper; + this.patrolResultMapper = patrolResultMapper; + } + + /** + * 报告生成并上传完成后推送。自动归档与手动归档均会触发。 + * + * @param report 已保存 filePath 的巡检报告 + */ + public void pushReport(InspectionReport report) { + pushReport(report, null); + } + + /** + * 报告生成并上传完成后推送(带发起人)。 + * + * @param report 已保存 filePath 的巡检报告 + * @param username 报告发起人(当前登录账号,可为空) + */ + public void pushReport(InspectionReport report, String username) { + if (report == null) { + return; + } + if (!properties.isEnabled()) { + log.info("[REPORT_PUSH] 未启用 report-push 配置,跳过报告推送: reportId={}", report.getLineId()); + return; + } + // 报告文件路径(动环导出保存的是 .docx,兼容 .xls 结尾路径) + String filePath = report.getFilePath(); + if (StringUtils.isEmpty(filePath)) { + log.warn("[REPORT_PUSH] 报告文件路径为空, 无法推送: reportId={}", report.getLineId()); + return; + } + String fileName = filePath.substring(filePath.lastIndexOf('/') + 1); + // 报告元数据(《盐源新能源智慧感知平台_巡检报告接口文档_v1.0.0(2)》reportInfo 字段,发布时组装) + JSONObject reportInfo = new JSONObject(); + reportInfo.put("id", report.getLineId() == null + ? String.valueOf(System.currentTimeMillis()) : String.valueOf(report.getLineId())); + reportInfo.put("name", StringUtils.isNotEmpty(fileName) + ? fileName : "巡检报告"); + reportInfo.put("fileType", "docx"); + reportInfo.put("stationId", resolveStationId()); + reportInfo.put("stationName", resolveStationName(report)); + reportInfo.put("inspectTime", report.getInspectionStartTime() == null ? null + : DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, report.getInspectionStartTime())); + reportInfo.put("createTime", DateUtils.parseDateToStr(DateUtils.yyyyMMddHHmmss2, new Date())); + reportInfo.put("inspectType", resolveInspectType()); + reportInfo.put("inspectDeviceName", properties.getInspectDeviceName()); + reportInfo.put("inspectContent", StringUtils.isNotEmpty(report.getPatrolStatistics()) + ? report.getPatrolStatistics() : report.getInspectionTaskName()); + reportInfo.put("taskId", StringUtils.isNotEmpty(report.getTaskId()) ? report.getTaskId() : ""); + reportInfo.put("taskName", report.getInspectionTaskName()); + // 报告发起人:归档入口捕获的当前登录账号,为空时取配置审核人 + reportInfo.put("username", StringUtils.isNotEmpty(username) ? username : properties.getCheckPerson()); + + ReportPushMessage message = new ReportPushMessage(); + message.setReportId(report.getLineId()); + message.setFilePath(filePath); + message.setReportInfo(reportInfo.toJSONString()); + reportPushPublisher.publish(message); + log.info("[REPORT_PUSH] 报告推送消息已发布: reportId={}, filePath={}, username={}", + report.getLineId(), filePath, username); + } + + /** + * 场站ID:配置优先,其次取 basedata_station.station_no + */ + private String resolveStationId() { + if (StringUtils.isNotEmpty(properties.getStationId())) { + return properties.getStationId(); + } + try { + String stationNo = patrolTaskMapper.selectStationNo(); + return stationNo == null ? "" : stationNo; + } catch (Exception e) { + log.warn("[REPORT_PUSH] 查询场站编号失败: {}", e.getMessage()); + return ""; + } + } + + /** + * 电站名称:报告记录优先,其次 basedata_station.station_name + */ + private String resolveStationName(InspectionReport report) { + if (report != null && StringUtils.isNotEmpty(report.getStationName())) { + return report.getStationName(); + } + try { + Map stationMap = patrolResultMapper.selectBasedataStation(); + if (stationMap != null && stationMap.get("station_name") != null) { + return stationMap.get("station_name"); + } + } catch (Exception e) { + log.warn("[REPORT_PUSH] 查询电站名称失败: {}", e.getMessage()); + } + return ""; + } + + /** + * 巡检类型:配置优先,默认动环智能巡检 + */ + private String resolveInspectType() { + return StringUtils.isNotEmpty(properties.getInspectType()) ? properties.getInspectType() : "动环智能巡检"; + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java new file mode 100644 index 0000000..965ac11 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java @@ -0,0 +1,1221 @@ +package com.inspect.resultmain.controller; + +import com.inspect.base.core.sftp.SftpClient; +import com.inspect.base.core.sftp.SftpUploadEntity; +import com.inspect.base.core.utils.DateUtils; +import com.inspect.base.core.utils.StringUtils; +import com.inspect.base.core.web.controller.BaseController; +import com.inspect.base.core.web.domain.AjaxResult; +import com.inspect.common.security.utils.SecurityUtils; +import com.inspect.img.domain.InspectionReportImg; +import com.inspect.img.service.IInspectionReportImgService; +import com.inspect.insreport.domain.InspectionReport; +import com.inspect.insreport.service.IInspectionReportService; +import com.inspect.insreportdata.domain.InspectionReportData; +import com.inspect.insreportdata.service.IInspectionReportDataService; +import com.inspect.message.MessageUtils; +import com.inspect.partrolresult.domain.PatrolResult; +import com.inspect.partrolresult.service.IPatrolResultService; +import com.inspect.partrolresult.util.PrintUtil; +import com.inspect.reportfont.ReportFontEmbedder; +import com.inspect.reportpush.service.ReportPushService; +import com.inspect.resultmain.domain.PatrolTaskResultMain; +import com.inspect.resultmain.service.IPatrolTaskResultMainService; +import org.apache.commons.io.function.IOConsumer; +import org.apache.poi.xwpf.usermodel.*; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 动环智能巡检报告 —— 归档导出(Word 版) + *

+ * 逻辑流程对标 {@link PatrolTaskResultMainController#updateInfo_shaoxing} / + * {@code exportExcelWordAndZipShaoxing}(数据准备、保存、异步导出完全一致), + * 仅 Word 生成改为动环模板样式(模板:动环智能巡检报告_20260817.docx)。 + *

+ * 样式要点(与模板逐项对齐): + *

    + *
  • 页面:A4(11906×16838 twips),版心 上2098/右1474/下1984/左1587
  • + *
  • 封面:42pt 仿宋大标题 + 分页;第二页 logo + 方正小标宋 22pt 深蓝标题 + * + 仿宋 16pt 浅蓝副标题 + 封面签署表(初审/校对/审核/巡检日期)
  • + *
  • 标题:黑体 16pt 加粗;表头:深蓝 1F4E79 底白字加粗;正文:仿宋 12pt
  • + *
  • 表格边框:B4C6E7 细线(sz=0),单元格垂直居中,行高不低于 454 twips
  • + *
  • 巡检详情:每个点位一张 8 行 × 4 列详情表,末行基准图/缺陷图
  • + *
+ *

+ * 已知不确定点(待归档时调整,均以 TODO 标注): + * 位置字段来源、巡检点位/设备数量口径、审核意见来源、巡检读数、封面 logo 路径、 + * 基准图/缺陷图选择规则、详情表范围(全部点位 or 仅缺陷)、Excel 导出。 + * + * @author inspect + */ +@RestController +@RefreshScope +@RequestMapping({"/resultmain"}) +public class DonghuanReportExportController extends BaseController { + + // ============================ 样式常量(与模板一致) ============================ + /** 正文 */ + private static final String FONT_BODY = "仿宋"; + /** 一级小标题 */ + private static final String FONT_HEAD = "黑体"; + /** 报告标题 */ + private static final String FONT_TITLE = "方正小标宋简体"; + /** 深蓝:标题 / 表头底色 */ + private static final String COLOR_DARK = "1F4E79"; + /** 浅蓝:封面副标题 */ + private static final String COLOR_LIGHT = "2E75B6"; + /** 表格边框浅蓝 */ + private static final String COLOR_BORDER = "B4C6E7"; + /** 封面占位值灰色 */ + private static final String COLOR_GRAY = "999999"; + /** 表头白字 */ + private static final String COLOR_WHITE = "FFFFFF"; + + /** 正文 12pt(半磅 24) */ + private static final int SZ_BODY = 24; + /** 小标题 14pt(半磅 28) 四号 */ + private static final int SZ_HEAD_FOURTH = 28; + /** 小标题 16pt(半磅 32)三号 */ + private static final int SZ_HEAD_THIRD = 32; + /** 行高 atLeast(twips) */ + private static final int ROW_H = 454; + /** 单元格左右边距(twips) */ + private static final int CELL_MAR = 108; + + /** 巡检详情图片尺寸(EMU,与模板一致) */ + private static final long IMG_PATROL_W = 1711325L, IMG_PATROL_H = 960755L; + private static final long IMG_DEFECT_W = 1629410L, IMG_DEFECT_H = 916940L; + + @Value("${sftp.basePath}") + private String basePath; + + @Value("${sftp.stationCode}") + private String stationCode; + + /** + * 封面 logo 图片:默认从 classpath 加载(图片位于 + * inspect-main-task/src/main/resources/report/logo.png) + */ + @Value("${report.donghuan.logo:report/logo.png}") + private String logoPath; + + private final IPatrolTaskResultMainService patrolTaskResultMainService; + private final IPatrolResultService patrolResultService; + private final IInspectionReportDataService inspectionReportDataService; + private final IInspectionReportImgService inspectionReportImgService; + private final IInspectionReportService inspectionReportService; + private final SftpClient sftpClient; + private final ReportPushService reportPushService; + private final ReportFontEmbedder reportFontEmbedder; + + private static final ExecutorService executor = Executors.newFixedThreadPool(10); + + @Autowired + private MessageUtils messageUtils; + + public DonghuanReportExportController(IPatrolTaskResultMainService patrolTaskResultMainService, + IPatrolResultService patrolResultService, + IInspectionReportDataService inspectionReportDataService, + IInspectionReportImgService inspectionReportImgService, + IInspectionReportService inspectionReportService, + SftpClient sftpClient, + ReportPushService reportPushService, + ReportFontEmbedder reportFontEmbedder) { + this.patrolTaskResultMainService = patrolTaskResultMainService; + this.patrolResultService = patrolResultService; + this.inspectionReportDataService = inspectionReportDataService; + this.inspectionReportImgService = inspectionReportImgService; + this.inspectionReportService = inspectionReportService; + this.sftpClient = sftpClient; + this.reportPushService = reportPushService; + this.reportFontEmbedder = reportFontEmbedder; + } + + // ============================================================================ + // 一、入口:动环归档 + // ============================================================================ + @PostMapping({"/updateInfo_shaoxing"}) + public AjaxResult updateInfo_donghuan(@RequestBody PatrolTaskResultMain resultMain) { + long startTime = System.currentTimeMillis(); + // 报告发起人:当前登录账号 + final String username = SecurityUtils.getUsername(); + logger.info("[ARCHIVE] 动环归档触发, 发起人: {}", username); + CompletableFuture> saveReportFuture = CompletableFuture.>supplyAsync(() -> { + List taskPatrolledIds = resultMain.getTaskPatrolledIds(); + logger.info("-----------patrolTaskResultMains taskPatrolledIds: {}", taskPatrolledIds); + List patrolTaskResultMains = + patrolTaskResultMainService.selectPatrolTaskResultMainByTaskPatrolledIds(taskPatrolledIds); + if (patrolTaskResultMains == null || patrolTaskResultMains.isEmpty()) { + logger.error("[归档]失败, 数据采集中..."); + return Collections.emptyList(); + } + List lineIds = patrolTaskResultMains.stream() + .map(PatrolTaskResultMain::getLineId).collect(Collectors.toList()); + List longs = patrolTaskResultMainService.selectLineIdsByList(lineIds); + if (longs != null && !longs.isEmpty()) { + lineIds.addAll(longs); + } + lineIds = lineIds.stream().distinct().collect(Collectors.toList()); + List resultList = patrolResultService.selectPatrolResultListByMainIds(lineIds); + if (resultList == null || resultList.isEmpty()) { + logger.error("[归档]失败, 数据采集中..."); + return Collections.emptyList(); + } + PrintUtil.useTime("查询统计数据", startTime); + logger.info("[ARCHIVE] Start saving reports (donghuan)"); + return patrolResultService.saveReportShaoxing(resultMain, lineIds, resultList); + }, executor).exceptionally(ex -> { + logger.error("Report saving failed", ex); + return Collections.emptyList(); + }); + PrintUtil.useTime("保存报告数据", startTime); + + // 保存完成后异步导出(动环 Word 版) + saveReportFuture.thenAcceptAsync(reportIds -> { + logger.info("-----------[ARCHIVE] Saved export reportIds: {}", reportIds); + if (reportIds.isEmpty()) { + logger.warn("No reports to export"); + return; + } + CompletableFuture.runAsync(() -> exportDonghuanReport(reportIds, username), executor) + .whenComplete((v, ex) -> { + if (ex != null) { + logger.error("Async export failed for reportIds: {}", reportIds, ex); + } + }); + }, executor).exceptionally(ex -> { + logger.error("Error occurred during saveReport: ", ex); + return null; + }); + return toAjax(1); + } + + // ============================================================================ + // 二、导出:动环 Word + 图片 ZIP + // ============================================================================ + public void exportDonghuanReport(List reportIds, String username) { + // 合并报告,所有报告内容一致,取第一个导出即可 + Long lineId = reportIds.get(0); + List tempFiles = new ArrayList<>(); + Map tempFileMap = new LinkedHashMap<>(); + XWPFDocument wordDocument = null; + try { + InspectionReport inspectionReport = + inspectionReportService.selectInspectionReportByLineId(lineId); + if (inspectionReport == null) { + logger.warn("--exportDonghuanReport----report lineId:{} not exist!", lineId); + return; + } + InspectionReportData query = new InspectionReportData(); + query.setReportId(String.valueOf(inspectionReport.getLineId())); + List dataList = + inspectionReportDataService.selectInspectionReportDataList(query); + if (dataList == null || dataList.isEmpty()) { + logger.info("--exportDonghuanReport------report lineId:{},filter:{} no report data!", + lineId, inspectionReport.getFilter()); + return; + } + inspectionReport.setReportDatalist(dataList); + + // 图片映射:imgSrc -> 唯一文件名 + Map imgSrcToFileNameMap = new HashMap<>(); + List images = new ArrayList<>(); + for (InspectionReportData reportData : dataList) { + if (!messageUtils.get("缺陷").equals(reportData.getPointStatus())) { + continue; + } + InspectionReportImg imgQuery = new InspectionReportImg(); + imgQuery.setReportInfoId(reportData.getLineId()); + List reportImgList = + inspectionReportImgService.selectInspectionReportImgList(imgQuery); + reportData.setReportImgList(reportImgList); + + int imgIndex = 1; + for (InspectionReportImg reportImg : reportImgList) { + // 原图 + String originalImgSrc = reportImg.getImgSrc(); + // 基准图 + String imageNormalUrlPath = reportImg.getImageNormalUrlPath(); + // 结果图 + String img = reportImg.getImg(); + images.add(originalImgSrc); + images.add(imageNormalUrlPath); + images.add(img); + String pointName = reportData.getPointName() != null + ? reportData.getPointName() : "unnamed"; + + // 原图 + if (originalImgSrc != null) { + String extension = ""; + int dotIndex = originalImgSrc.lastIndexOf('.'); + if (dotIndex > 0) { + extension = originalImgSrc.substring(dotIndex); + } + String uniqueFileName = String.format("%s_%s_%s_%d%s", + originalImgSrc.split("_")[2], + pointName.replaceAll("[\\\\/:*?\"<>|]", "") + .replaceAll("\\s+", ""), + reportData.getLineId(), imgIndex, extension); + imgSrcToFileNameMap.put(originalImgSrc, uniqueFileName); + imgIndex++; + } + // 结果图 + if (img != null) { + String extension = ""; + int dotIndex = img.lastIndexOf('.'); + if (dotIndex > 0) { + extension = img.substring(dotIndex); + } + String uniqueImgName = String.format("%s_%s_%s_%d%s", + img.split("_")[2], + pointName.replaceAll("[\\\\/:*?\"<>|]", "") + .replaceAll("\\s+", ""), + reportData.getLineId(), imgIndex, extension); + imgSrcToFileNameMap.put(img, uniqueImgName); + imgIndex++; + } + // 基准图 + if (imageNormalUrlPath != null) { + String extension = ""; + int dotIndex = imageNormalUrlPath.lastIndexOf('.'); + if (dotIndex > 0) { + extension = imageNormalUrlPath.substring(dotIndex); + } + String uniqueImgNormalPathName = String.format("%s_%s_%s_%d%s", + imageNormalUrlPath.split("_")[2], + pointName.replaceAll("[\\\\/:*?\"<>|]", "") + .replaceAll("\\s+", ""), + reportData.getLineId(), imgIndex, extension); + imgSrcToFileNameMap.put(imageNormalUrlPath, uniqueImgNormalPathName); + imgIndex++; + } + } + } + + // 图片下载到临时文件,避免内存驻留大量图片字节 + for (String imgSrc : images) { + try { + Path tempFile = downloadImageToTemp(imgSrc); + if (tempFile != null) { + tempFileMap.put(imgSrc, tempFile); + tempFiles.add(tempFile); + } + } catch (Exception e) { + logger.warn("报告归档下载图片失败: {}", imgSrc, e); + } + } + + // 生成动环样式 Word + wordDocument = getXWPFDocumentDonghuan(inspectionReport, tempFileMap); + + String basePath = this.basePath + stationCode + "/Model/"; + Date startTime = inspectionReport.getInspectionStartTime() != null + ? inspectionReport.getInspectionStartTime() : new Date(); + String timestamp = DateUtils.format(DateUtils.yyyyMMddHHmmss2, startTime) + + "_" + System.currentTimeMillis(); + String namePrefix = ""; +// if ("1".equals(inspectionReport.getFilter())) { +// namePrefix = messageUtils.get("初筛") + "_"; +// } else if ("0".equals(inspectionReport.getFilter())) { +// namePrefix = messageUtils.get("大模型") + "_"; +// } + String reportName = inspectionReport.getInspectionTaskName() + "_" + timestamp; + + // 上传图片 ZIP + CompletableFuture zipUploadFuture = CompletableFuture.runAsync(() -> { + uploadFile(basePath, reportName, ".zip", outputStream -> { + try (ZipOutputStream zipOut = new ZipOutputStream(outputStream)) { + for (Map.Entry entry : tempFileMap.entrySet()) { + String pointFileName = imgSrcToFileNameMap + .getOrDefault(entry.getKey(), "unnamed"); + ZipEntry zipEntry = new ZipEntry(pointFileName); + zipOut.putNextEntry(zipEntry); + Files.copy(entry.getValue(), zipOut); + zipOut.closeEntry(); + } + } catch (IOException e) { + throw new RuntimeException("生成ZIP文件失败", e); + } + }); + }, executor); + + // TODO 归档时调整:如需 Excel 导出,可复用 + // PatrolTaskResultMainController.getSXSSFWorkbookShaoxing(inspectionReport, tempFileMap) + // 并在此上传 .xls(本类暂只导出 Word + 图片 ZIP) + + // 上传 Word + XWPFDocument finalWordDocument = wordDocument; + CompletableFuture wordUploadFuture = CompletableFuture.runAsync(() -> { + uploadFile(basePath, reportName, ".docx", finalWordDocument::write); + }, executor); + + CompletableFuture.allOf(zipUploadFuture, wordUploadFuture) + .whenComplete((v, t) -> { + try { + inspectionReport.setFilePath(basePath + reportName + ".docx"); + inspectionReport.setLineIds(reportIds); + inspectionReportService.updateInspectionReportFilePath(inspectionReport); + // 报告生成并上传完成后,发布消息到 RabbitMQ,由消费者异步 HTTP 上传外部平台(自动归档与手动归档均推送) + reportPushService.pushReport(inspectionReport, username); + } finally { + closeResources(finalWordDocument, tempFiles); + } + }); + } catch (Throwable e) { + logger.error("生成动环巡视文件异常", e); + closeResources(wordDocument, tempFiles); + } + } + + // ============================================================================ + // 三、动环 Word 构建 + // ============================================================================ + private XWPFDocument getXWPFDocumentDonghuan(InspectionReport report, + Map tempFileMap) { + XWPFDocument doc = new XWPFDocument(); + // 嵌入报告字体(仿宋/黑体/方正小标宋),docx 自带字体,查看方无需安装 + reportFontEmbedder.embed(doc); + setupPage(doc); + buildCoverPage1(doc); // 第1页:42pt 大标题 + 分页 + buildCoverPage2(doc, report); // 第2页:logo + 报告标题 + 副标题 + 签署表 + buildSectionBasic(doc, report); // 一、巡检基本情况 + buildSectionStatus(doc); // 二、巡检状态说明 + buildSectionOverview(doc, report); // 三、巡检结果概览 + buildSectionDetail(doc, report, tempFileMap); // 四、巡检详情 + return doc; + } + + /** 段落 keepNext:标题与后续表格/内容保持同页,避免标题孤行 */ + private void setKeepNext(XWPFParagraph p) { + CTPPr pPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + if (!pPr.isSetKeepNext()) { + pPr.addNewKeepNext(); + } + } + + /** + * 设置段落首行缩进 1 字符。 + * 写 w:firstLineChars=100(Word/WPS 原生「首行缩进:1 字符」,随字号自动折算 1 个汉字宽), + * 同时按段落字号(halfPoints,半磅)回填 w:firstLine(twips = halfPoints × 10), + * 兼容不识别 firstLineChars 的阅读器(如 LibreOffice)。 + */ + private void setFirstLineIndentOneChar(XWPFParagraph p, int halfPoints) { + p.setIndentationFirstLine(halfPoints * 10); + CTPPr pPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + CTInd ind = pPr.isSetInd() ? pPr.getInd() : pPr.addNewInd(); + ind.setFirstLineChars(BigInteger.valueOf(100)); + } + + /** 页面设置:A4 + 模板版心 + 文档网格(与模板 sectPr 一致) */ + private void setupPage(XWPFDocument doc) { + CTBody body = doc.getDocument().getBody(); + CTSectPr sectPr = body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); + CTPageSz pgSz = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); + pgSz.setW(BigInteger.valueOf(11906)); + pgSz.setH(BigInteger.valueOf(16838)); + CTPageMar pgMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); + pgMar.setTop(BigInteger.valueOf(2098)); + pgMar.setRight(BigInteger.valueOf(1474)); + pgMar.setBottom(BigInteger.valueOf(1984)); + pgMar.setLeft(BigInteger.valueOf(1587)); + pgMar.setHeader(BigInteger.valueOf(851)); + pgMar.setFooter(BigInteger.valueOf(992)); + pgMar.setGutter(BigInteger.ZERO); + CTColumns cols = sectPr.isSetCols() ? sectPr.getCols() : sectPr.addNewCols(); + cols.setNum(BigInteger.ONE); + cols.setSpace(BigInteger.valueOf(720)); + // 文档网格 linePitch=360(每行 18pt):模板封面第二页正是靠它撑满一页, + // 否则空行按 12pt 排版,内容变矮,「一、巡检基本情况」会被挤到封面页 + CTDocGrid grid = sectPr.isSetDocGrid() ? sectPr.getDocGrid() : sectPr.addNewDocGrid(); + grid.setLinePitch(BigInteger.valueOf(360)); + grid.setCharSpace(BigInteger.ZERO); + } + + /** 第1页封面:42pt 仿宋大标题 + 分页符 */ + private void buildCoverPage1(XWPFDocument doc) { + XWPFParagraph p = addPara(doc, "#动环智能巡检", FONT_BODY, 84, false, false, null, + ParagraphAlignment.CENTER, 120, 120); + p.createRun().addBreak(BreakType.PAGE); + } + + /** 第2页:logo + 报告标题 + 副标题 + 空行 + 封面签署表 */ + private void buildCoverPage2(XWPFDocument doc, InspectionReport report) { + // logo:优先 classpath 资源(src/main/resources/report/logo.png), + // 兼容本地绝对/相对路径 + if (StringUtils.isNotEmpty(logoPath)) { + try (InputStream is = openLogoStream(logoPath)) { + if (is != null) { + XWPFParagraph logoPara = doc.createParagraph(); + XWPFRun logoRun = logoPara.createRun(); + // 与模板一致:3203575 × 541020 EMU(POI 5.x 的 addPicture 仅 int 尺寸重载) + logoRun.addPicture(is, XWPFDocument.PICTURE_TYPE_PNG, "logo.png", + (int) 3203575L, (int) 541020L); + } + } catch (Exception e) { + logger.warn("插入封面 logo 失败: {}", logoPath, e); + } + } + addPara(doc, "", null, SZ_BODY, false, false, null, null, 600, null); + addPara(doc, "", null, SZ_BODY, false, false, null, null, 200, null); + + // 报告标题:站名 + 巡检报告(方正小标宋简体 22pt 加粗 深蓝) + // TODO 归档时调整:站名是否已含「场站/电站」后缀 + // 站名为空时标题不拼占位符(标题本身不会空白) + String station = report.getStationName(); + String reportTitle = (StringUtils.isEmpty(station) ? "" : station) + "巡检报告"; + addPara(doc, reportTitle, FONT_TITLE, 44, true, false, COLOR_DARK, + ParagraphAlignment.CENTER, 0, 100); + // 副标题 + addPara(doc, "【巡检类型:动环智能巡检】", FONT_BODY, SZ_HEAD_THIRD, false, false, + COLOR_LIGHT, ParagraphAlignment.CENTER, 200, 100); + + // 空行(撑出版面,与模板一致) + for (int i = 0; i < 11; i++) { + addPara(doc, "", null, SZ_BODY, false, false, null, + ParagraphAlignment.CENTER, 200, 100); + } + addPara(doc, "", null, SZ_BODY, false, false, null, + ParagraphAlignment.CENTER, 400, null); + + // 封面签署表:初审/校对/审核/巡检日期(4行×2列,宽4686 twips) + XWPFTable table = addTable(doc, 4, 2, new int[]{1800, 2886}, 4686, true); + String[] labels = {"初审", "校对", "审核", "巡检日期"}; + // TODO 归档时调整:初审/校对/审核 人员数据来源暂用占位符;巡检日期取巡视开始日期 + String[] values = {"[填写]", "[填写]", "[填写]", + report.getInspectionStartTime() != null + ? DateUtils.format(DateUtils.yyyyMMdd2, report.getInspectionStartTime()) : "[填写]"}; + for (int i = 0; i < labels.length; i++) { + setRow(table.getRow(i), null, true); + setRowJcCenter(table.getRow(i)); + // 标签:仿宋 12pt 加粗 深蓝 居中 + styleCell(table.getRow(i).getCell(0), labels[i], FONT_BODY, SZ_BODY, true, false, + COLOR_DARK, false, ParagraphAlignment.CENTER, 0, 0, 1800, null, + 60, 150, 60, 120); + // 值:仿宋 12pt 灰色斜体 居中 + styleCell(table.getRow(i).getCell(1), values[i], FONT_BODY, SZ_BODY, false, true, + COLOR_GRAY, false, ParagraphAlignment.CENTER, 0, 0, 2886, null, + 60, 150, 60, 120); + } + } + + /** 一、巡检基本情况 */ + private void buildSectionBasic(XWPFDocument doc, InspectionReport report) { + XWPFParagraph h = addPara(doc, "一、巡检基本情况", FONT_HEAD, SZ_HEAD_FOURTH, true, false, + null, null, 160, 80); + setKeepNext(h); + setFirstLineIndentOneChar(h, SZ_HEAD_FOURTH); // 标题首行缩进 1 字符 + + XWPFTable t = addTable(doc, 7, 4, new int[]{1827, 2655, 1920, 2659}, 9061, false); + List dataList = report.getReportDatalist(); + + // R0 表头(跨4列,深蓝底白字) + setRow(t.getRow(0), ROW_H, false); + mergeRowCells(t, 0, 0, 3); + styleCell(t.getRow(0).getCell(0), "场站基本信息", FONT_BODY, SZ_BODY, true, false, + COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4, + 0, CELL_MAR, 0, CELL_MAR); + + // R1 电站名称 | 白杨坪 | 位置 | 四川省成都市 + setRow(t.getRow(1), ROW_H, false); + bodyCell(t.getRow(1).getCell(0), "电站名称", 1827); + bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), 2655); + bodyCell(t.getRow(1).getCell(2), "位置", 1920); + bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), 2659); + + // ==================== R2:巡视任务基本信息(新增表头) ==================== + setRow(t.getRow(2), ROW_H, false); + mergeRowCells(t, 2, 0, 3); + styleCell(t.getRow(2).getCell(0), "巡视任务基本信息", FONT_BODY, SZ_BODY, true, false, + COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4, + 0, CELL_MAR, 0, CELL_MAR); + + // R3 巡检任务 | xxx(跨3列) + setRow(t.getRow(3), ROW_H, false); + bodyCell(t.getRow(3).getCell(0), "巡检任务", 1827); + mergeRowCells(t, 3, 1, 3); + styleCell(t.getRow(3).getCell(1), val(report.getInspectionTaskName()), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.LEFT, 0, 0, + 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR); + + // R4 巡检开始时间 | xx | 巡检结束时间 | xx + setRow(t.getRow(4), ROW_H, false); + bodyCell(t.getRow(4).getCell(0), "巡检开始时间", 1827); + bodyCell(t.getRow(4).getCell(1), val(formatDateTime(report.getInspectionStartTime())), 2655); + bodyCell(t.getRow(4).getCell(2), "巡检结束时间", 1920); + bodyCell(t.getRow(4).getCell(3), val(formatDateTime(report.getInspectionEndTime())), 2659); + + // R5 巡检点位数量(个) | n | 巡检设备数量(个) | m + // TODO 归档时调整:数量口径(按点位名去重 / 按设备名去重) + setRow(t.getRow(5), ROW_H, false); + bodyCell(t.getRow(5).getCell(0), "巡检点位数量(个)", 1827); + bodyCell(t.getRow(5).getCell(1), String.valueOf(distinctCount(dataList, + InspectionReportData::getPointName)), 2655); + bodyCell(t.getRow(5).getCell(2), "巡检设备数量(个)", 1920); + bodyCell(t.getRow(5).getCell(3), String.valueOf(distinctCount(dataList, + InspectionReportData::getEqName)), 2659); + + // R6 审核意见 | (跨3列,可空) + // TODO 归档时调整:审核意见来源(暂用 report.description,可改为 checkPerson 或空) + setRow(t.getRow(6), ROW_H, false); + bodyCell(t.getRow(6).getCell(0), "审核意见", 1827); + mergeRowCells(t, 6, 1, 3); + styleCell(t.getRow(6).getCell(1), val(report.getDescription()), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.LEFT, 0, 0, + 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR); + } + + /** 二、巡检状态说明(静态文案,与模板一致) */ + private void buildSectionStatus(XWPFDocument doc) { + XWPFParagraph h = addPara(doc, "二、巡检状态说明", FONT_HEAD, SZ_HEAD_FOURTH, true, false, + null, null, 160, 80); + setKeepNext(h); + setFirstLineIndentOneChar(h, SZ_HEAD_FOURTH); // 标题首行缩进 1 字符 + + XWPFTable t = addTable(doc, 4, 3, new int[]{1831, 4115, 3114}, 9060, false); + String[][] rows = { + {"状态", "说明", "典型场景"}, + {"缺陷", "巡检结果表明设备存在异常状态或潜在运行风险", + "表计超限、温度过高、表面油污、设备部件破损"}, + {"异常", "巡检数据未满足有效判定条件,无法准确判断设备当前状态", + "图像模糊、目标遮挡、通信异常、传感器数据异常"}, + {"正常", "巡检数据有效,检测结果符合设备正常运行要求,未发现异常状态或运行风险", + "表计读数正常、温度正常、设备外观正常"}, + }; + for (int i = 0; i < rows.length; i++) { + setRow(t.getRow(i), ROW_H, false); + if (i == 0) { + headerCell(t.getRow(i).getCell(0), rows[i][0], 1831); + headerCell(t.getRow(i).getCell(1), rows[i][1], 4115); + headerCell(t.getRow(i).getCell(2), rows[i][2], 3114); + } else { + bodyCell(t.getRow(i).getCell(0), rows[i][0], 1831, true); + bodyCell(t.getRow(i).getCell(1), rows[i][1], 4115, false); + bodyCell(t.getRow(i).getCell(2), rows[i][2], 3114, false); + } + } + } + + /** 三、巡检结果概览:(一)巡检统计 + (二)缺陷汇总表 */ + private void buildSectionOverview(XWPFDocument doc, InspectionReport report) { + XWPFParagraph h = addPara(doc, "三、巡检结果概览", FONT_HEAD, SZ_HEAD_FOURTH, true, false, + null, null, 160, 80); + setKeepNext(h); + setFirstLineIndentOneChar(h, SZ_HEAD_FOURTH); // 标题首行缩进 1 字符 + + List dataList = report.getReportDatalist() != null + ? report.getReportDatalist() : Collections.emptyList(); + String statusDefect = messageUtils.get("缺陷"); + String statusAbnormal = messageUtils.get("异常"); + String statusNormal = messageUtils.get("正常"); + + // (一)巡检统计 + XWPFParagraph h1 = addPara(doc, "(一)巡检统计", FONT_BODY, SZ_HEAD_THIRD, true, false, + null, null, 80, 40); + setKeepNext(h1); + setFirstLineIndentOneChar(h1, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + XWPFTable statTable = addTable(doc, 5, 2, new int[]{4296, 4713}, 9008, true); + setRow(statTable.getRow(0), ROW_H, false); + headerCell(statTable.getRow(0).getCell(0), "巡检状态", 4296); + headerCell(statTable.getRow(0).getCell(1), "数量(个)", 4713); + String[][] statRows = { + {statusDefect, String.valueOf(countByStatus(dataList, statusDefect))}, + {statusAbnormal, String.valueOf(countByStatus(dataList, statusAbnormal))}, + {statusNormal, String.valueOf(countByStatus(dataList, statusNormal))}, + {"合计", String.valueOf(dataList.size())}, + }; + for (int i = 0; i < statRows.length; i++) { + setRow(statTable.getRow(i + 1), ROW_H, false); + bodyCell(statTable.getRow(i + 1).getCell(0), statRows[i][0], 4296, true); + bodyCell(statTable.getRow(i + 1).getCell(1), statRows[i][1], 4713, true); + } + + // (二)缺陷汇总表 + XWPFParagraph h2 = addPara(doc, "(二)缺陷汇总表", FONT_BODY, SZ_HEAD_THIRD, true, false, + null, null, 80, 40); + setKeepNext(h2); + setFirstLineIndentOneChar(h2, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + List defectList = dataList.stream() + .filter(d -> statusDefect.equals(d.getPointStatus())) + .collect(Collectors.toList()); + int[] summaryWidths = {717, 1200, 1230, 1245, 1215, 1710, 990, 727}; + int summaryTotalWidth = Arrays.stream(summaryWidths).sum(); + // 无缺陷数据时保留一行(合并整行输出文字说明),避免表格空白 + int summaryRows = 1 + Math.max(defectList.size(), 1); + XWPFTable summaryTable = addTable(doc, summaryRows, 8, summaryWidths, 9034, false); + String[] summaryHeaders = {"序号", "区域名称", "间隔名称", "设备名称", "部件名称", + "巡检点位名称", "巡检", "状态"}; + setRow(summaryTable.getRow(0), ROW_H, true); + for (int i = 0; i < summaryHeaders.length; i++) { + headerCell(summaryTable.getRow(0).getCell(i), summaryHeaders[i], summaryWidths[i]); + } + // 「巡检 / 结果」表头换行 + XWPFParagraph headP = summaryTable.getRow(0).getCell(6).getParagraphs().get(0); + setRunFont(headP.createRun(), "结果", FONT_BODY, SZ_BODY, true, false, COLOR_WHITE, true); + int num = 1; + for (int ri = 0; ri < defectList.size(); ri++) { + InspectionReportData d = defectList.get(ri); + setRow(summaryTable.getRow(ri + 1), ROW_H, true); + String[] vals = {String.valueOf(num++), val(d.getArea()), val(d.getTimeInterval()), + val(d.getEqName()), val(d.getParts()), val(d.getPointName()), + val(d.getInspectionResults()), val(d.getPointStatus())}; + for (int ci = 0; ci < vals.length; ci++) { + bodyCell(summaryTable.getRow(ri + 1).getCell(ci), vals[ci], + summaryWidths[ci], false, 120, 120); + } + } + if (defectList.isEmpty()) { + // 无缺陷数据时:整行合并输出文字说明(原占位横杠改为「本次巡检未发现缺陷项」) + setRow(summaryTable.getRow(1), ROW_H, true); + mergeRowCells(summaryTable, 1, 0, 7); + styleCell(summaryTable.getRow(1).getCell(0), + messageUtils.get("本次巡检未发现缺陷项"), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.CENTER, 120, 120, + summaryTotalWidth, 8, 0, CELL_MAR, 0, CELL_MAR); + } + } + + /** 四、巡检详情:每个点位一张 8 行 × 4 列详情表 */ + private void buildSectionDetail(XWPFDocument doc, InspectionReport report, + Map tempFileMap) { + XWPFParagraph h = addPara(doc, "四、巡检详情", FONT_HEAD, SZ_HEAD_FOURTH, true, false, + null, null, 160, 80); + setKeepNext(h); + setFirstLineIndentOneChar(h, SZ_HEAD_FOURTH); // 标题首行缩进 1 字符 + + List dataList = report.getReportDatalist() != null + ? report.getReportDatalist().stream().filter(item -> messageUtils.get("缺陷").equals(item.getPointStatus())).collect(Collectors.toList()) : Collections.emptyList(); + // TODO 归档时调整:详情表范围(全部点位 or 仅缺陷点位,如需仅缺陷请在此过滤) + if (dataList.isEmpty()) { + // 无缺陷数据时输出文字说明,避免「四、巡检详情」章节空白 + addPara(doc, messageUtils.get("本次巡检未发现缺陷项"), FONT_BODY, SZ_BODY, + false, false, null, ParagraphAlignment.LEFT, 160, 80); + return; + } + for (InspectionReportData d : dataList) { + XWPFTable t = addTable(doc, 8, 4, new int[]{1707, 2891, 1665, 2785}, 9048, false); + int[] grid = {1707, 2891, 1665, 2785}; + + String[][] pairs = { + {"巡检点位名称", val(d.getPointName()), "巡检时间", val(formatDateTime(d.getAcquisitionTime()))}, + {"区域名称", val(d.getArea()), "间隔名称", val(d.getTimeInterval())}, + {"设备名称", val(d.getEqName()), "部件名称", val(d.getParts())}, + {"数据来源", val(d.getDataSources()), "算法名称", val(d.getAlgName())}, + {"巡检读数", val(d.getInspectionValue()), "巡检结果", val(d.getInspectionResults())}, + }; + for (int ri = 0; ri < pairs.length; ri++) { + setRow(t.getRow(ri), ROW_H, false); + bodyCell(t.getRow(ri).getCell(0), pairs[ri][0], grid[0], false, 120, 120); + bodyCell(t.getRow(ri).getCell(1), pairs[ri][1], grid[1], false, 120, 120); + bodyCell(t.getRow(ri).getCell(2), pairs[ri][2], grid[2], false, 120, 120); + bodyCell(t.getRow(ri).getCell(3), pairs[ri][3], grid[3], false, 120, 120); + } + + // R5 巡检状态 | 状态(跨3列) + setRow(t.getRow(5), ROW_H, false); + bodyCell(t.getRow(5).getCell(0), "巡检状态", grid[0], false, 120, 120); + mergeRowCells(t, 5, 1, 3); + styleCell(t.getRow(5).getCell(1), val(d.getPointStatus()), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.LEFT, 120, 120, + grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR); + + // R6 巡检原图 | 图片文件名(跨3列) + String imgName = firstImageName(d); + setRow(t.getRow(6), ROW_H, false); + bodyCell(t.getRow(6).getCell(0), "巡检原图", grid[0], false, 120, 120); + mergeRowCells(t, 6, 1, 3); + styleCell(t.getRow(6).getCell(1), val(imgName), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.LEFT, 120, 120, + grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR); + + // R7 基准图 | 巡检原图照片 | 缺陷图 | 缺陷图照片 + // 图片列表为空或路径缺失时置 null,由 imageCell 输出横杠占位,避免空白/空指针 + setRow(t.getRow(7), ROW_H, false); + Path patrolPath = null; + Path defectPath = null; + if (d.getReportImgList() != null && !d.getReportImgList().isEmpty()) { + InspectionReportImg inspectionReportImg = d.getReportImgList().get(0); + patrolPath = tempFileMap.get(inspectionReportImg.getImageNormalUrlPath()); + defectPath = tempFileMap.get(inspectionReportImg.getImg()); + } + bodyCell(t.getRow(7).getCell(0), "基准图", grid[0], false, 120, 120); + imageCell(t.getRow(7).getCell(1), patrolPath, + IMG_PATROL_W, IMG_PATROL_H, grid[1]); + bodyCell(t.getRow(7).getCell(2), "缺陷图", grid[2], false, 120, 120); + imageCell(t.getRow(7).getCell(3), defectPath, + IMG_DEFECT_W, IMG_DEFECT_H, grid[3]); + + // 表间间距 + addPara(doc, "", null, SZ_BODY, false, false, null, null, 120, 120); + } + } + + // ============================================================================ + // 四、POI 样式工具方法 + // ============================================================================ + + /** 新建段落并写文字;halfPoints=字号半磅;before/after 单位 twips */ + private XWPFParagraph addPara(XWPFDocument doc, String text, String font, int halfPoints, + boolean bold, boolean italic, String color, + ParagraphAlignment align, Integer before, Integer after) { + XWPFParagraph p = doc.createParagraph(); + if (align != null) { + p.setAlignment(align); + } + if (before != null) { + p.setSpacingBefore(before); + } + if (after != null) { + p.setSpacingAfter(after); + } + // 正文段落行距 276 auto(1.15 倍),与模板 Normal 样式一致: + // 模板封面第二页正是靠它把空行撑满一页;若按默认单倍行距,封面页变矮, + // 「一、巡检基本情况」会被挤到封面页(表格内段落走 styleCell,显式 240) + CTPPr pPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + CTSpacing spacing = pPr.isSetSpacing() ? pPr.getSpacing() : pPr.addNewSpacing(); + spacing.setLine(BigInteger.valueOf(276)); + spacing.setLineRule(STLineSpacingRule.AUTO); + if (text != null && !text.isEmpty()) { + XWPFRun run = p.createRun(); + setRunFont(run, text, font, halfPoints, bold, italic, color, false); + } + return p; + } + + /** 设置 run 字体(含中文 eastAsia)、字号、加粗、斜体、颜色 */ + private void setRunFont(XWPFRun run, String text, String font, int halfPoints, + boolean bold, boolean italic, String color, boolean lineBreak) { + if (lineBreak) { + run.addBreak(); // 换行符在前,实现「巡检⏎结果」这类两行表头 + } + if (text != null) { + run.setText(text); + } + // 字体/字号直接写 w:rPr XML: + // - setFontSize(double) 仅 POI 5.x 有,而运行环境是 poi-ooxml 4.1.2(target/lib), + // 调用会抛 NoSuchMethodError + // - w:sz 单位固定为半磅(24 = 12pt),所有 POI 版本一致 + CTRPr rPr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); + if (font != null) { + run.setFontFamily(font); + CTFonts fonts = rPr.isSetRFonts() ? rPr.getRFonts() : rPr.addNewRFonts(); + fonts.setEastAsia(font); + fonts.setCs(font); + // 注意:嵌入字体的引用写在 fontTable.xml(w:font w:name + w:embedRegular), + // run 这里只需写字体名,Word 打开时按名字查 fontTable 使用嵌入字体 + } + CTHpsMeasure sz = rPr.isSetSz() ? rPr.getSz() : rPr.addNewSz(); + sz.setVal(BigInteger.valueOf(halfPoints)); + CTHpsMeasure szCs = rPr.isSetSzCs() ? rPr.getSzCs() : rPr.addNewSzCs(); + szCs.setVal(BigInteger.valueOf(halfPoints)); + run.setBold(bold); + run.setItalic(italic); + if (color != null) { + run.setColor(color); + } + } + + /** 建表:设置 tblW / 边框 / autofit / 网格列宽 */ + private XWPFTable addTable(XWPFDocument doc, int rows, int cols, int[] gridWidths, + int tblWidth, boolean center) { + XWPFTable table = doc.createTable(rows, cols); + CTTbl tbl = table.getCTTbl(); + CTTblPr tblPr = tbl.getTblPr(); + if (tblPr == null) { + tblPr = tbl.addNewTblPr(); + } + CTTblWidth tblW = tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW(); + tblW.setW(BigInteger.valueOf(tblWidth)); + tblW.setType(STTblWidth.DXA); + + if (center) { + CTJc jc = tblPr.isSetJc() ? tblPr.getJc() : tblPr.addNewJc(); + jc.setVal(STJc.CENTER); + } + + CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders(); + setBorders(borders); + + CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); + layout.setType(STTblLayoutType.AUTOFIT); + + CTTblCellMar cellMar = tblPr.isSetTblCellMar() ? tblPr.getTblCellMar() : tblPr.addNewTblCellMar(); + setCellMar(cellMar, 0, CELL_MAR, 0, CELL_MAR); + + // 网格列宽 + CTTblGrid grid = tbl.getTblGrid(); + if (grid == null) { + grid = tbl.addNewTblGrid(); + } + for (int i = grid.sizeOfGridColArray() - 1; i >= 0; i--) { + grid.removeGridCol(i); + } + for (int w : gridWidths) { + grid.addNewGridCol().setW(BigInteger.valueOf(w)); + } + return table; + } + + private void setBorders(CTTblBorders borders) { + CTBorder top = borders.isSetTop() ? borders.getTop() : borders.addNewTop(); + CTBorder left = borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(); + CTBorder bottom = borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(); + CTBorder right = borders.isSetRight() ? borders.getRight() : borders.addNewRight(); + CTBorder insideH = borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH(); + CTBorder insideV = borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV(); + for (CTBorder b : new CTBorder[]{top, left, bottom, right, insideH, insideV}) { + b.setVal(STBorder.SINGLE); + b.setColor(COLOR_BORDER); + b.setSz(BigInteger.ZERO); + b.setSpace(BigInteger.ZERO); + } + } + + private void setBorders(CTTcBorders borders) { + CTBorder top = borders.isSetTop() ? borders.getTop() : borders.addNewTop(); + CTBorder left = borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(); + CTBorder bottom = borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(); + CTBorder right = borders.isSetRight() ? borders.getRight() : borders.addNewRight(); + for (CTBorder b : new CTBorder[]{top, left, bottom, right}) { + b.setVal(STBorder.SINGLE); + b.setColor(COLOR_BORDER); + b.setSz(BigInteger.ZERO); + b.setSpace(BigInteger.ZERO); + } + } + + private void setCellMar(CTTblCellMar mar, int top, int left, int bottom, int right) { + CTTblWidth t = mar.isSetTop() ? mar.getTop() : mar.addNewTop(); + t.setW(BigInteger.valueOf(top)); + t.setType(STTblWidth.DXA); + CTTblWidth l = mar.isSetLeft() ? mar.getLeft() : mar.addNewLeft(); + l.setW(BigInteger.valueOf(left)); + l.setType(STTblWidth.DXA); + CTTblWidth b = mar.isSetBottom() ? mar.getBottom() : mar.addNewBottom(); + b.setW(BigInteger.valueOf(bottom)); + b.setType(STTblWidth.DXA); + CTTblWidth r = mar.isSetRight() ? mar.getRight() : mar.addNewRight(); + r.setW(BigInteger.valueOf(right)); + r.setType(STTblWidth.DXA); + } + + private void setCellMar(CTTcMar mar, int top, int left, int bottom, int right) { + CTTblWidth t = mar.isSetTop() ? mar.getTop() : mar.addNewTop(); + t.setW(BigInteger.valueOf(top)); + t.setType(STTblWidth.DXA); + CTTblWidth l = mar.isSetLeft() ? mar.getLeft() : mar.addNewLeft(); + l.setW(BigInteger.valueOf(left)); + l.setType(STTblWidth.DXA); + CTTblWidth b = mar.isSetBottom() ? mar.getBottom() : mar.addNewBottom(); + b.setW(BigInteger.valueOf(bottom)); + b.setType(STTblWidth.DXA); + CTTblWidth r = mar.isSetRight() ? mar.getRight() : mar.addNewRight(); + r.setW(BigInteger.valueOf(right)); + r.setType(STTblWidth.DXA); + } + + /** 行高(atLeast)+ 可选 cantSplit */ + private void setRow(XWPFTableRow row, Integer height, boolean cantSplit) { + // 旧版 poi-ooxml-schemas 中 w:tr 的类型是 CTRow(非 CTr), + // 且 trPr 内元素为数组式访问器(sizeOfXxxArray/getXxxArray/addNewXxx) + CTRow ctRow = row.getCtRow(); + CTTrPr trPr = ctRow.isSetTrPr() ? ctRow.getTrPr() : ctRow.addNewTrPr(); + if (cantSplit && trPr.sizeOfCantSplitArray() == 0) { + trPr.addNewCantSplit(); + } + if (height != null) { + CTHeight h = trPr.sizeOfTrHeightArray() > 0 ? trPr.getTrHeightArray(0) : trPr.addNewTrHeight(); + h.setVal(BigInteger.valueOf(height)); + h.setHRule(STHeightRule.AT_LEAST); + } + } + + /** 行内水平居中(封面签署表用) */ + private void setRowJcCenter(XWPFTableRow row) { + CTRow ctRow = row.getCtRow(); + CTTrPr trPr = ctRow.isSetTrPr() ? ctRow.getTrPr() : ctRow.addNewTrPr(); + CTJc jc = trPr.sizeOfJcArray() > 0 ? trPr.getJcArray(0) : trPr.addNewJc(); + jc.setVal(STJc.CENTER); + } + + /** 水平合并 row 第 from..to 列(gridSpan + 移除多余单元格) */ + private void mergeRowCells(XWPFTable table, int rowIdx, int from, int to) { + XWPFTableRow row = table.getRow(rowIdx); + XWPFTableCell first = row.getCell(from); + CTTcPr tcPr = first.getCTTc().isSetTcPr() ? first.getCTTc().getTcPr() : first.getCTTc().addNewTcPr(); + CTDecimalNumber gs = tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); + gs.setVal(BigInteger.valueOf(to - from + 1)); + List tcs = row.getCtRow().getTcList(); + for (int i = to; i > from; i--) { + tcs.remove(i); + } + } + + /** 表头单元格:深蓝底 + 白字 + 加粗 + 居中 */ + private void headerCell(XWPFTableCell cell, String text, int tcw) { + styleCell(cell, text, FONT_BODY, SZ_BODY, true, false, COLOR_WHITE, true, + ParagraphAlignment.CENTER, 0, 0, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + } + + /** 正文单元格:仿宋 12pt,可选居中/行前段后间距 */ + private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center, + int before, int after) { + styleCell(cell, text, FONT_BODY, SZ_BODY, false, false, null, false, + center ? ParagraphAlignment.CENTER : ParagraphAlignment.LEFT, + before, after, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + } + + private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center) { + bodyCell(cell, text, tcw, center, 0, 0); + } + + private void bodyCell(XWPFTableCell cell, String text, int tcw) { + bodyCell(cell, text, tcw, false, 0, 0); + } + + /** 图片单元格:细边框 + 垂直居中 + 图片(保持模板尺寸);无图时输出横杠占位 */ + private void imageCell(XWPFTableCell cell, Path imagePath, long w, long h, int tcw) { + styleCell(cell, "", FONT_BODY, SZ_BODY, false, false, null, false, + ParagraphAlignment.LEFT, 120, 120, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + XWPFParagraph p = cell.getParagraphs().get(0); + if (imagePath == null) { + // 无图片时以横杠占位,避免空白单元格 + XWPFRun dashRun = p.createRun(); + setRunFont(dashRun, "--", FONT_BODY, SZ_BODY, false, false, null, false); + return; + } + XWPFRun run = p.createRun(); + try (InputStream is = Files.newInputStream(imagePath)) { + run.addPicture(is, XWPFDocument.PICTURE_TYPE_JPEG, "img.jpg", (int) w, (int) h); + } catch (Exception e) { + logger.warn("插入巡检图片失败: {}", imagePath, e); + } + } + + /** + * 通用单元格样式:B4C6E7 细边框(sz=0)、垂直居中、可选深蓝底、 + * 列宽、gridSpan、单元格边距 + */ + private void styleCell(XWPFTableCell cell, String text, String font, int halfPoints, + boolean bold, boolean italic, String color, boolean shade, + ParagraphAlignment align, int before, int after, + Integer tcw, Integer gridSpan, + int marTop, int marLeft, int marBottom, int marRight) { + CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + + if (tcw != null) { + CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); + w.setW(BigInteger.valueOf(tcw)); + w.setType(STTblWidth.DXA); + } + if (gridSpan != null && gridSpan > 1) { + CTDecimalNumber gs = tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); + gs.setVal(BigInteger.valueOf(gridSpan)); + } + CTTcBorders borders = tcPr.isSetTcBorders() ? tcPr.getTcBorders() : tcPr.addNewTcBorders(); + setBorders(borders); + if (shade) { + CTShd shd = tcPr.isSetShd() ? tcPr.getShd() : tcPr.addNewShd(); + shd.setVal(STShd.CLEAR); + shd.setColor("auto"); + shd.setFill(COLOR_DARK); + } + CTTcMar tcMar = tcPr.isSetTcMar() ? tcPr.getTcMar() : tcPr.addNewTcMar(); + setCellMar(tcMar, marTop, marLeft, marBottom, marRight); + CTVerticalJc vAlign = tcPr.isSetVAlign() ? tcPr.getVAlign() : tcPr.addNewVAlign(); + vAlign.setVal(STVerticalJc.CENTER); + + // 段落:清空默认段落,写入第一个段落 + List ctPList = cell.getCTTc().getPList(); + for (int i = ctPList.size() - 1; i > 0; i--) { + cell.getCTTc().removeP(i); + } + XWPFParagraph p = cell.getParagraphs().get(0); + for (int i = p.getRuns().size() - 1; i >= 0; i--) { + p.removeRun(i); + } + p.setAlignment(align); + p.setSpacingBefore(before); + p.setSpacingAfter(after); + // 表格内段落与模板一致:单倍行距(w:line=240 auto),不受 Normal 样式 1.15 倍影响 + CTPPr cellPPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + CTSpacing cellSpacing = cellPPr.isSetSpacing() ? cellPPr.getSpacing() : cellPPr.addNewSpacing(); + cellSpacing.setLine(BigInteger.valueOf(240)); + cellSpacing.setLineRule(STLineSpacingRule.AUTO); + if (text != null && !text.isEmpty()) { + XWPFRun run = p.createRun(); + setRunFont(run, text, font, halfPoints, bold, italic, color, false); + } + } + + // ============================================================================ + // 五、数据小工具 + // ============================================================================ + + /** + * 空值/空白字符串转为占位横杠(--),避免报告中出现空白单元格; + * 非空则原样返回。 + */ + private String val(String s) { + return (s == null || s.trim().isEmpty()) ? "--" : s; + } + + private long countByStatus(List list, String status) { + if (list == null) { + return 0; + } + return list.stream().filter(d -> status.equals(d.getPointStatus())).count(); + } + + private long distinctCount(List list, + java.util.function.Function getter) { + if (list == null) { + return 0; + } + return list.stream().map(getter).filter(StringUtils::isNotEmpty).distinct().count(); + } + + /** 点位第一张图片的文件名(imgType=0),无则空串 */ + private String firstImageName(InspectionReportData d) { + if (d.getReportImgList() == null) { + return ""; + } + for (InspectionReportImg img : d.getReportImgList()) { + if ("0".equals(img.getImgType()) && StringUtils.isNotEmpty(img.getImgSrc())) { + String src = img.getImgSrc(); + int idx = src.lastIndexOf('/'); + return idx >= 0 ? src.substring(idx + 1) : src; + } + } + return ""; + } + + /** 点位第 idx 张图片(imgType=0)的临时文件,无则 null */ + private Path firstImagePath(InspectionReportData d, Map tempFileMap, int idx) { + if (d.getReportImgList() == null) { + return null; + } + int found = 0; + for (InspectionReportImg img : d.getReportImgList()) { + if ("0".equals(img.getImgType()) && StringUtils.isNotEmpty(img.getImgSrc())) { + if (found == idx) { + return tempFileMap.get(img.getImgSrc()); + } + found++; + } + } + return null; + } + + /** 点位第 idx 张图片(imgType=0)的临时文件,无则 null */ + private Path getImagePath(InspectionReportData d, Map tempFileMap, int idx) { + if (d.getReportImgList() == null) { + return null; + } + int found = 0; + for (InspectionReportImg img : d.getReportImgList()) { + if ("0".equals(img.getImgType()) && StringUtils.isNotEmpty(img.getImgSrc())) { + if (found == idx) { + return tempFileMap.get(img.getImgSrc()); + } + found++; + } + } + return null; + } + + + private String formatDateTime(Date date) { + return date != null ? DateUtils.format(DateUtils.yyyyMMddHHmmss2, date) : ""; + } + + // ============================================================================ + // 六、上传 / 资源工具(与 PatrolTaskResultMainController 保持一致) + // ============================================================================ + + private void uploadFile(String basePath, String fileName, String suffix, + IOConsumer writer) { + SftpUploadEntity sftpUploadEntity = new SftpUploadEntity(); + sftpUploadEntity.setFilePath(basePath); + sftpUploadEntity.setFileName(fileName); + sftpUploadEntity.setSuffix(suffix); + try { + String filePath = sftpClient.upload(sftpUploadEntity, outputStream -> { + writer.accept(outputStream); + outputStream.flush(); + }); + logger.info("文件上传成功: {}", filePath); + } catch (Exception e) { + logger.error("文件上传失败", e); + } + } + + private Path downloadImageToTemp(String imagePath) { + try { + if (imagePath == null) { + return null; + } + Path tempFile = Files.createTempFile("inspect_img_", ".jpg"); + sftpClient.downLoad(imagePath, inputStream -> + Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING)); + return tempFile; + } catch (Exception e) { + logger.warn("图片下载到临时文件失败: {}", imagePath, e); + return null; + } + } + + /** + * 打开 logo 图片流:优先按 classpath 资源加载(相对路径,图片随包发布), + * 找不到时兼容本地绝对/相对文件路径;两者都无则返回 null。 + */ + private InputStream openLogoStream(String logoPath) throws IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream(logoPath); + if (is != null) { + return is; + } + Path p = Paths.get(logoPath); + if (Files.exists(p)) { + return Files.newInputStream(p); + } + logger.warn("logo 资源不存在: classpath={}, file={}", logoPath, p.toAbsolutePath()); + return null; + } + + private void closeResources(XWPFDocument wordDocument, List tempFiles) { + if (wordDocument != null) { + try { + wordDocument.close(); + logger.info("XWPFDocument 已关闭"); + } catch (IOException e) { + logger.error("关闭XWPFDocument失败", e); + } + } + for (Path tempFile : tempFiles) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + logger.warn("删除临时文件失败: {}", tempFile, e); + } + } + } +} diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/PatrolTaskResultMainController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/PatrolTaskResultMainController.java index a9ef223..a6cfa70 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/PatrolTaskResultMainController.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/PatrolTaskResultMainController.java @@ -263,7 +263,7 @@ public class PatrolTaskResultMainController extends BaseController { } - @PostMapping({"/updateInfo_shaoxing"}) + @PostMapping({"/updateInfo_shaoxing1"}) public AjaxResult updateInfo_shaoxing( @RequestBody PatrolTaskResultMain resultMain) { long startTime = System.currentTimeMillis(); diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/taskstatus/service/impl/PatrolTaskStatusServiceImpl.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/taskstatus/service/impl/PatrolTaskStatusServiceImpl.java index 1652865..fa67491 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/taskstatus/service/impl/PatrolTaskStatusServiceImpl.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/taskstatus/service/impl/PatrolTaskStatusServiceImpl.java @@ -22,6 +22,7 @@ import com.inspect.taskstatus.mapper.PatrolTaskStatusMapper; import com.inspect.taskstatus.service.IPatrolTaskStatusService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.aop.framework.AopContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -102,6 +103,7 @@ public class PatrolTaskStatusServiceImpl implements IPatrolTaskStatusService { return this.patrolTaskStatusMapper.updatePatrolTaskStatusOfTaskState(patrolTaskStatus); } + @TaskStatusChange(targetState = "1", async = true) // "1"表示已完成状态 public int updatePatrolTaskStatusOfTaskStateAndEndTime(PatrolTaskStatus patrolTaskStatus) { return this.patrolTaskStatusMapper.updatePatrolTaskStatusOfTaskStateAndEndTime(patrolTaskStatus); } @@ -119,6 +121,7 @@ public class PatrolTaskStatusServiceImpl implements IPatrolTaskStatusService { } @Override + @TaskStatusChange(targetState = "1", async = true) // "1"表示已完成状态 public int insertIgnorePatrolTaskStatus(PatrolTaskStatus patrolTaskStatusMain) { return this.patrolTaskStatusMapper.insertIgnorePatrolTaskStatus(patrolTaskStatusMain); } @@ -158,8 +161,8 @@ public class PatrolTaskStatusServiceImpl implements IPatrolTaskStatusService { patrolTaskStatus.setTaskState(state); setNormalTaskStatus(patrolTaskStatus); - // 更新子任务或普通任务 - updatePatrolTaskStatus(patrolTaskStatus); + // 更新子任务或普通任务(走代理调用,确保 @TaskStatusChange 切面在状态变为完成时触发) + ((IPatrolTaskStatusService) AopContext.currentProxy()).updatePatrolTaskStatus(patrolTaskStatus); log.info("updateTaskProgress: {}", patrolTaskStatus); // 如果是子任务,需要同步更新主任务 if (TaskType.SUB.getCode().equals(patrolTaskStatus.getTaskType()) && patrolTaskStatus.getMainId() != null) { @@ -239,8 +242,8 @@ public class PatrolTaskStatusServiceImpl implements IPatrolTaskStatusService { mainTask.setTaskEstimatedTime(mainAlgorithmProgress.toString()); setNormalTaskStatus(mainTask); - // 更新主任务 - patrolTaskStatusMapper.updatePatrolTaskStatus(mainTask); + // 更新主任务(走代理调用,确保 @TaskStatusChange 切面在联合任务完成时触发) + ((IPatrolTaskStatusService) AopContext.currentProxy()).updatePatrolTaskStatus(mainTask); } // 生成巡视归档记录 diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf new file mode 100644 index 0000000..68334e2 Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/simfang.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf new file mode 100644 index 0000000..5bd4687 Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/simhei.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf b/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf new file mode 100644 index 0000000..0161fcc Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/fonts/方正小标宋简体.ttf differ diff --git a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportDataMapper.xml b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportDataMapper.xml index 34cd8b7..ef3bbf0 100644 --- a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportDataMapper.xml +++ b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportDataMapper.xml @@ -15,6 +15,7 @@ + @@ -36,6 +37,7 @@ data_sources, acquisition_time, inspection_results, + inspection_value, point_status, inspection_img, alg_name, @@ -164,11 +166,11 @@ insert into inspection_report_data - (line_id,code,report_id,area,time_interval,eq_name,parts,point_name,data_sources,acquisition_time,inspection_results,point_status,inspection_img,alg_name,task_name,task_patrolled_id,task_result_id) + (line_id,code,report_id,area,time_interval,eq_name,parts,point_name,data_sources,acquisition_time,inspection_results,inspection_value,point_status,inspection_img,alg_name,task_name,task_patrolled_id,task_result_id) values (#{t.lineId}, #{t.code}, #{t.reportId}, #{t.area}, #{t.timeInterval}, #{t.eqName}, #{t.parts}, - #{t.pointName},#{t.dataSources},#{t.acquisitionTime},#{t.inspectionResults},#{t.pointStatus},#{t.inspectionImg},#{t.algName},#{t.taskName},#{t.taskPatrolledId},#{t.taskResultId}) + #{t.pointName},#{t.dataSources},#{t.acquisitionTime},#{t.inspectionResults},#{t.inspectionValue},#{t.pointStatus},#{t.inspectionImg},#{t.algName},#{t.taskName},#{t.taskPatrolledId},#{t.taskResultId}) diff --git a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportImgMapper.xml b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportImgMapper.xml index 9a0a4e5..360f1cc 100644 --- a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportImgMapper.xml +++ b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportImgMapper.xml @@ -9,12 +9,13 @@ + - select line_id, report_info_id, img, img_src, creat_time, img_type + select line_id, report_info_id, img, img_src, image_normal_url_path, creat_time, img_type from inspection_report_img @@ -87,9 +88,9 @@ - insert into inspection_report_img (line_id, report_info_id, img, img_src, creat_time, img_type) values + insert into inspection_report_img (line_id, report_info_id, img, img_src, image_normal_url_path, creat_time, img_type) values - (#{t.lineId}, #{t.reportInfoId}, #{t.img}, #{t.imgSrc}, #{t.creatTime}, #{t.imgType}) + (#{t.lineId}, #{t.reportInfoId}, #{t.img}, #{t.imgSrc}, #{t.imageNormalUrlPath}, #{t.creatTime}, #{t.imgType}) diff --git a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml index 19fd2bf..5569dd8 100644 --- a/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml +++ b/inspect-main/inspect-main-task/src/main/resources/mapper/task/InspectionReportMapper.xml @@ -23,6 +23,7 @@ + @@ -34,6 +35,7 @@ station_name, volt_level, station_type, + location, inspection_date, inspection_task_name, env_info, @@ -113,6 +115,7 @@ station_name, volt_level, station_type, + location, inspection_date, inspection_task_name, env_info, @@ -132,6 +135,7 @@ #{stationName}, #{voltLevel}, #{stationType}, + #{location}, #{inspectionDate}, #{inspectionTaskName}, #{envInfo}, @@ -155,6 +159,7 @@ station_name = #{stationName}, volt_level = #{voltLevel}, station_type = #{stationType}, + location = #{location}, inspection_date = #{inspectionDate}, inspection_task_name = #{inspectionTaskName}, env_info = #{envInfo}, diff --git a/inspect-main/inspect-main-task/src/main/resources/mapper/task/PatrolResultMapper.xml b/inspect-main/inspect-main-task/src/main/resources/mapper/task/PatrolResultMapper.xml index d319d34..641be86 100644 --- a/inspect-main/inspect-main-task/src/main/resources/mapper/task/PatrolResultMapper.xml +++ b/inspect-main/inspect-main-task/src/main/resources/mapper/task/PatrolResultMapper.xml @@ -346,6 +346,116 @@ ORDER BY line_id desc + + diff --git a/inspect-main/inspect-main-task/src/main/resources/report/logo.png b/inspect-main/inspect-main-task/src/main/resources/report/logo.png new file mode 100644 index 0000000..a83f630 Binary files /dev/null and b/inspect-main/inspect-main-task/src/main/resources/report/logo.png differ diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml new file mode 100644 index 0000000..637d657 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/fontTable.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml new file mode 100644 index 0000000..3eb86c3 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/styles.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml new file mode 100644 index 0000000..67f3205 --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/theme1.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml new file mode 100644 index 0000000..b5d1b1e --- /dev/null +++ b/inspect-main/inspect-main-task/src/main/resources/report/wordparts/webSettings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inspect-metadata/src/main/java/com/inspect/metadata/eqpbook/domain/BasedataEqpBookChannel.java b/inspect-metadata/src/main/java/com/inspect/metadata/eqpbook/domain/BasedataEqpBookChannel.java index 90001ad..7824b33 100644 --- a/inspect-metadata/src/main/java/com/inspect/metadata/eqpbook/domain/BasedataEqpBookChannel.java +++ b/inspect-metadata/src/main/java/com/inspect/metadata/eqpbook/domain/BasedataEqpBookChannel.java @@ -93,6 +93,8 @@ public class BasedataEqpBookChannel extends BaseEntity { private String areaName; + private Long areaId; + public String toString() { return (new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)).append("channelId", this.getChannelId()).append("channelCode", this.getChannelCode()).append("channelName", this.getChannelName()).append("parentChannelId", this.getParentChannelId()).append("eqpbookId", this.getEqpBookId()).append("channelType", this.getChannelType()).append("onlineState", this.getOnlineState()).append("channelContent", this.getChannelContent()).append("provider", this.getProvider()).append("address", this.getAddress()).append("host", this.getHost()).append("port", this.getPort()).append("createBy", this.getCreateBy()).append("createTime", this.getCreateTime()).append("updateBy", this.getUpdateBy()).append("updateTime", this.getUpdateTime()).append("deleteFlag", this.getDeleteFlag()).append("patroldeviceCode", this.getPatrolDeviceCode()).append("username", this.getUsername()).append("password", this.getPassword()).toString(); } diff --git a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml index fe7ed92..e98c59b 100644 --- a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml +++ b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml @@ -5,6 +5,10 @@ + + + + @@ -58,6 +62,10 @@