feat(report): 完善报告退回流程及新增 PDF 预览功能
- 新增退回到报告审核环节,支持退回至检验任务或报告审核两种目标
已添加2个文件
已修改11个文件
651 ■■■■■ 文件已修改
inspect-server/src/main/java/com/ruoyi/inspect/controller/InsReportController.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/mapper/InsReportReturnRecordMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReport.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReportReturnRecord.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderPlanServiceImpl.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsReportServiceImpl.java 316 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/SealUsageLedgerServiceImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/util/ReportSealLayoutUtil.java 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/vo/InsOrderPlanVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/resources/mapper/InsOrderRatesMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/resources/mapper/InsReportMapper.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/resources/mapper/InsSampleMapper.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/controller/InsReportController.java
@@ -167,6 +167,14 @@
        insReportService.download(id, reportType, reportPageDto.getCreateOrderUser(), response);
    }
    @Operation(summary = "按 Word åŽŸå§‹åˆ†é¡µæŽ’ç‰ˆé¢„è§ˆæŠ¥å‘Š PDF")
    @GetMapping("/preview")
    @PreAuthorize("@ss.hasPermi('business:reportPreparation')")
    @PersonalScope(permsName = "business:reportPreparation", objectName = ReportPageDto.class, paramName = "createOrderUser")
    public void preview(Integer id, Integer reportType, ReportPageDto reportPageDto, HttpServletResponse response) {
        insReportService.preview(id, reportType, reportPageDto.getCreateOrderUser(), response);
    }
    @Operation(summary = "重新生成报告")
    @PostMapping("/regenerate")
//    @PreAuthorize("@ss.hasPermi('business:reportPreparation')")
@@ -182,16 +190,13 @@
        return Result.success(insReportService.upAll(file));
    }
    /**
     * é€€å›žåˆ°æ£€éªŒä»»åŠ¡
     * @param id
     * @return
     */
    @Operation(summary = "退回到检验任务")
    @Operation(summary = "退回到检验任务或报告审核")
    @PostMapping("/sendBackTask")
    public Result sendBackTask(@RequestBody Map<String, Object> param){
        Integer id = (Integer) param.get("id");
        return Result.success(insReportService.sendBackTask(id));
        Integer id = toInteger(param.get("id"));
        String target = param.get("target") == null ? null : String.valueOf(param.get("target"));
        String reason = param.get("reason") == null ? null : String.valueOf(param.get("reason"));
        return Result.success(insReportService.sendBackTask(id, target, reason));
    }
    /**
inspect-server/src/main/java/com/ruoyi/inspect/mapper/InsReportReturnRecordMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.ruoyi.inspect.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.inspect.pojo.InsReportReturnRecord;
public interface InsReportReturnRecordMapper extends BaseMapper<InsReportReturnRecord> {
}
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReport.java
@@ -48,6 +48,12 @@
    @Schema(description = "检验报告地址")
    private String inspectionUrl;
    @Schema(description = "批准前的检测报告审核稿地址")
    private String reviewUrl;
    @Schema(description = "批准前的检验报告审核稿地址")
    private String reviewInspectionUrl;
    /**
     * æ‰‹åŠ¨ä¸Šä¼ æŠ¥å‘Šåœ°å€
     */
@@ -65,7 +71,7 @@
    private String inspectionTempUrlPdf;
    /**
     * 1:批准 0:不批准
     * 1:批准 0:不批准 2:已退回
     */
    @Schema(description = "批准状态")
    private Integer isRatify;
@@ -74,7 +80,7 @@
    private String ratifyTell;
    /**
     * 1:审核通过 0:审核不通过
     * 1:审核通过 0:审核不通过 2:已退回
     */
    @Schema(description = "审核状态")
    private Integer isExamine;
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReportReturnRecord.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package com.ruoyi.inspect.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * æŠ¥å‘Šé€€å›žå‰çš„审批状态和文件快照,用于追溯历史批准版本。
 */
@Data
@TableName("ins_report_return_record")
@Schema(description = "报告退回记录")
public class InsReportReturnRecord implements Serializable {
    @Schema(description = "主键")
    @TableId(type = IdType.AUTO)
    private Long id;
    @Schema(description = "报告ID")
    private Integer reportId;
    @Schema(description = "检验单ID")
    private Integer insOrderId;
    @Schema(description = "退回前节点:APPROVED å·²æ‰¹å‡†ï¼ŒREPORT_REVIEW æŠ¥å‘Šå®¡æ ¸ï¼ŒREPORT_APPROVAL æŠ¥å‘Šæ‰¹å‡†")
    private String fromStage;
    @Schema(description = "目标节点:INSPECTION_TASK æ£€éªŒä»»åŠ¡ï¼ŒREPORT_REVIEW æŠ¥å‘Šå®¡æ ¸")
    private String targetStage;
    @Schema(description = "退回原因")
    private String returnReason;
    @Schema(description = "退回前提交状态:0 å¾…提交,1 å·²æäº¤")
    private Integer state;
    @Schema(description = "退回前审核状态:0 ä¸é€šè¿‡ï¼Œ1 é€šè¿‡ï¼Œ2 å·²é€€å›ž")
    private Integer isExamine;
    @Schema(description = "退回前审核时间")
    private LocalDateTime examineTime;
    @Schema(description = "退回前批准状态:0 ä¸æ‰¹å‡†ï¼Œ1 æ‰¹å‡†ï¼Œ2 å·²é€€å›ž")
    private Integer isRatify;
    @Schema(description = "退回前批准时间")
    private LocalDateTime ratifyTime;
    @Schema(description = "退回前检测报告文件地址")
    private String reportUrl;
    @Schema(description = "退回前检验报告文件地址")
    private String inspectionUrl;
    @Schema(description = "退回前检测报告临时预览地址")
    private String tempUrlPdf;
    @Schema(description = "退回前检验报告临时预览地址")
    private String inspectionTempUrlPdf;
    @Schema(description = "退回操作人用户ID")
    private Integer operatorId;
    @Schema(description = "退回记录创建时间")
    private LocalDateTime createTime;
}
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportService.java
@@ -50,6 +50,9 @@
     */
    void download(Integer id, Integer reportType, Integer createOrderUser, HttpServletResponse response);
    /** ä¸Ž Word ä¸‹è½½ç›¸åŒçš„æ•°æ®èŒƒå›´ï¼Œä½¿ç”¨å›ºå®šåˆ†é¡µçš„ PDF ä¿ç•™æŠ¥å‘Šç‰ˆå¼ã€‚ */
    void preview(Integer id, Integer reportType, Integer createOrderUser, HttpServletResponse response);
    int upAll(MultipartFile file) throws IOException;
    /**
@@ -63,11 +66,10 @@
    Long getUnqualifiedCount(InsOrder insOrder);
    /**
     * é€€å›žåˆ°æ£€éªŒä»»åŠ¡
     * @param id
     * @return
     * é€€å›žæŠ¥å‘Šæµç¨‹ã€‚
     * target:INSPECTION_TASK é€€å›žæ£€éªŒä»»åŠ¡ï¼›REPORT_REVIEW é€€å›žæŠ¥å‘Šå®¡æ ¸ã€‚
     */
    boolean sendBackTask(Integer id);
    boolean sendBackTask(Integer id, String target, String reason);
    /**
     * æŠ¥å‘Šå¯¼å‡º
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderPlanServiceImpl.java
@@ -1856,11 +1856,13 @@
            }
            // æ£€éªŒäººå‘˜æäº¤å¤æ ¸æ—¶ä¸ç”ŸæˆæŠ¥å‘Šï¼›å…¨éƒ¨è¯•验室复核通过后生成报告。
            if (report == null || StringUtils.isBlank(report.getUrl())) {
            boolean returnedFromReport = report != null && Objects.equals(report.getIsPass(), 0);
            if (report == null || StringUtils.isBlank(report.getUrl()) || returnedFromReport) {
                this.generateReport(orderId, DETECTION_REPORT, writeUserId);
            }
            // ç›´ç»‘模板订单同时生成检验报告,两份文件共用同一条审批记录。
            if (directTemplateOrder && (report == null || StringUtils.isBlank(report.getInspectionUrl()))) {
            if (directTemplateOrder && (report == null || StringUtils.isBlank(report.getInspectionUrl())
                    || returnedFromReport)) {
                this.generateReport(orderId, INSPECTION_REPORT, writeUserId);
            }
            report = insReportMapper.selectOne(Wrappers.<InsReport>lambdaQuery()
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsReportServiceImpl.java
@@ -35,6 +35,7 @@
import com.ruoyi.common.constant.InsOrderTypeConstants;
import com.ruoyi.common.constant.MenuJumpPathConstants;
import com.ruoyi.common.core.domain.Result;
import com.ruoyi.common.core.domain.entity.User;
import com.ruoyi.common.core.domain.entity.SysMenu;
import com.ruoyi.common.core.domain.entity.InformationNotification;
import com.ruoyi.common.utils.DateImageUtil;
@@ -103,11 +104,18 @@
    private static final int DETECTION_REPORT = 0;
    private static final int INSPECTION_REPORT = 1;
    private static final String RETURN_TO_INSPECTION_TASK = "INSPECTION_TASK";
    private static final String RETURN_TO_REPORT_REVIEW = "REPORT_REVIEW";
    private static final int RETURNED_STATUS = 2;
    @Resource
    private UserMapper userMapper;
    @Resource
    private InsReportMapper insReportMapper;
    @Resource
    private InsReportEditSessionMapper insReportEditSessionMapper;
    @Resource
    private InsReportReturnRecordMapper insReportReturnRecordMapper;
    @Resource
    private SealUsageLedgerService sealUsageLedgerService;
    @Resource
@@ -446,6 +454,9 @@
        if (Objects.equals(insReport.getIsRatify(), 1)) {
            throw new ErrorException("报告已批准,请勿重复操作");
        }
        if (!Objects.equals(insReport.getState(), 1) || !Objects.equals(insReport.getIsExamine(), 1)) {
            throw new ErrorException("报告尚未审核通过,不能批准");
        }
        if (Objects.equals(insReport.getExamineUserId(), SecurityUtils.getUserId().intValue())) {
            throw new ErrorException("审核人和批准人不能为同一人,请重新选择批准人");
        }
@@ -499,10 +510,21 @@
                }
            });
            //如果批准不通过 ç›´æŽ¥é€€å›žåˆ°æäº¤äººé‚£è¾¹åŽ»
            insReport.setState(0);
            insReport.setIsExamine(0);
            return insReportMapper.updateById(insReport);
            // æ‰¹å‡†ä¸é€šè¿‡ç›´æŽ¥é€€å›žæŠ¥å‘Šå®¡æ ¸ï¼Œç”±åŽŸå®¡æ ¸äººä¿®æ”¹åŽé‡æ–°å®¡æ ¸ã€æ‰¹å‡†ã€‚
            recordReportReturn(insReport, "REPORT_APPROVAL", RETURN_TO_REPORT_REVIEW,
                    StringUtils.isBlank(ratifyTell) ? "批准不通过" : ratifyTell, insReport.getRatifyTime());
            insReport.setState(1);
            insReport.setExamineTell(ratifyTell);
            int affected = insReportMapper.updateById(insReport);
            if (affected != 1) {
                throw new ErrorException("报告退回审核失败");
            }
            insReportMapper.update(null, Wrappers.<InsReport>lambdaUpdate()
                    .eq(InsReport::getId, id)
                    .set(InsReport::getIsExamine, RETURNED_STATUS)
                    .set(InsReport::getExamineTime, insReport.getRatifyTime()));
            expireReportEditSessions(id);
            return affected;
        }
        //获取批准人的签名地址
        String signatureUrl;
@@ -552,6 +574,7 @@
        insReport.setReportSealName(reportSeal == null ? null : reportSeal.getName());
        insReport.setSeamSealId(seamSeal == null ? null : seamSeal.getId());
        insReport.setSeamSealName(seamSeal == null ? null : seamSeal.getName());
        prepareApprovalCopies(insReport);
        String primaryReportUrl = getPrimaryReportUrl(insReport);
        String finalUrl = primaryReportUrl.replace("/word", wordUrl);
@@ -740,6 +763,37 @@
                .collect(Collectors.toList());
    }
    /** æ‰¹å‡†ç« åªå†™å…¥å‰¯æœ¬ï¼Œä¿ç•™æ— æ‰¹å‡†ç« çš„审核稿用于批准后退回修改。 */
    private void prepareApprovalCopies(InsReport report) {
        String primarySource = getPrimaryReportUrl(report);
        String inspectionSource = report.getInspectionUrl();
        report.setReviewUrl(primarySource);
        report.setReviewInspectionUrl(inspectionSource);
        report.setUrlS(copyReportForApproval(primarySource));
        if (StringUtils.isNotBlank(inspectionSource)) {
            report.setInspectionUrl(copyReportForApproval(inspectionSource));
        }
    }
    private String copyReportForApproval(String sourceUrl) {
        try {
            Path source = Paths.get(sourceUrl.replace("/word", wordUrl));
            if (!Files.exists(source)) {
                throw new ErrorException("待批准报告文件不存在");
            }
            String fileName = "approved-" + UUID.randomUUID() + ".docx";
            Path target = Paths.get(wordUrl, fileName);
            Files.createDirectories(target.getParent());
            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
            return "/word/" + fileName;
        } catch (ErrorException e) {
            throw e;
        } catch (Exception e) {
            log.error("创建报告批准副本失败: {}", sourceUrl, e);
            throw new ErrorException("创建报告批准副本失败");
        }
    }
    private void refreshTemporaryPreviews(InsReport report) {
        String primaryPdf = wordToPdfTemp(getPrimaryReportUrl(report).replace("/word", wordUrl));
        if (StringUtils.isBlank(primaryPdf)) {
@@ -865,6 +919,82 @@
            log.error("下载报告失败,reportId={}", id, e);
            throw new ErrorException("报告下载失败");
        }
    }
    @Override
    public void preview(Integer id, Integer reportType, Integer createOrderUser, HttpServletResponse response) {
        int type = reportType == null ? DETECTION_REPORT : reportType;
        if (id == null || (type != DETECTION_REPORT && type != INSPECTION_REPORT)) {
            throw new ErrorException("报告或报告类型不正确");
        }
        InsReport report = insReportMapper.selectDownloadableReport(id, createOrderUser);
        if (report == null) {
            throw new ErrorException("报告不存在、尚未生成或无权查看");
        }
        String sourceUrl = type == INSPECTION_REPORT ? report.getInspectionUrl() : getPrimaryReportUrl(report);
        if (StringUtils.isBlank(sourceUrl)) {
            throw new ErrorException("报告文件尚未生成");
        }
        try {
            Path source = resolvePreviewFile(sourceUrl);
            Path pdf;
            if (Objects.equals(report.getIsRatify(), 1)) {
                // å·²æ‰¹å‡†æŠ¥å‘Šå¿…须使用批准时生成的 PDF,保留 PDF ä¸“用的 MA ç« å’Œéª‘缝章。
                // inspectionTempUrlPdf å¯èƒ½ä»æŒ‡å‘审核阶段的临时稿,不能作为有章报告返回。
                pdf = resolvePreviewFile(sourceUrl.replaceFirst("(?i)\\.docx?$", ".pdf"));
                if (pdf == null) {
                    throw new ErrorException("有章报告 PDF ä¸å­˜åœ¨ï¼Œè¯·é‡æ–°æ‰¹å‡†ç”Ÿæˆ");
                }
            } else {
                pdf = resolvePreviewFile(type == INSPECTION_REPORT
                        ? report.getInspectionTempUrlPdf() : report.getTempUrlPdf());
                if (source == null) throw new ErrorException("Word æŠ¥å‘Šæ–‡ä»¶ä¸å­˜åœ¨");
                if (pdf == null || Files.getLastModifiedTime(pdf).compareTo(Files.getLastModifiedTime(source)) < 0) {
                    // ç¼ºå°‘预览或 Word æ›´æ–°åŽå†è½¬æ¢ï¼›åŒä¸€ç‰ˆæœ¬é‡å¤æŸ¥çœ‹å¤ç”¨ç¼“存,不改审批数据。
                    Path directory = Paths.get(wordUrl).toAbsolutePath().normalize().resolve("preview");
                    Files.createDirectories(directory);
                    String version = source + ":" + Files.size(source) + ":" + Files.getLastModifiedTime(source);
                    String name = UUID.nameUUIDFromBytes(version.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + ".pdf";
                    pdf = directory.resolve(name);
                    if (!Files.isRegularFile(pdf)) {
                        Path temporary = Files.createTempFile(directory, "report-", ".pdf");
                        try {
                            if (wordToPdf(source.toString(), temporary.toString()) == null) {
                                throw new ErrorException("报告转 PDF å¤±è´¥ï¼Œè¯·è”系管理员");
                            }
                            Files.move(temporary, pdf, StandardCopyOption.REPLACE_EXISTING);
                        } finally {
                            Files.deleteIfExists(temporary);
                        }
                    }
                }
            }
            String filename = (StringUtils.isBlank(report.getCode()) ? "报告" : report.getCode())
                    .replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "-")
                    + (type == INSPECTION_REPORT ? "-检验报告.pdf" : "-检测报告.pdf");
            response.setContentType("application/pdf");
            response.setHeader("Content-Disposition", "inline; filename*=UTF-8''"
                    + URLEncoder.encode(filename, "UTF-8").replace("+", "%20"));
            response.setHeader("Cache-Control", "no-store");
            response.setContentLengthLong(Files.size(pdf));
            Files.copy(pdf, response.getOutputStream());
        } catch (IOException e) {
            log.error("报告预览失败,reportId={}", id, e);
            throw new ErrorException("报告预览失败,请联系管理员");
        }
    }
    /** åªè¯»å–报告目录内的文件,缺失文件交由调用方处理。 */
    private Path resolvePreviewFile(String url) throws IOException {
        if (StringUtils.isBlank(url)) return null;
        String path = url.replace('\\', '/');
        if (!path.startsWith("/word/")) throw new ErrorException("报告文件地址不正确");
        Path root = Paths.get(wordUrl).toAbsolutePath().normalize();
        Path file = root.resolve(path.substring("/word/".length())).normalize();
        if (!file.startsWith(root)) throw new ErrorException("报告文件地址不正确");
        if (!Files.isRegularFile(file)) return null;
        if (!file.toRealPath().startsWith(root.toRealPath())) throw new ErrorException("报告文件地址不正确");
        return file;
    }
    //批量上传
@@ -1234,38 +1364,172 @@
        return unqualifiedCount;
    }
    /**
     * é€€å›žåˆ°æ£€éªŒä»»åŠ¡
     * @param id
     * @return
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean sendBackTask(Integer id) {
        InsReport report = insReportMapper.selectById(id);
    public boolean sendBackTask(Integer id, String target, String reason) {
        if (id == null || (!RETURN_TO_INSPECTION_TASK.equals(target)
                && !RETURN_TO_REPORT_REVIEW.equals(target))) {
            throw new ErrorException("退回参数不正确");
        }
        if (StringUtils.isBlank(reason)) {
            throw new ErrorException("请输入退回原因");
        }
        InsReport report = insReportMapper.selectOne(Wrappers.<InsReport>lambdaQuery()
                .eq(InsReport::getId, id)
                .last("FOR UPDATE"));
        if (report == null || report.getInsOrderId() == null) {
            throw new ErrorException("报告不存在或未关联检验单");
        }
        // æŠ¥å‘Šç”±è®¢å•下各试验室共同形成;退回时全部恢复为待复核,避免只随机退回一个试验室。
        insOrderStateMapper.update(null, Wrappers.<InsOrderState>lambdaUpdate()
                .eq(InsOrderState::getInsOrderId, report.getInsOrderId())
                .set(InsOrderState::getInsState, 4));
        InsSampleUser sampleUser = insSampleUserMapper.selectOne(Wrappers.<InsSampleUser>lambdaQuery()
                .eq(InsSampleUser::getInsSampleId, report.getInsOrderId())
                .orderByDesc(InsSampleUser::getId)
                .last("limit 1"));
        if (sampleUser != null) {
            insSampleUserMapper.deleteById(sampleUser.getId());
        if (!Objects.equals(report.getIsPass(), 1)) {
            throw new ErrorException("报告已退出报告流程,请勿重复退回");
        }
        // ä¿®æ”¹æŠ¥å‘Šè¡Œä¸ºæœªæ˜¾ç¤º
        LocalDateTime returnTime = LocalDateTime.now();
        recordReportReturn(report, target, reason, returnTime);
        if (RETURN_TO_REPORT_REVIEW.equals(target)) {
            if (!Objects.equals(report.getIsRatify(), 1)) {
                throw new ErrorException("只有已批准报告可以退回到报告审核");
            }
            String reviewUrl = report.getReviewUrl();
            String reviewInspectionUrl = report.getReviewInspectionUrl();
            if (StringUtils.isBlank(reviewUrl)) {
                reviewUrl = prepareLegacyApprovedReportForReview(report,
                        StringUtils.isNotBlank(report.getUrlS()) ? report.getUrlS() : report.getUrl());
                if (StringUtils.isNotBlank(report.getInspectionUrl())) {
                    reviewInspectionUrl = prepareLegacyApprovedReportForReview(report, report.getInspectionUrl());
                }
            }
            int affected = insReportMapper.update(null, Wrappers.<InsReport>lambdaUpdate()
                    .eq(InsReport::getId, id)
                    .eq(InsReport::getIsRatify, 1)
                    .set(InsReport::getState, 1)
                    .set(InsReport::getIsExamine, RETURNED_STATUS)
                    .set(InsReport::getExamineTime, returnTime)
                    .set(InsReport::getExamineTell, reason)
                    .set(InsReport::getIsRatify, RETURNED_STATUS)
                    .set(InsReport::getRatifyTime, returnTime)
                    .set(InsReport::getRatifyTell, reason)
                    .set(InsReport::getUrlS, Objects.equals(reviewUrl, report.getUrl()) ? null : reviewUrl)
                    .set(StringUtils.isNotBlank(reviewInspectionUrl), InsReport::getInspectionUrl,
                            reviewInspectionUrl)
                    .set(InsReport::getReviewUrl, null)
                    .set(InsReport::getReviewInspectionUrl, null)
                    .set(InsReport::getTempUrlPdf, null)
                    .set(InsReport::getInspectionTempUrlPdf, null));
            if (affected != 1) {
                throw new ErrorException("报告状态已变化,请刷新后重试");
            }
            expireReportEditSessions(id);
            return true;
        }
        if (!Objects.equals(report.getState(), 1) && !Objects.equals(report.getIsRatify(), 1)) {
            throw new ErrorException("当前报告不在审核或已批准状态,不能退回检验任务");
        }
        InsOrder order = insOrderMapper.selectById(report.getInsOrderId());
        if (order == null) {
            throw new ErrorException("报告关联的检验单不存在");
        }
        boolean hadApproval = report.getIsRatify() != null;
        // æ¢å¤è®¢å•主状态,否则检验任务查询要求 io.state=1 æ—¶ä»æ— æ³•找到该任务。
        insOrderMapper.update(null, Wrappers.<InsOrder>lambdaUpdate()
                .eq(InsOrder::getId, report.getInsOrderId())
                .set(InsOrder::getState, 1)
                .set(InsOrder::getInsState, 4));
        // æ•´ä»½æŠ¥å‘Šç”±è®¢å•下各试验室共同形成,退回后各检验室均回到可修改、重新提交复核的状态。
        insOrderStateMapper.update(null, Wrappers.<InsOrderState>lambdaUpdate()
                .eq(InsOrderState::getInsOrderId, report.getInsOrderId())
                .set(InsOrderState::getInsState, 4)
                .set(InsOrderState::getVerifyUser, null)
                .set(InsOrderState::getVerifyTell, reason));
        // åªæ’¤é”€å¤æ ¸äººä»»åŠ¡ï¼Œä¿ç•™å„æ£€éªŒå®¤åŽŸæ£€éªŒäººï¼Œé¿å…é€€å›žåŽä»»åŠ¡æ— äººå¯è§ã€‚
        insSampleUserMapper.delete(Wrappers.<InsSampleUser>lambdaQuery()
                .eq(InsSampleUser::getInsSampleId, report.getInsOrderId())
                .eq(InsSampleUser::getState, 1));
        // æŠ¥å‘Šæš‚æ—¶é€€å‡ºç¼–制列表。原始数据复核通过后,现有流程会恢复报告并重新提交审核。
        insReportMapper.update(null, Wrappers.<InsReport>lambdaUpdate()
                .eq(InsReport::getId, id)
                .set(InsReport::getIsPass, 0));
                .set(InsReport::getIsPass, 0)
                .set(InsReport::getState, 0)
                .set(InsReport::getIsExamine, RETURNED_STATUS)
                .set(InsReport::getExamineTime, returnTime)
                .set(InsReport::getExamineTell, reason)
                .set(InsReport::getIsRatify, hadApproval ? RETURNED_STATUS : null)
                .set(InsReport::getRatifyTime, hadApproval ? returnTime : null)
                .set(InsReport::getRatifyTell, reason)
                .set(InsReport::getUrlS, null)
                .set(InsReport::getReviewUrl, null)
                .set(InsReport::getReviewInspectionUrl, null)
                .set(InsReport::getTempUrlPdf, null)
                .set(InsReport::getInspectionTempUrlPdf, null));
        return false;
        expireReportEditSessions(id);
        return true;
    }
    private String prepareLegacyApprovedReportForReview(InsReport report, String approvedUrl) {
        if (StringUtils.isBlank(approvedUrl)) {
            throw new ErrorException("历史批准报告文件不存在,无法退回报告审核");
        }
        try {
            Path source = Paths.get(approvedUrl.replace("/word", wordUrl));
            String fileName = "returned-" + UUID.randomUUID() + ".docx";
            Path target = Paths.get(wordUrl, fileName);
            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
            ReportSealLayoutUtil.restoreForReview(target.toString());
            return "/word/" + fileName;
        } catch (Exception e) {
            log.error("历史批准报告恢复审核稿失败: {}", approvedUrl, e);
            throw new ErrorException("历史批准报告无法清除旧批准信息,请退回检验任务重新生成");
        }
    }
    private void recordReportReturn(InsReport report, String target, String reason,
                                    LocalDateTime returnTime) {
        recordReportReturn(report,
                Objects.equals(report.getIsRatify(), 1) ? "APPROVED" : "REPORT_REVIEW",
                target, reason, returnTime);
    }
    private void recordReportReturn(InsReport report, String fromStage, String target, String reason,
                                    LocalDateTime returnTime) {
        InsReportReturnRecord record = new InsReportReturnRecord();
        record.setReportId(report.getId());
        record.setInsOrderId(report.getInsOrderId());
        record.setFromStage(fromStage);
        record.setTargetStage(target);
        record.setReturnReason(reason.trim());
        record.setState(report.getState());
        record.setIsExamine(report.getIsExamine());
        record.setExamineTime(report.getExamineTime());
        record.setIsRatify(report.getIsRatify());
        record.setRatifyTime(report.getRatifyTime());
        record.setReportUrl(StringUtils.isNotBlank(report.getUrlS()) ? report.getUrlS() : report.getUrl());
        record.setInspectionUrl(report.getInspectionUrl());
        record.setTempUrlPdf(report.getTempUrlPdf());
        record.setInspectionTempUrlPdf(report.getInspectionTempUrlPdf());
        record.setOperatorId(SecurityUtils.getUserId().intValue());
        record.setCreateTime(returnTime);
        if (insReportReturnRecordMapper.insert(record) != 1) {
            throw new ErrorException("报告退回记录保存失败");
        }
    }
    private void expireReportEditSessions(Integer reportId) {
        insReportEditSessionMapper.update(null, Wrappers.<InsReportEditSession>lambdaUpdate()
                .eq(InsReportEditSession::getReportId, reportId)
                .in(InsReportEditSession::getStatus, "EDITING", "SAVED", "FAILED", "SAVE_REQUESTED")
                .set(InsReportEditSession::getStatus, "EXPIRED")
                .set(InsReportEditSession::getMessage, "报告已退回")
                .set(InsReportEditSession::getUpdateTime, LocalDateTime.now()));
    }
    /**
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/SealUsageLedgerServiceImpl.java
@@ -1,6 +1,7 @@
package com.ruoyi.inspect.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.exception.ErrorException;
@@ -93,6 +94,12 @@
                || StringUtils.isBlank(report.getSealNames())) {
            throw new ErrorException("报告批准信息不完整,无法生成用印台账");
        }
        // æŠ¥å‘Šé€€å›žåŽé‡æ–°æ‰¹å‡†ä»å±žäºŽåŒä¸€ä»½æŠ¥å‘Šï¼›ä¿ç•™é¦–次批准生成的独立用印记录,
        // é¿å…å”¯ä¸€é”®å†²çªå¯¼è‡´é‡æ–°æ‰¹å‡†äº‹åŠ¡æ•´ä½“å›žæ»šã€‚
        if (ledgerMapper.selectCount(Wrappers.<SealUsageLedger>lambdaQuery()
                .eq(SealUsageLedger::getReportId, report.getId())) > 0) {
            return;
        }
        User ratifyUser = userMapper.selectById(report.getRatifyUserId());
        if (ratifyUser == null || StringUtils.isBlank(ratifyUser.getName())) {
            throw new ErrorException("找不到报告批准人,无法生成用印台账");
inspect-server/src/main/java/com/ruoyi/inspect/util/ReportSealLayoutUtil.java
@@ -1,6 +1,8 @@
package com.ruoyi.inspect.util;
import com.aspose.words.Document;
import com.aspose.words.Cell;
import com.aspose.words.CompositeNode;
import com.aspose.words.LayoutCollector;
import com.aspose.words.LayoutEnumerator;
import com.aspose.words.License;
@@ -11,6 +13,7 @@
import com.aspose.words.Paragraph;
import com.aspose.words.RelativeHorizontalPosition;
import com.aspose.words.RelativeVerticalPosition;
import com.aspose.words.Run;
import com.aspose.words.SaveFormat;
import com.aspose.words.Section;
import com.aspose.words.Shape;
@@ -75,6 +78,98 @@
        document.save(wordPath, SaveFormat.DOCX);
    }
    /** ä»ŽåŽ†å²æ‰¹å‡†ç¨¿ä¸­æ¸…é™¤æ‰¹å‡†å†…å®¹ï¼Œæ¢å¤é‡æ–°æ‰¹å‡†æ‰€éœ€çš„å ä½ç¬¦ã€‚ */
    public static void restoreForReview(String wordPath) throws Exception {
        applyAsposeLicense();
        Document document = new Document(wordPath);
        List<Shape> shapes = imageShapes(document);
        Shape signatureShape = null;
        List<Shape> dateShapes = new ArrayList<>();
        for (Shape shape : shapes) {
            if (hasPlaceholderMarker(shape, "ratifyUrl")) {
                signatureShape = shape;
                continue;
            }
            if (hasPlaceholderMarker(shape, "ratifyDateUrl")) {
                dateShapes.add(shape);
                continue;
            }
            // æ‰¹å‡†æ—¶æ·»åŠ çš„æŠ¥å‘Šç« å’Œ MA ç« æŒ‰é¡µé¢æµ®åŠ¨å®šä½ï¼ŒæŒ‰ä½ç½®æ¸…ç†è€Œéžæ¯”è¾ƒå›¾ç‰‡å­—èŠ‚ã€‚
            if (isApprovalSeal(shape)) {
                shape.remove();
            }
        }
        if (signatureShape != null) {
            replaceWithPlaceholder(document, signatureShape, "{{@ratifyUrl}}");
        } else if (!restoreApprovalCellPlaceholder(document)) {
            throw new IllegalStateException("未找到批准栏,无法恢复批准签名占位符");
        }
        // éƒ¨åˆ†æŠ¥å‘Šæ¨¡æ¿æ²¡æœ‰æ‰¹å‡†æ—¥æœŸå›¾ç‰‡ï¼Œæ‰¹å‡†æ—¶é—´ä»…存在数据库中。
        for (Shape dateShape : dateShapes) {
            replaceWithPlaceholder(document, dateShape, "{{@ratifyDateUrl}}");
        }
        document.save(wordPath, SaveFormat.DOCX);
    }
    private static boolean isApprovalSeal(Shape shape) throws Exception {
        return shape.getWrapType() != WrapType.INLINE
                && shape.getRelativeHorizontalPosition() == RelativeHorizontalPosition.PAGE
                && shape.getRelativeVerticalPosition() == RelativeVerticalPosition.PAGE
                && shape.getWidth() >= 50D && shape.getWidth() <= 140D
                && shape.getHeight() >= 50D && shape.getHeight() <= 140D;
    }
    private static boolean restoreApprovalCellPlaceholder(Document document) throws Exception {
        NodeCollection cells = document.getChildNodes(NodeType.CELL, true);
        for (int i = 0; i < cells.getCount(); i++) {
            Cell label = (Cell) cells.get(i);
            if (!"批准".equals(label.getText().replace("\r", "").replace("\u0007", "").trim())) {
                continue;
            }
            Node sibling = label.getNextSibling();
            if (!(sibling instanceof Cell)) {
                continue;
            }
            Cell signatureCell = (Cell) sibling;
            for (Shape shape : imageShapesIn(signatureCell)) {
                shape.remove();
            }
            Paragraph paragraph = signatureCell.getFirstParagraph();
            if (paragraph == null) {
                throw new IllegalStateException("批准签名单元格缺少段落");
            }
            if (!signatureCell.getText().contains("{{@ratifyUrl}}")) {
                paragraph.appendChild(new Run(document, "{{@ratifyUrl}}"));
            }
            return true;
        }
        return false;
    }
    private static List<Shape> imageShapesIn(Cell cell) throws Exception {
        List<Shape> result = new ArrayList<>();
        NodeCollection nodes = cell.getChildNodes(NodeType.SHAPE, true);
        for (int i = 0; i < nodes.getCount(); i++) {
            Shape shape = (Shape) nodes.get(i);
            if (shape.hasImage()) {
                result.add(shape);
            }
        }
        return result;
    }
    private static boolean hasPlaceholderMarker(Shape shape, String marker) throws Exception {
        String alternativeText = shape.getAlternativeText();
        return alternativeText != null && alternativeText.contains(marker);
    }
    @SuppressWarnings({"rawtypes", "unchecked"})
    private static void replaceWithPlaceholder(Document document, Shape shape, String placeholder) {
        CompositeNode parent = (CompositeNode) shape.getParentNode();
        parent.insertBefore(new Run(document, placeholder), shape);
        shape.remove();
    }
    private static void applyAsposeLicense() throws Exception {
        try (InputStream inputStream = ReportSealLayoutUtil.class.getResourceAsStream("/lib/license.xml")) {
            if (inputStream == null) {
@@ -93,7 +188,7 @@
            return null;
        }
        Path path = java.nio.file.Paths.get(imagePath);
        return Files.isRegularFile(path) ? Files.readAllBytes(path) : null;
        return Files.readAllBytes(path);
    }
    private static void removeMatchingImages(Document document, byte[] imageBytes) throws Exception {
@@ -115,12 +210,18 @@
    private static void addCoverReportSeal(Document document, String imagePath) throws Exception {
        Paragraph anchor = firstBodyParagraph(document);
        LayoutCollector collector = new LayoutCollector(document);
        Paragraph company = findPageCompanyParagraph(document, collector, 1);
        Rectangle2D rectangle = company == null ? null : getLayoutRectangle(document, collector, company);
        PageSetup pageSetup = document.getFirstSection().getPageSetup();
        double[] size = fitImage(imagePath, REPORT_MAX_WIDTH, REPORT_MAX_HEIGHT);
        Shape shape = createFloatingImage(document, imagePath, size[0], size[1]);
        shape.setLeft(pageSetup.getLeftMargin() + 20D);
        shape.setTop(pageSetup.getPageHeight() - pageSetup.getBottomMargin()
                - size[1] - POSITION_GAP);
        shape.setLeft((pageSetup.getPageWidth() - size[0]) / 2D);
        // å°é¢ç« ä¸Žåº•部机构名称重叠,随名称的实际排版位置移动。
        double preferredTop = rectangle == null
                ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - POSITION_GAP
                : rectangle.getCenterY() - size[1] / 2D;
        shape.setTop(clamp(preferredTop, 0D, pageSetup.getPageHeight() - size[1]));
        shape.setBehindText(true);
        anchor.appendChild(shape);
    }
@@ -157,7 +258,9 @@
    private static void addLastPageReportSeal(Document document, String imagePath) throws Exception {
        int lastPage = document.getPageCount();
        LayoutCollector collector = new LayoutCollector(document);
        Paragraph anchor = findLastPageAnchor(document, collector, lastPage);
        Paragraph title = findResultTitle(document);
        // ç»“果表可能跨页,章应跟随结果标题,不能跟随最后一页的“报告结束”。
        Paragraph anchor = title == null ? findLastPageAnchor(document, collector, lastPage) : title;
        if (anchor == null) {
            return;
        }
@@ -167,11 +270,23 @@
        Shape shape = createFloatingImage(document, imagePath, size[0], size[1]);
        shape.setLeft((pageSetup.getPageWidth() - size[0]) / 2D);
        Rectangle2D rectangle = getLayoutRectangle(document, collector, anchor);
        double preferredTop = rectangle == null
                ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - POSITION_GAP
                : rectangle.getY() + rectangle.getHeight() + POSITION_GAP;
        double preferredTop;
        if (title != null) {
            Paragraph company = findPageCompanyParagraph(document, collector, collector.getStartPageIndex(title));
            Rectangle2D companyRectangle = company == null ? null : getLayoutRectangle(document, collector, company);
            // ç« çš„中心对准机构名称与结果标题组成的区域,允许伸入上页边距。
            double headingTop = companyRectangle == null || rectangle == null
                    ? (rectangle == null ? pageSetup.getTopMargin() : rectangle.getY())
                    : Math.min(companyRectangle.getY(), rectangle.getY());
            double headingBottom = rectangle == null ? headingTop : rectangle.getMaxY();
            preferredTop = (headingTop + headingBottom - size[1]) / 2D;
        } else {
            preferredTop = rectangle == null
                    ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - POSITION_GAP
                    : rectangle.getY() + rectangle.getHeight() + POSITION_GAP;
        }
        shape.setTop(clamp(preferredTop,
                pageSetup.getTopMargin(),
                title == null ? pageSetup.getTopMargin() : 0D,
                pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1]));
        shape.setBehindText(true);
        anchor.appendChild(shape);
@@ -180,7 +295,14 @@
    private static Shape createFloatingImage(Document document, String imagePath,
                                              double width, double height) throws Exception {
        Shape shape = new Shape(document, ShapeType.IMAGE);
        shape.getImageData().setImage(imagePath);
        // Aspose 15 ä¼šæŠŠ Linux çš„ / å¼€å¤´è·¯å¾„当作相对路径,可能生成空图片且不报错。
        // é€šè¿‡æµåµŒå…¥çœŸå®žå›¾ç‰‡å†…容,避免路径被按文档的资源地址再次解析。
        try (InputStream imageStream = Files.newInputStream(java.nio.file.Paths.get(imagePath))) {
            shape.getImageData().setImage(imageStream);
        }
        if (!shape.hasImage()) {
            throw new IllegalStateException("报告印章图片嵌入失败");
        }
        shape.setWidth(width);
        shape.setHeight(height);
        shape.setWrapType(WrapType.NONE);
@@ -220,6 +342,42 @@
        return fallback;
    }
    private static Paragraph findPageCompanyParagraph(Document document, LayoutCollector collector, int page)
            throws Exception {
        Paragraph company = null;
        NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true);
        for (int i = 0; i < paragraphs.getCount(); i++) {
            Paragraph paragraph = (Paragraph) paragraphs.get(i);
            if (paragraph.getAncestor(NodeType.BODY) != null
                    && paragraph.getAncestor(NodeType.CELL) == null
                    && collector.getStartPageIndex(paragraph) == page
                    && normalizedText(paragraph).endsWith("公司")) {
                company = paragraph;
            }
        }
        return company;
    }
    private static Paragraph findResultTitle(Document document) throws Exception {
        NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true);
        for (int i = 0; i < paragraphs.getCount(); i++) {
            Paragraph paragraph = (Paragraph) paragraphs.get(i);
            if (paragraph.getAncestor(NodeType.BODY) == null
                    || paragraph.getAncestor(NodeType.CELL) != null) {
                continue;
            }
            String text = normalizedText(paragraph);
            if ("检验结果".equals(text) || "检测结果".equals(text)) {
                return paragraph;
            }
        }
        return null;
    }
    private static String normalizedText(Paragraph paragraph) {
        return paragraph.getText().replaceAll("[\\s\\u3000]+", "");
    }
    private static Paragraph findBodyParagraphContaining(Document document, String text,
                                                          LayoutCollector collector, Integer page)
            throws Exception {
inspect-server/src/main/java/com/ruoyi/inspect/vo/InsOrderPlanVO.java
@@ -63,6 +63,9 @@
    @Schema(description = "理由")
    private String verifyTell;
    @Schema(description = "报告退回到检验任务的来源:APPROVED ä¸ºæ‰¹å‡†çŽ¯èŠ‚ï¼ŒREPORT_REVIEW ä¸ºå®¡æ ¸çŽ¯èŠ‚")
    private String reportReturnSource;
    private String sonLaboratory;
inspect-server/src/main/resources/mapper/InsOrderRatesMapper.xml
@@ -17,6 +17,7 @@
        left join ins_order_standard_template iost on iost.ins_order_id = io.id
        left join standard_template st on st.id = iost.template_id
        where iost.id is not null
        and io.state != 5
        group by io.id) a
        <if test="ew.customSqlSegment != null and ew.customSqlSegment != ''">
            ${ew.customSqlSegment}
@@ -27,8 +28,10 @@
    <select id="selectTemplateRatesDetail" resultType="com.ruoyi.inspect.dto.TemplateRateDto">
        select iost.number, iost.name, COALESCE(st.price, 0) as price
        from ins_order_standard_template iost
        join ins_order io on io.id = iost.ins_order_id
        left join standard_template st on st.id = iost.template_id
        where iost.ins_order_id = #{insOrderId}
        and io.state != 5
        order by iost.sort, iost.id
    </select>
inspect-server/src/main/resources/mapper/InsReportMapper.xml
@@ -49,6 +49,10 @@
            <if test='queryStatus == 2'>
                and ir.state = 1 and ir.is_examine = 1 and (ir.is_ratify != 1 or ir.is_ratify is null)
            </if>
            <!-- å·²é€€å›ž -->
            <if test='queryStatus == 3'>
                and (ir.is_examine = 2 or ir.is_ratify = 2)
            </if>
        </if>
        group by ir.id
        ) a
@@ -102,11 +106,11 @@
        case when state = 0 then '待提交' when state = 1 then '已提交' else '' end         state,
        examine_user,
        DATE_FORMAT(examine_time, '%Y-%m-%d %H:%i:%s')                                     examine_time,
        case when is_examine = 0 then '不通过' when is_examine = 1 then '通过' else '' end is_examine,
        case when is_examine = 0 then '不通过' when is_examine = 1 then '通过' when is_examine = 2 then '已退回' else '' end is_examine,
        examine_tell,
        ratify_user,
        DATE_FORMAT(ratify_time, '%Y-%m-%d %H:%i:%s')                                      ratify_time,
        case when is_ratify = 0 then '不批准' when is_ratify = 1 then '批准' else '' end   is_ratify,
        case when is_ratify = 0 then '不批准' when is_ratify = 1 then '批准' when is_ratify = 2 then '已退回' else '' end   is_ratify,
        ratify_tell
        from (
        select
@@ -147,6 +151,10 @@
            <if test='queryStatus == 2'>
                and ir.state = 1 and ir.is_examine = 1 and (ir.is_ratify != 1 or ir.is_ratify is null)
            </if>
            <!-- å·²é€€å›ž -->
            <if test='queryStatus == 3'>
                and (ir.is_examine = 2 or ir.is_ratify = 2)
            </if>
        </if>
        group by ir.id
        ) a
inspect-server/src/main/resources/mapper/InsSampleMapper.xml
@@ -195,6 +195,13 @@
            ELSE ios.ins_state
        END AS ins_state,
        ios.verify_tell,
        CASE WHEN ios.ins_state = 4 AND a.report_is_pass = 0 THEN (
            SELECT rr.from_stage
            FROM ins_report_return_record rr
            WHERE rr.ins_order_id = a.id AND rr.target_stage = 'INSPECTION_TASK'
            ORDER BY rr.id DESC
            LIMIT 1
        ) ELSE NULL END AS report_return_source,
        verify_user
        FROM
        (
@@ -240,6 +247,7 @@
        io.type_source,
        io.ifs_inventory_id,
        ira.id ins_report_id,
        ira.is_pass report_is_pass,
        ira.url,
        ira.url_s,
        ira.temp_url_pdf,