liding
2026-10-23 56fe897d3e06bfa1e1105980d0044e6e67b2a484
feat(report): 实现浏览器内报告DOCX编辑功能

- 新增 InsReportEditingService 提供编辑会话创建、文档读写及版本校验
- 新增 InsReportEditSession 实体及对应 Mapper 管理编辑会话生命周期
- 审核流程集成编辑会话校验,批准前强制确认报告已保存且版本一致
- 支持检测报告与检验报告双类型在线编辑及临时PDF件名
- 报告检测项目名称自动附加单位后缀
已添加3个文件
已修改16个文件
536 ■■■■■ 文件已修改
basic-server/src/main/java/com/ruoyi/basic/pojo/StandardTemplate.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
basic-server/src/main/java/com/ruoyi/basic/service/impl/StandardTemplateServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
basic-server/src/main/resources/mapper/StandardTemplateMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/controller/InsReportController.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/dto/WriteInspectionConditionDto.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/mapper/InsReportEditSessionMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsOrderStandardTemplate.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReport.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReportEditSession.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportEditingService.java 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderPlanServiceImpl.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderTemplateServiceImpl.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsProductServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsReportServiceImpl.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/java/com/ruoyi/inspect/vo/TemplateConditionVo.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/resources/static/detection-report-template-v2.docx 补丁 | 查看 | 原始文档 | blame | 历史
inspect-server/src/main/resources/static/inspection-report-template-v2.docx 补丁 | 查看 | 原始文档 | blame | 历史
basic-server/src/main/java/com/ruoyi/basic/pojo/StandardTemplate.java
@@ -15,6 +15,7 @@
 */
@TableName(value ="standard_template")
@Data
@Schema(description = "原始记录模板")
public class StandardTemplate  implements Serializable {
    /**
     *
@@ -75,6 +76,9 @@
    @Schema(description = "模板编号")
    private String number;
    @Schema(description = "标准要求值")
    private String standardRequirement;
    /**
     * ä»·æ ¼(元)
     */
basic-server/src/main/java/com/ruoyi/basic/service/impl/StandardTemplateServiceImpl.java
@@ -131,6 +131,9 @@
        // æŸ¥è¯¢æ—§æ¨¡æ¿
        StandardTemplate standardTemplate = baseMapper.selectById(newTemplate.getId());
        newTemplate.setThing(standardTemplate.getThing());
        if (newTemplate.getStandardRequirement() == null) {
            newTemplate.setStandardRequirement(standardTemplate.getStandardRequirement());
        }
        if (newTemplate.getPrice() == null) {
            newTemplate.setPrice(java.math.BigDecimal.ZERO);
        }
basic-server/src/main/resources/mapper/StandardTemplateMapper.xml
@@ -9,6 +9,7 @@
            <result property="name" column="name" jdbcType="VARCHAR"/>
            <result property="remark" column="remark" jdbcType="VARCHAR"/>
            <result property="thing" column="thing" jdbcType="VARCHAR"/>
            <result property="standardRequirement" column="standard_requirement" jdbcType="VARCHAR"/>
            <result property="createUser" column="create_user" jdbcType="INTEGER"/>
            <result property="updateUser" column="update_user" jdbcType="INTEGER"/>
            <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
@@ -17,7 +18,7 @@
    <select id="selectStandardTemplatePageList" resultType="com.ruoyi.basic.pojo.StandardTemplate">
        select * from (
        select st.id, st.name,st.number, st.price, st.remark, u2.name create_user_name, u3.name update_user_name, st.create_time, st.update_time
        select st.id, st.name,st.number, st.standard_requirement, st.price, st.remark, u2.name create_user_name, u3.name update_user_name, st.create_time, st.update_time
        from standard_template st
        left join user u2 on u2.id = st.create_user
        left join user u3 on u3.id = st.update_user
inspect-server/src/main/java/com/ruoyi/inspect/controller/InsReportController.java
@@ -1,16 +1,13 @@
package com.ruoyi.inspect.controller;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.basic.dto.IfsInventoryQuantitySupplierDto;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.PersonalScope;
import com.ruoyi.common.core.domain.Result;
import com.ruoyi.framework.exception.ErrorException;
import com.ruoyi.inspect.dto.ReportPageDto;
import com.ruoyi.inspect.service.InsOrderPlanService;
import com.ruoyi.inspect.service.InsReportEditingService;
import com.ruoyi.inspect.service.InsReportService;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
@@ -20,16 +17,12 @@
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Objects;
import java.util.Scanner;
@RestController
@RequestMapping("/insReport")
@@ -41,6 +34,9 @@
    @Resource
    private InsOrderPlanService insOrderPlanService;
    @Resource
    private InsReportEditingService insReportEditingService;
    @Value("${wordUrl}")
    private String wordUrl;
@@ -106,7 +102,11 @@
        Integer isExamine = (Integer) param.get("isExamine");
        String examineTell = (String) param.get("examineTell");
        Integer userId = (Integer) param.get("userId");
        return Result.success(insReportService.examineReport(id, isExamine, examineTell, userId));
        Map<String, String> sessions = new java.util.HashMap<>();
        if (param.get("editSessions") instanceof Map<?, ?> values) {
            values.forEach((key, value) -> sessions.put(String.valueOf(key), String.valueOf(value)));
        }
        return Result.success(insReportEditingService.examineReport(id, isExamine, examineTell, userId, sessions));
    }
    @Operation(summary = "批准")
@@ -131,34 +131,26 @@
        return value instanceof Number ? ((Number) value).intValue() : Integer.valueOf(String.valueOf(value));
    }
    @Anonymous
    @RequestMapping("/onlyOffice/save")
    public void saveFile(@RequestParam String fileName, HttpServletRequest request, HttpServletResponse response) {
        PrintWriter writer = null;
        try {
            writer = response.getWriter();
            // èŽ·å–ä¼ è¾“çš„json数据
            Scanner scanner = new Scanner(request.getInputStream()).useDelimiter("\\A");
            String body = scanner.hasNext() ? scanner.next() : "";
            JSONObject jsonObject = JSONObject.parseObject(body);
    @Operation(summary = "获取浏览器端报告编辑会话")
    @GetMapping("/editor/config")
    public Result editConfig(Integer id, Integer reportType) {
        return Result.success(insReportEditingService.createEditConfig(id, reportType));
    }
            if (jsonObject.containsKey("url")) {
                String jsonArray = jsonObject.get("lastsave").toString(); // æ›´æ–°æ—¶é—´
                String fileUrl = jsonObject.get("url").toString(); // æ›´æ–°æ–‡ä»¶url
                HttpUtil.downloadFile(fileUrl, FileUtil.file(wordUrl + "/" + fileName));
            }
        } catch (Exception e) {
            e.printStackTrace();
            writer.write("{\"error\":-1}");
            return;
        }
        /*
         * status = 1,我们给onlyOffice的服务返回{"error":"0"}的信息。
         * è¿™æ ·onlyOffice会认为回调接口是没问题的,这样就可以在线编辑文档了,否则的话会弹出窗口说明
         */
        if (Objects.nonNull(writer)) {
            writer.write("{\"error\":0}");
        }
    @Operation(summary = "读取审核 DOCX å†…容")
    @GetMapping("/editor/document")
    public org.springframework.http.ResponseEntity<byte[]> editDocument(@RequestParam String token) {
        return org.springframework.http.ResponseEntity.ok().header("Cache-Control", "no-store")
                .contentType(org.springframework.http.MediaType.parseMediaType(
                        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
                .body(insReportEditingService.readDocument(token));
    }
    @Operation(summary = "保存浏览器内编辑的 DOCX")
    @PostMapping(value = "/editor/document", consumes = "multipart/form-data")
    public Result saveEditDocument(@RequestParam String token, @RequestParam String version,
                                   @RequestPart MultipartFile file) {
        return Result.success(insReportEditingService.saveDocument(token, version, file));
    }
    @Operation(summary = "报告批量下载")
inspect-server/src/main/java/com/ruoyi/inspect/dto/WriteInspectionConditionDto.java
@@ -19,6 +19,9 @@
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionTime;
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionEndTime;
    private String detectionPlace;
    private List<Integer> deviceIds;
    private Integer insResult;
@@ -27,6 +30,8 @@
    private boolean temperaturePresent;
    @JsonIgnore
    private boolean detectionTimePresent;
    @JsonIgnore
    private boolean detectionEndTimePresent;
    @JsonIgnore
    private boolean detectionPlacePresent;
    @JsonIgnore
@@ -44,6 +49,11 @@
        this.detectionTimePresent = true;
    }
    public void setDetectionEndTime(LocalDate detectionEndTime) {
        this.detectionEndTime = detectionEndTime;
        this.detectionEndTimePresent = true;
    }
    public void setDetectionPlace(String detectionPlace) {
        this.detectionPlace = detectionPlace;
        this.detectionPlacePresent = true;
inspect-server/src/main/java/com/ruoyi/inspect/mapper/InsReportEditSessionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
package com.ruoyi.inspect.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.inspect.pojo.InsReportEditSession;
public interface InsReportEditSessionMapper extends BaseMapper<InsReportEditSession> {
}
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsOrderStandardTemplate.java
@@ -58,6 +58,9 @@
    @Schema(description = "模板编号")
    private String number;
    @Schema(description = "下单时原始记录模板的标准要求值")
    private String standardRequirement;
    @Schema(description = "模板展示及生成顺序")
    private Integer sort;
@@ -73,10 +76,14 @@
    @Schema(description = "该模板检验时的湿度")
    private String humidity;
    @Schema(description = "检测日期")
    @Schema(description = "检测开始日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionTime;
    @Schema(description = "检测结束日期,历史空值按开始日期处理")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionEndTime;
    @Schema(description = "检测地点")
    private String detectionPlace;
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReport.java
@@ -14,6 +14,7 @@
 */
@TableName(value ="ins_report")
@Data
@Schema(description = "检验报告")
public class InsReport implements Serializable {
    /**
     *
@@ -59,6 +60,10 @@
    @Schema(description = "临时报告地址")
    private String tempUrlPdf;
    /** æ£€éªŒæŠ¥å‘Šä¸´æ—¶é¢„览地址。 */
    @Schema(description = "检验报告临时预览地址")
    private String inspectionTempUrlPdf;
    /**
     * 1:批准 0:不批准
     */
inspect-server/src/main/java/com/ruoyi/inspect/pojo/InsReportEditSession.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
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;
/** æµè§ˆå™¨ç«¯ DOCX å®¡æ ¸ç¼–辑会话;documentKey ä¿å­˜æ–‡ä»¶å†…容 SHA-256。 */
@Data
@TableName("ins_report_edit_session")
@Schema(description = "报告审核在线编辑会话")
public class InsReportEditSession implements Serializable {
    @Schema(description = "会话ID")
    @TableId(type = IdType.AUTO)
    private Long id;
    @Schema(description = "编辑会话令牌")
    private String token;
    @Schema(description = "报告ID")
    private Integer reportId;
    @Schema(description = "报告类型:0检测报告,1检验报告")
    private Integer reportType;
    @Schema(description = "DOCX文件内容的SHA-256版本")
    private String documentKey;
    @Schema(description = "审核人ID")
    private Integer userId;
    @Schema(description = "会话状态")
    private String status;
    @Schema(description = "保存请求ID")
    private String saveRequestId;
    @Schema(description = "保存结果说明")
    private String message;
    @Schema(description = "会话过期时间")
    private LocalDateTime expiresAt;
    @Schema(description = "最近保存完成时间")
    private LocalDateTime savedAt;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportEditingService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,219 @@
package com.ruoyi.inspect.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.exception.ErrorException;
import com.ruoyi.inspect.mapper.InsReportEditSessionMapper;
import com.ruoyi.inspect.mapper.InsReportMapper;
import com.ruoyi.inspect.pojo.InsReport;
import com.ruoyi.inspect.pojo.InsReportEditSession;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.file.*;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
/** æµè§ˆå™¨å†… DOCX ç¼–辑:登录鉴权、版本校验,无匿名回调及文档服务器依赖。 */
@Service
public class InsReportEditingService {
    private static final long MAX_BYTES = 100L * 1024 * 1024;
    @Resource private InsReportMapper reportMapper;
    @Resource private InsReportEditSessionMapper sessionMapper;
    @Resource private InsReportService reportService;
    @Value("${wordUrl}") private String wordUrl;
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> createEditConfig(Integer id, Integer type) {
        if (!Objects.equals(type, 0) && !Objects.equals(type, 1)) throw new ErrorException("报告类型不正确");
        InsReport report = lockEditable(id);
        Path file = resolveFile(report, type);
        LocalDateTime now = LocalDateTime.now();
        sessionMapper.update(null, Wrappers.<InsReportEditSession>lambdaUpdate()
                .eq(InsReportEditSession::getReportId, id).eq(InsReportEditSession::getReportType, type)
                .in(InsReportEditSession::getStatus, "EDITING", "SAVED", "FAILED", "SAVE_REQUESTED")
                .set(InsReportEditSession::getStatus, "EXPIRED"));
        InsReportEditSession session = new InsReportEditSession();
        session.setReportId(id);
        session.setReportType(type);
        session.setToken(UUID.randomUUID().toString());
        session.setDocumentKey(version(file));
        session.setUserId(SecurityUtils.getUserId().intValue());
        // æœªæ‰“开编辑的第二份报告确认使用当前持久化版本;当前编辑器由前端保存屏障提交。
        session.setStatus("SAVED");
        session.setSavedAt(now);
        session.setExpiresAt(now.plusHours(8));
        session.setCreateTime(now);
        session.setUpdateTime(now);
        sessionMapper.insert(session);
        return Map.of("sessionToken", session.getToken(), "version", session.getDocumentKey(),
                "title", file.getFileName().toString(), "reportType", type);
    }
    @Transactional(rollbackFor = Exception.class)
    public byte[] readDocument(String token) {
        InsReportEditSession session = findSession(token);
        InsReport report = lockEditable(session.getReportId());
        requireUsable(session);
        Path file = resolveFile(report, session.getReportType());
        requireVersion(session, file);
        try { return Files.readAllBytes(file); }
        catch (Exception e) { throw new ErrorException("读取报告失败"); }
    }
    @Transactional(rollbackFor = Exception.class)
    public Map<String, Object> saveDocument(String token, String expectedVersion, MultipartFile file) {
        InsReportEditSession session = findSession(token);
        InsReport report = lockEditable(session.getReportId());
        requireUsable(session);
        if (!Objects.equals(expectedVersion, session.getDocumentKey())) {
            throw new ErrorException("报告版本已变化,请重新打开后编辑");
        }
        requireVersion(session, resolveFile(report, session.getReportType()));
        if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
            throw new ErrorException("报告不能为空,且不能超过 100MB");
        }
        try {
            // æ ¡éªŒ OOXML ç»“构,拒绝 ZIP、HTML æˆ–错误响应冒充 DOCX。
            try (InputStream input = file.getInputStream(); XWPFDocument doc = new XWPFDocument(input)) {
                if (doc.getDocument().getBody() == null) throw new IllegalArgumentException();
            }
            Path root = Paths.get(wordUrl).toAbsolutePath().normalize();
            Files.createDirectories(root);
            Path staged = root.resolve("edited-" + UUID.randomUUID() + ".docx");
            file.transferTo(staged);
            // æ–° Word、PDF å…¨éƒ¨ç”ŸæˆæˆåŠŸåŽå‘å¸ƒæ•°æ®åº“æŒ‡é’ˆï¼›ä»»ä½•å¤±è´¥éƒ½ä¸è¦†ç›–æ—§æ–‡ä»¶ã€‚
            String pdfUrl = reportService.prepareEditedReportPreview(staged.toString());
            String documentUrl = "/word/" + staged.getFileName();
            var update = Wrappers.<InsReport>lambdaUpdate().eq(InsReport::getId, report.getId());
            if (session.getReportType() == 1) {
                update.set(InsReport::getInspectionUrl, documentUrl).set(InsReport::getInspectionTempUrlPdf, pdfUrl);
            } else {
                update.set(InsReport::getUrlS, documentUrl).set(InsReport::getTempUrlPdf, pdfUrl);
            }
            reportMapper.update(null, update);
            session.setDocumentKey(version(staged));
            session.setStatus("SAVED");
            session.setSavedAt(LocalDateTime.now());
            session.setUpdateTime(LocalDateTime.now());
            session.setMessage("保存成功");
            sessionMapper.updateById(session);
            return Map.of("version", session.getDocumentKey(), "completed", true, "pdfUrl", pdfUrl);
        } catch (Exception e) {
            throw e instanceof ErrorException error ? error : new ErrorException("保存 Word å¤±è´¥ï¼Œè¯·é‡è¯•:" + e.getMessage());
        }
    }
    /** ä¿å­˜ç‰ˆæœ¬æ ¸å¯¹ã€å®¡æ ¸ç­¾ååŠä¼šè¯å¤±æ•ˆä½¿ç”¨åŒä¸€ä¸ªæŠ¥å‘Šè¡Œé”ä¸Žæ•°æ®åº“事务。 */
    @Transactional(rollbackFor = Exception.class)
    public int examineReport(Integer id, Integer passed, String reason, Integer approver, Map<String, String> tokens) {
        InsReport report = lockEditable(id);
        if (!Objects.equals(passed, 0) && !Objects.equals(passed, 1)) throw new ErrorException("审核结果不正确");
        if (Objects.equals(passed, 1)) {
            checkSaved(report, 0, tokens);
            if (StringUtils.isNotBlank(report.getInspectionUrl())) checkSaved(report, 1, tokens);
            // ç­¾åæµç¨‹ä¼šæ”¹å†™æ–‡ä»¶ï¼›å…ˆå¤åˆ¶å†åœ¨äº‹åŠ¡ä¸­åˆ‡æ¢æŒ‡é’ˆï¼Œå¤±è´¥æ—¶æ—§çš„å·²ä¿å­˜ç¨¿ä¸å—å½±å“ã€‚
            stageForSignature(report);
        }
        int result = reportService.examineReport(id, passed, reason, approver);
        sessionMapper.update(null, Wrappers.<InsReportEditSession>lambdaUpdate()
                .eq(InsReportEditSession::getReportId, id)
                .in(InsReportEditSession::getStatus, "EDITING", "SAVED", "FAILED", "SAVE_REQUESTED")
                .set(InsReportEditSession::getStatus, "FINALIZED")
                .set(InsReportEditSession::getUpdateTime, LocalDateTime.now()));
        return result;
    }
    private void stageForSignature(InsReport report) {
        try {
            Path root = Paths.get(wordUrl).toAbsolutePath().normalize();
            var update = Wrappers.<InsReport>lambdaUpdate().eq(InsReport::getId, report.getId());
            for (int type : StringUtils.isNotBlank(report.getInspectionUrl()) ? new int[]{0, 1} : new int[]{0}) {
                Path copy = root.resolve("reviewed-" + UUID.randomUUID() + ".docx");
                Files.copy(resolveFile(report, type), copy);
                String url = "/word/" + copy.getFileName();
                if (type == 0) update.set(InsReport::getUrlS, url);
                else update.set(InsReport::getInspectionUrl, url);
            }
            reportMapper.update(null, update);
        } catch (Exception e) { throw new ErrorException("准备审核签名文件失败,请重试"); }
    }
    private void checkSaved(InsReport report, int type, Map<String, String> tokens) {
        if (tokens == null || StringUtils.isBlank(tokens.get(String.valueOf(type)))) {
            throw new ErrorException("请先打开审核编辑页面并保存报告");
        }
        InsReportEditSession session = findSession(tokens.get(String.valueOf(type)));
        requireUsable(session);
        if (!Objects.equals(session.getReportId(), report.getId()) || session.getReportType() != type) {
            throw new ErrorException("报告编辑会话不匹配");
        }
        requireVersion(session, resolveFile(report, type));
    }
    private InsReport lockEditable(Integer id) {
        InsReport report = reportMapper.selectOne(Wrappers.<InsReport>lambdaQuery()
                .eq(InsReport::getId, id).last("FOR UPDATE"));
        if (report == null) throw new ErrorException("报告不存在");
        if (!Objects.equals(report.getState(), 1) || Objects.equals(report.getIsExamine(), 1)
                || Objects.equals(report.getIsRatify(), 1)) throw new ErrorException("报告已不在待审核状态");
        if (!Objects.equals(report.getExamineUserId(), SecurityUtils.getUserId().intValue())) {
            throw new ErrorException("只有指定审核人可以编辑和审核报告");
        }
        return report;
    }
    private InsReportEditSession findSession(String token) {
        if (StringUtils.isBlank(token)) throw new ErrorException("缺少编辑会话");
        InsReportEditSession session = sessionMapper.selectOne(Wrappers.<InsReportEditSession>lambdaQuery()
                .eq(InsReportEditSession::getToken, token));
        if (session == null || !Objects.equals(session.getUserId(), SecurityUtils.getUserId().intValue())) {
            throw new ErrorException("编辑会话不存在或无权访问");
        }
        return session;
    }
    private void requireUsable(InsReportEditSession session) {
        // ç­‰å¾…报告锁期间可能已被另一窗口失效,故此处进行锁定读。
        InsReportEditSession current = sessionMapper.selectOne(Wrappers.<InsReportEditSession>lambdaQuery()
                .eq(InsReportEditSession::getId, session.getId()).last("FOR UPDATE"));
        if (current.getExpiresAt().isBefore(LocalDateTime.now()) || !"SAVED".equals(current.getStatus())) {
            throw new ErrorException("编辑会话已失效,请重新打开报告");
        }
        session.setDocumentKey(current.getDocumentKey());
        session.setStatus(current.getStatus());
    }
    private Path resolveFile(InsReport report, int type) {
        String url = type == 1 ? report.getInspectionUrl() : StringUtils.defaultIfBlank(report.getUrlS(), report.getUrl());
        if (StringUtils.isBlank(url) || !url.startsWith("/word/") || !url.toLowerCase().endsWith(".docx")) {
            throw new ErrorException("对应的 DOCX æŠ¥å‘Šä¸å­˜åœ¨");
        }
        try {
            Path root = Paths.get(wordUrl).toRealPath();
            Path file = root.resolve(url.substring(6)).normalize().toRealPath();
            if (!file.startsWith(root) || !Files.isRegularFile(file) || Files.size(file) > MAX_BYTES) throw new IllegalArgumentException();
            return file;
        } catch (Exception e) { throw new ErrorException("报告文件不存在或路径不合法"); }
    }
    private void requireVersion(InsReportEditSession session, Path file) {
        if (!Objects.equals(session.getDocumentKey(), version(file))) throw new ErrorException("报告已被替换,请重新打开后编辑");
    }
    private String version(Path file) {
        try (InputStream input = Files.newInputStream(file)) {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] buffer = new byte[8192];
            int length;
            while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length);
            return HexFormat.of().formatHex(digest.digest());
        } catch (Exception e) { throw new ErrorException("无法读取报告版本"); }
    }
}
inspect-server/src/main/java/com/ruoyi/inspect/service/InsReportService.java
@@ -40,6 +40,9 @@
    int wordInsertUrl(Map<String, Object> map, String url);
    /** åœ¨çº¿ç¼–辑保存后重新生成对应报告的临时 PDF。 */
    String prepareEditedReportPreview(String absoluteDocxPath);
    String downAll(String ids);
    /**
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderPlanServiceImpl.java
@@ -359,6 +359,9 @@
                        "模板ID " + snapshot.getTemplateId());
                if (snapshot.getDetectionTime() == null) {
                    collect.add("请选择“" + templateName + "”的检测时间");
                } else if (snapshot.getDetectionEndTime() != null
                        && snapshot.getDetectionEndTime().isBefore(snapshot.getDetectionTime())) {
                    collect.add("“" + templateName + "”的检测结束日期不能早于开始日期");
                }
                if (StringUtils.isBlank(snapshot.getDetectionPlace())) {
                    collect.add("请填写“" + templateName + "”的检测地点");
@@ -1655,6 +1658,8 @@
            refreshed.setInsOrderId(orderId);
            refreshed.setTemplateId(templateId);
            refreshed.setNumber(standardTemplate == null ? oldSnapshot.getNumber() : standardTemplate.getNumber());
            refreshed.setStandardRequirement(oldSnapshot == null
                    ? standardTemplate.getStandardRequirement() : oldSnapshot.getStandardRequirement());
            refreshed.setName(standardTemplate == null ? oldSnapshot.getName() : standardTemplate.getName());
            refreshed.setRemark(standardTemplate == null ? oldSnapshot.getRemark() : standardTemplate.getRemark());
            refreshed.setThing(standardTemplate == null ? oldSnapshot.getThing() : standardTemplate.getThing());
@@ -1665,6 +1670,7 @@
            refreshed.setHumidity(templateHumidityMap.get(templateId));
            // åˆ·æ–°æ¨¡æ¿ç»“构时必须保留检验阶段填写的业务数据,避免进入复核后回显为空。
            refreshed.setDetectionTime(oldSnapshot == null ? null : oldSnapshot.getDetectionTime());
            refreshed.setDetectionEndTime(oldSnapshot == null ? null : oldSnapshot.getDetectionEndTime());
            refreshed.setDetectionPlace(oldSnapshot == null ? null : oldSnapshot.getDetectionPlace());
            refreshed.setInsResult(oldSnapshot == null ? null : oldSnapshot.getInsResult());
            Integer templateSort = templateSortMap.get(templateId);
@@ -2004,7 +2010,8 @@
        if (Objects.equals(report.getIsRatify(), 1)) {
            throw new ErrorException("已批准报告不能重新生成");
        }
        if (StringUtils.isNotBlank(report.getUrlS())) {
        // urlS åŒæ—¶ç”¨äºŽäººå·¥ä¸Šä¼ å’Œå®¡æ ¸é¡µé¢ä¿å­˜çš„ Word;只有人工上传需要先手动切回系统报告。
        if (StringUtils.isNotBlank(report.getUrlS()) && !isBrowserEditedReportUrl(report.getUrlS())) {
            throw new ErrorException("当前报告已手动上传,请先还原为系统报告后再重新生成");
        }
        if (report.getInsOrderId() == null) {
@@ -2048,7 +2055,12 @@
                .eq(InsReport::getId, reportId)
                .set(InsReport::getWriteTime, report.getWriteTime())
                .set(InsReport::getIsPass, report.getIsPass())
                .set(InsReport::getUrlS, null)
                .set(InsReport::getTempUrlPdf, "/word/" + tempPdfName));
    }
    private static boolean isBrowserEditedReportUrl(String url) {
        return url != null && url.matches("(?i)^/word/(?:edited|reviewed)-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\\.docx$");
    }
    private Map<String, Object> buildExistingReportMarks(InsReport report) {
@@ -3613,7 +3625,8 @@
                        .eq(InsOrderStandardTemplate::getInsOrderId, orderId)
                        .orderByAsc(InsOrderStandardTemplate::getSort, InsOrderStandardTemplate::getId));
        List<LocalDate> detectionDates = templateSnapshots.stream()
                .map(InsOrderStandardTemplate::getDetectionTime)
                .flatMap(snapshot -> java.util.stream.Stream.of(
                        snapshot.getDetectionTime(), snapshot.getDetectionEndTime()))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        String insTime = formatReportTestDate(detectionDates);
@@ -3629,7 +3642,7 @@
        List<Map<String, String>> finalDeviceList = deviceList;
        String finalModelStr = modelStr;
        // ç›´ç»‘模板订单没有 ins_product,检测项目、单位和结果均从订单模板快照中获取。
        // ç›´ç»‘模板订单没有 ins_product,检测项目、标准要求值和结果均从订单模板快照中获取。
        List<Map<String, String>> detectionResultRows = directTemplateOrder
                ? buildTemplateDetectionResultRows(orderId)
                : buildDetectionResultRows(samples);
@@ -3811,7 +3824,7 @@
    }
    /**
     * æ–°ç‰ˆæ£€æµ‹æŠ¥å‘Šçš„结果表固定为四列:序号、检测项目、单位、检测结果。
     * æ–°ç‰ˆæ£€æµ‹ã€æ£€éªŒæŠ¥å‘Šå…±ç”¨ç»“果数据;标准要求值取自原始记录模板配置。
     */
    private List<Map<String, String>> buildDetectionResultRows(List<SampleProductDto> samples) {
        List<Map<String, String>> rows = new ArrayList<>();
@@ -3820,6 +3833,7 @@
        }
        boolean multipleSamples = samples.size() > 1;
        Map<Integer, String> requirementsByTemplate = new HashMap<>();
        int sequence = 1;
        for (SampleProductDto sample : samples) {
            if (CollectionUtils.isEmpty(sample.getInsProduct())) {
@@ -3840,8 +3854,15 @@
                Map<String, String> row = new LinkedHashMap<>();
                row.put("seq", String.valueOf(sequence++));
                row.put("itemName", itemName);
                row.put("itemName", formatReportItemWithUnit(itemName, product.getUnit()));
                row.put("unit", defaultReportText(product.getUnit(), "/"));
                String requirement = product.getTemplateId() == null ? null
                        : requirementsByTemplate.computeIfAbsent(product.getTemplateId(), templateId -> {
                            StandardTemplate sourceTemplate = standardTemplateService.getById(templateId);
                            return defaultReportText(sourceTemplate == null ? null
                                    : sourceTemplate.getStandardRequirement(), "/");
                        });
                row.put("standardRequirement", defaultReportText(requirement, "/"));
                row.put("result", defaultReportText(product.getLastValue(), "/"));
                row.put("inspectionConclusion", formatInspectionConclusion(product.getInsResult()));
                rows.add(row);
@@ -3867,7 +3888,8 @@
                Wrappers.<InsOrderStandardTemplate>lambdaQuery()
                        .eq(InsOrderStandardTemplate::getInsOrderId, orderId)
                        .orderByAsc(InsOrderStandardTemplate::getSort, InsOrderStandardTemplate::getId));
        context.setDetectionDates(snapshots.stream().map(InsOrderStandardTemplate::getDetectionTime)
        context.setDetectionDates(snapshots.stream().flatMap(snapshot -> java.util.stream.Stream.of(
                        snapshot.getDetectionTime(), snapshot.getDetectionEndTime()))
                .filter(Objects::nonNull).distinct().sorted().collect(Collectors.toList()));
        List<Integer> deviceIds = insOrderTemplateDeviceMapper.selectDeviceIdsByOrderId(orderId);
        if (CollectionUtils.isNotEmpty(deviceIds)) {
@@ -3895,6 +3917,12 @@
        List<Map<String, String>> rows = new ArrayList<>();
        int sequence = 1;
        for (InsOrderStandardTemplate snapshot : snapshots) {
            String standardRequirement = snapshot.getStandardRequirement();
            // å…¼å®¹æ–°å¢žè¯¥å­—段前下单的旧记录;有快照时始终以订单保存的值为准。
            if (StringUtils.isBlank(standardRequirement) && snapshot.getTemplateId() != null) {
                StandardTemplate sourceTemplate = standardTemplateService.getById(snapshot.getTemplateId());
                standardRequirement = sourceTemplate == null ? null : sourceTemplate.getStandardRequirement();
            }
            List<TemplateExportCell> exportCells = getTemplateExportCells(snapshot.getThing());
            JSONObject recordValues = StrUtil.isBlank(snapshot.getRecordValues())
                    ? new JSONObject() : JSON.parseObject(snapshot.getRecordValues());
@@ -3913,8 +3941,10 @@
                row.put("seq", String.valueOf(sequence++));
                String reportItemName = defaultReportText(itemName,
                        defaultReportText(snapshot.getName(), "模板ID " + snapshot.getTemplateId()));
                row.put("itemName", formatExportItemName(reportItemName));
                row.put("itemName", formatReportItemWithUnit(
                        formatExportItemName(reportItemName), unit));
                row.put("unit", defaultReportText(unit, "/"));
                row.put("standardRequirement", defaultReportText(standardRequirement, "/"));
                row.put("result", result);
                row.put("inspectionConclusion", formatInspectionConclusion(snapshot.getInsResult()));
                rows.add(row);
@@ -4090,6 +4120,14 @@
        return itemName.replaceAll("平均值\\s*[,,]?\\s*", "");
    }
    private static String formatReportItemWithUnit(String itemName, String unit) {
        if (StringUtils.isBlank(unit) || "/".equals(unit.trim())
                || itemName.contains(unit.trim())) {
            return itemName;
        }
        return itemName + "," + unit.trim();
    }
    private static String getMapText(Map<String, String> map, String... keys) {
        if (map == null) {
            return null;
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderServiceImpl.java
@@ -846,6 +846,7 @@
            snapshot.setTemplateId(template.getId());
            snapshot.setName(template.getName());
            snapshot.setNumber(template.getNumber());
            snapshot.setStandardRequirement(template.getStandardRequirement());
            snapshot.setRemark(template.getRemark());
            snapshot.setThing(template.getThing());
            snapshot.setSort(selection.getSort());
@@ -1091,6 +1092,7 @@
                                .select(InsOrderStandardTemplate::getTemplateId,
                                        InsOrderStandardTemplate::getTemperature,
                                        InsOrderStandardTemplate::getDetectionTime,
                                        InsOrderStandardTemplate::getDetectionEndTime,
                                        InsOrderStandardTemplate::getDetectionPlace,
                                        InsOrderStandardTemplate::getInsResult)
                                .orderByAsc(InsOrderStandardTemplate::getSort,
@@ -1100,6 +1102,8 @@
                    condition.setTemplateId(snapshot.getTemplateId());
                    condition.setTemperature(snapshot.getTemperature());
                    condition.setDetectionTime(snapshot.getDetectionTime());
                    condition.setDetectionEndTime(snapshot.getDetectionEndTime() == null
                            ? snapshot.getDetectionTime() : snapshot.getDetectionEndTime());
                    condition.setDetectionPlace(snapshot.getDetectionPlace());
                    condition.setInsResult(snapshot.getInsResult());
                    condition.setDeviceIds(templateDeviceIds.getOrDefault(
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsOrderTemplateServiceImpl.java
@@ -2,6 +2,7 @@
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -17,6 +18,9 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
 * @author Administrator
@@ -67,7 +71,29 @@
        vo.setStandardTreeId(standardTreeId);
        if (standardTreeId != null) {
            List<StandardTreeTemplateBindingVo> bindings = standardTreeService.selectTemplateBindings(standardTreeId);
            vo.setTemplateBindings(bindings == null ? new ArrayList<>() : bindings);
            // æ–°ä¿å­˜çš„下单模板记录了实际保留的原始记录模板;旧模板没有该字段,沿用标准库绑定列表。
            JSONObject content = StringUtils.isBlank(template.getThing())
                    ? null : JSON.parseObject(template.getThing());
            JSONArray savedSelections = content == null ? null : content.getJSONArray("templateList");
            if (savedSelections == null) {
                vo.setTemplateBindings(bindings == null ? new ArrayList<>() : bindings);
            } else {
                Map<Integer, StandardTreeTemplateBindingVo> currentBindings = (bindings == null
                        ? new ArrayList<StandardTreeTemplateBindingVo>() : bindings).stream()
                        .collect(Collectors.toMap(StandardTreeTemplateBindingVo::getTemplateId,
                                Function.identity(), (first, ignored) -> first));
                List<StandardTreeTemplateBindingVo> selected = new ArrayList<>();
                for (int index = 0; index < savedSelections.size(); index++) {
                    JSONObject selection = savedSelections.getJSONObject(index);
                    if (selection == null) continue;
                    StandardTreeTemplateBindingVo binding = currentBindings.get(selection.getInteger("templateId"));
                    if (binding != null) {
                        binding.setSort(index);
                        selected.add(binding);
                    }
                }
                vo.setTemplateBindings(selected);
            }
        } else {
            vo.setTemplateBindings(new ArrayList<>());
        }
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsProductServiceImpl.java
@@ -77,7 +77,24 @@
            if (snapshot == null) {
                throw new ErrorException("未找到当前模板的原始记录快照");
            }
            boolean hasSnapshotField = dto.isTemperaturePresent() || dto.isDetectionTimePresent()
            boolean hasDetectionRange = dto.isDetectionTimePresent() || dto.isDetectionEndTimePresent();
            if (hasDetectionRange) {
                if (!dto.isDetectionTimePresent()) {
                    throw new ErrorException("请选择完整的检测时间范围");
                }
                // å…¼å®¹æ—§å®¢æˆ·ç«¯åªæäº¤å•个日期;新客户端必须同时提交起止日期或同时清空。
                if (!dto.isDetectionEndTimePresent()) {
                    dto.setDetectionEndTime(dto.getDetectionTime());
                }
                if ((dto.getDetectionTime() == null) != (dto.getDetectionEndTime() == null)) {
                    throw new ErrorException("请选择完整的检测时间范围");
                }
                if (dto.getDetectionTime() != null
                        && dto.getDetectionEndTime().isBefore(dto.getDetectionTime())) {
                    throw new ErrorException("检测结束日期不能早于开始日期");
                }
            }
            boolean hasSnapshotField = dto.isTemperaturePresent() || hasDetectionRange
                    || dto.isDetectionPlacePresent()
                    || dto.isInsResultPresent();
            if (hasSnapshotField) {
@@ -88,6 +105,8 @@
                                        InsOrderStandardTemplate::getTemperature, dto.getTemperature())
                                .set(dto.isDetectionTimePresent(),
                                        InsOrderStandardTemplate::getDetectionTime, dto.getDetectionTime())
                                .set(hasDetectionRange,
                                        InsOrderStandardTemplate::getDetectionEndTime, dto.getDetectionEndTime())
                                .set(dto.isDetectionPlacePresent(),
                                        InsOrderStandardTemplate::getDetectionPlace, dto.getDetectionPlace())
                                .set(dto.isInsResultPresent(),
inspect-server/src/main/java/com/ruoyi/inspect/service/impl/InsReportServiceImpl.java
@@ -231,8 +231,6 @@
        info.setViewStatus(false);
        info.setJumpPath(MenuJumpPathConstants.REPORT_PREPARATION);
        informationNotificationService.addInformationNotification(info);
        String primaryReportUrl = getPrimaryReportUrl(insReport);
        // åˆ¤æ–­æ˜¯å¦æ˜¯åŽŸææ–™  éœ€è¦æ›¿æ¢****成供应商
        IfsInventoryQuantity one = ifsInventoryQuantityMapper.selectOne(new LambdaQueryWrapper<IfsInventoryQuantity>()
                .eq(IfsInventoryQuantity::getId, order.getIfsInventoryId()));
@@ -253,9 +251,7 @@
        }};
        writeMarksToReportFiles(insReport, writeMarks);
        // ä¿®æ”¹ä¸´æ—¶pdf
        String tempUrlPdf = wordToPdfTemp(primaryReportUrl.replace("/word", wordUrl));
        insReport.setTempUrlPdf("/word/" + tempUrlPdf);
        refreshTemporaryPreviews(insReport);
        insReportMapper.updateById(insReport);
@@ -383,16 +379,13 @@
        info.setViewStatus(false);
        info.setJumpPath(MenuJumpPathConstants.REPORT_PREPARATION);
        informationNotificationService.addInformationNotification(info);
        String primaryReportUrl = getPrimaryReportUrl(insReport);
        Map<String, Object> examineMarks = new HashMap<String, Object>() {{
            put("examineUrl", ReportSignaturePictureUtils.create(imgUrl, signatureUrl));
            put("examineDateUrl", Pictures.ofStream(DateImageUtil.createDateImage(null)).create());
        }};
        writeMarksToReportFiles(insReport, examineMarks);
        // ä¿®æ”¹ä¸´æ—¶pdf
        String tempUrlPdf = wordToPdfTemp(primaryReportUrl.replace("/word", wordUrl));
        insReport.setTempUrlPdf("/word/" + tempUrlPdf);
        refreshTemporaryPreviews(insReport);
        // å‘送企业微信通知(通知批准人审批)
        threadPoolTaskExecutor.execute(() -> {
@@ -728,6 +721,30 @@
                .collect(Collectors.toList());
    }
    private void refreshTemporaryPreviews(InsReport report) {
        String primaryPdf = wordToPdfTemp(getPrimaryReportUrl(report).replace("/word", wordUrl));
        if (StringUtils.isBlank(primaryPdf)) {
            throw new ErrorException("检测报告预览生成失败");
        }
        report.setTempUrlPdf("/word/" + primaryPdf);
        if (StringUtils.isNotBlank(report.getInspectionUrl())) {
            String inspectionPdf = wordToPdfTemp(report.getInspectionUrl().replace("/word", wordUrl));
            if (StringUtils.isBlank(inspectionPdf)) {
                throw new ErrorException("检验报告预览生成失败");
            }
            report.setInspectionTempUrlPdf("/word/" + inspectionPdf);
        }
    }
    @Override
    public String prepareEditedReportPreview(String absoluteDocxPath) {
        String pdfName = wordToPdfTemp(absoluteDocxPath);
        if (StringUtils.isBlank(pdfName)) {
            throw new ErrorException("报告预览生成失败");
        }
        return "/word/" + pdfName;
    }
    private void writeMarksToReportFiles(InsReport report, Map<String, Object> marks) {
        for (String reportUrl : getApprovalReportUrls(report)) {
            wordInsertUrl(marks, reportUrl.replace("/word", wordUrl));
@@ -804,7 +821,12 @@
            throw new ErrorException("报告文件不存在,请联系管理员");
        }
        try {
            String filename = file.getFileName().toString();
            // ç‰©ç†æ–‡ä»¶å¯èƒ½æ˜¯ reviewed/edited UUID;对用户始终使用业务报告编号。
            String reportNumber = StringUtils.isBlank(report.getCode()) ? "报告" : report.getCode();
            reportNumber = reportNumber.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "-").trim();
            String suffix = StringUtils.isNotBlank(report.getInspectionUrl())
                    ? (Objects.equals(reportType, INSPECTION_REPORT) ? "-检验报告" : "-检测报告") : "";
            String filename = reportNumber + suffix + ".docx";
            String encodedName = URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
            response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
            response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName);
inspect-server/src/main/java/com/ruoyi/inspect/vo/TemplateConditionVo.java
@@ -18,10 +18,14 @@
    @Schema(description = "温度")
    private String temperature;
    @Schema(description = "检测日期")
    @Schema(description = "检测开始日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionTime;
    @Schema(description = "检测结束日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate detectionEndTime;
    @Schema(description = "检测地点")
    private String detectionPlace;
inspect-server/src/main/resources/static/detection-report-template-v2.docx
Binary files differ
inspect-server/src/main/resources/static/inspection-report-template-v2.docx
Binary files differ