cnas-device/src/main/java/com/ruoyi/device/service/impl/DeviceExamineRecordServiceImpl.java
@@ -5,8 +5,6 @@
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.ruoyi.common.constant.MenuJumpPathConstants;
import com.ruoyi.common.core.domain.entity.InformationNotification;
import com.ruoyi.common.core.domain.entity.User;
@@ -24,11 +22,19 @@
import com.ruoyi.device.service.DeviceExamineRecordDetailService;
import com.ruoyi.device.service.DeviceExamineRecordService;
import com.ruoyi.framework.exception.ErrorException;
import com.ruoyi.inspect.util.HackLoopTableRenderPolicy;
import com.ruoyi.inspect.util.UserUtils;
import com.ruoyi.system.mapper.UserMapper;
import com.ruoyi.system.service.InformationNotificationService;
import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.apache.xmlbeans.XmlCursor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -37,9 +43,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -53,6 +57,22 @@
@Service
@Transactional(rollbackFor = Exception.class)
public class DeviceExamineRecordServiceImpl extends ServiceImpl<DeviceExamineRecordMapper, DeviceExamineRecord> implements DeviceExamineRecordService {
    /** 按设备套用的期间核查记录模板(与设备名称/型号匹配) */
    private static final String TEMPLATE_BALANCE_PERCENT = "/static/word/examine/examine-balance-percent.docx";
    private static final String TEMPLATE_BALANCE_PERCENT_BSA2202S = "/static/word/examine/examine-balance-percent-bsa2202s.docx";
    private static final String TEMPLATE_BALANCE_THOUSANDTH = "/static/word/examine/examine-balance-thousandth.docx";
    private static final String TEMPLATE_BALANCE_0_1MG_ME204E = "/static/word/examine/examine-balance-0.1mg-me204e.docx";
    private static final String TEMPLATE_BALANCE_0_1MG_AL204 = "/static/word/examine/examine-balance-0.1mg-al204.docx";
    private static final String TEMPLATE_SURFACE_TENSION = "/static/word/examine/examine-surface-tension.docx";
    private static final String TEMPLATE_ROTARY_VISCOSITY = "/static/word/examine/examine-rotary-viscosity.docx";
    private static final String TEMPLATE_VISCOSITY_PIN = "/static/word/examine/examine-viscosity-pin.docx";
    private static final String TEMPLATE_SPECTROPHOTOMETER = "/static/word/examine/examine-spectrophotometer.docx";
    /** 没有对应设备专用模板时套用的通用期间核查记录表 */
    private static final String TEMPLATE_GENERIC = "/static/word/examine/examine-generic.docx";
    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final String RECORD_NUMBER_TEXT = "记录编号:LMSY-CX09-JL02";
    @Resource
    private DeviceExamineRecordDetailService deviceExamineRecordDetailService;
@@ -194,7 +214,9 @@
        LambdaUpdateWrapper<DeviceExamineRecord> wrapper = Wrappers.<DeviceExamineRecord>lambdaUpdate()
                .eq(DeviceExamineRecord::getPlanDetailsId, dto.getPlanDetailsId())
                .set(DeviceExamineRecord::getReviewStatus, reviewStatus)
                .set(DeviceExamineRecord::getReviewRemark, dto.getReviewRemark());
                .set(DeviceExamineRecord::getReviewRemark, dto.getReviewRemark())
                // 审核动作时间即核查完成时间,列表「核查时间」按它显示
                .set(DeviceExamineRecord::getUpdateTime, LocalDateTime.now());
        // 为0清除审核人
        if (reviewStatus != null && reviewStatus == 0) {
@@ -221,17 +243,8 @@
            this.save(record);
        }
        // 复核通过后,设备档案的最近/下次核查日期进入下一轮
        // 复核通过后,设备档案的最近/下次核查日期按审核当天滚动;计划行的检查日期是计划日期,审核不改它
        if (reviewStatus != null && reviewStatus == 1) {
            // 提前审核时把本轮计划归到实际审核月份,便于系统登记下一轮;手动录入到日的日期保持原样
            String currentCheckTime = planDetails.getCheckTime();
            boolean hasCheckDate = currentCheckTime != null && currentCheckTime.trim().length() >= 10;
            if (!hasCheckDate) {
                DeviceExaminePlanDetails updateDetail = new DeviceExaminePlanDetails();
                updateDetail.setPlanDetailsId(dto.getPlanDetailsId());
                updateDetail.setCheckTime(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
                deviceExaminePlanService.updateExaminePlanDetail(updateDetail);
            }
            deviceExaminePlanService.updateDeviceExamineDate(dto.getPlanDetailsId());
        }
        return true;
@@ -250,40 +263,320 @@
        if (deviceExamineRecordDto == null) {
            throw new ErrorException("该计划明细暂无核查记录,请先录入后再导出");
        }
        DeviceExaminePlanDetails planDetails = deviceExaminePlanDetailsMapper.selectById(planDetailsId);
        Device device = planDetails != null && planDetails.getDeviceId() != null
                ? deviceMapper.selectById(planDetails.getDeviceId()) : null;
        // 查询复核核查记录详情
        List<DeviceExamineRecordDetail> deviceExamineRecordDetailList = deviceExamineRecordDetailService.list(Wrappers.<DeviceExamineRecordDetail>lambdaQuery().eq(DeviceExamineRecordDetail::getRecordId, deviceExamineRecordDto.getRecordId()));
        // 获取路径
        InputStream inputStream = this.getClass().getResourceAsStream("/static/word/examine-record.docx");
        Configure configure = Configure.builder()
                .bind("deviceExamineRecordDetailList", new HackLoopTableRenderPolicy())
                .build();
        XWPFTemplate template = XWPFTemplate.compile(inputStream, configure).render(
                new HashMap<String, Object>() {{
                    put("deviceExamineRecordDto", deviceExamineRecordDto);
                    put("deviceExamineRecordDetailList", deviceExamineRecordDetailList);
                    //获取核查人的签名地址
                    put("checkerUserUrl", UserUtils.getFinalUserSignatureOrTransparent(deviceExamineRecordDto.getCheckerUserId()));
                    //获取审查人的签名地址
                    put("reviewUserUrl", UserUtils.getFinalUserSignatureOrTransparent(deviceExamineRecordDto.getReviewUserId()));
                }});
        try {
            response.setContentType("application/msword");
            String deviceName = StringUtils.isNotEmpty(deviceExamineRecordDto.getDeviceName()) ? deviceExamineRecordDto.getDeviceName() : "";
            String fileName = URLEncoder.encode(
                    deviceName+ "核查记录", "UTF-8");
            response.setHeader("Content-disposition",
                    "attachment;filename=" + fileName + ".docx");
        String fileName;
        try {
            fileName = URLEncoder.encode(deviceName + "期间核查记录.docx", "UTF-8");
        } catch (Exception e) {
            fileName = "examine-record.docx";
        }
        // 按设备匹配专用模板,匹配不到时套用通用期间核查记录表
        String templatePath = resolveExamineRecordTemplate(planDetails, device);
        exportByDeviceTemplate(templatePath, planDetails, deviceExamineRecordDto, fileName, response);
    }
    /**
     * 按设备名称/型号匹配期间核查记录模板
     */
    private String resolveExamineRecordTemplate(DeviceExaminePlanDetails planDetails, Device device) {
        String deviceName = planDetails == null ? null : planDetails.getDeviceName();
        if (StringUtils.isBlank(deviceName) && device != null) {
            deviceName = device.getDeviceName();
        }
        String deviceModel = planDetails == null ? null : planDetails.getDeviceModel();
        if (StringUtils.isBlank(deviceModel) && device != null) {
            deviceModel = device.getSpecificationModel();
        }
        String name = deviceName == null ? "" : deviceName.trim();
        String model = deviceModel == null ? "" : deviceModel.trim().toUpperCase();
        if (name.contains("表面张力")) {
            return TEMPLATE_SURFACE_TENSION;
        }
        if (name.contains("旋转粘度") || name.contains("六速")) {
            return TEMPLATE_ROTARY_VISCOSITY;
        }
        if (name.contains("品氏")) {
            return TEMPLATE_VISCOSITY_PIN;
        }
        if (name.contains("分光光度")) {
            return TEMPLATE_SPECTROPHOTOMETER;
        }
        // 电子天平先按型号细分,再按档位名称
        if (model.contains("BSA2202S")) {
            return TEMPLATE_BALANCE_PERCENT_BSA2202S;
        }
        if (model.contains("ME204E")) {
            return TEMPLATE_BALANCE_0_1MG_ME204E;
        }
        if (model.contains("AL204")) {
            return TEMPLATE_BALANCE_0_1MG_AL204;
        }
        if (name.contains("万分之")) {
            return TEMPLATE_BALANCE_0_1MG_AL204;
        }
        if (name.contains("千分之")) {
            return TEMPLATE_BALANCE_THOUSANDTH;
        }
        if (name.contains("百分之")) {
            return TEMPLATE_BALANCE_PERCENT;
        }
        return TEMPLATE_GENERIC;
    }
    /**
     * 按设备专用模板导出:只填表头信息,检测结果表格留空
     */
    private void exportByDeviceTemplate(String templatePath, DeviceExaminePlanDetails planDetails,
                                        DeviceExamineRecordDto recordDto, String fileName, HttpServletResponse response) {
        try (InputStream inputStream = this.getClass().getResourceAsStream(templatePath)) {
            if (inputStream == null) {
                throw new ErrorException("核查记录模板不存在:" + templatePath);
            }
            XWPFDocument document = new XWPFDocument(inputStream);
            // 通用表按参考图带记录编号;设备专用模板保持模板原样
            if (TEMPLATE_GENERIC.equals(templatePath)) {
                insertRecordNumberLine(document);
            }
            if (!document.getTables().isEmpty()) {
                fillTemplateTable(document.getTables().get(0), planDetails, recordDto);
            }
            response.setContentType("application/msword");
            response.setHeader("Content-disposition", "attachment;filename=" + fileName);
            OutputStream os = response.getOutputStream();
            template.write(os);
            document.write(os);
            os.flush();
            os.close();
            inputStream.close();
            document.close();
        } catch (ErrorException e) {
            throw e;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("导出失败");
        }
    }
    /**
     * 在标题上方插入固定的记录编号行
     */
    private void insertRecordNumberLine(XWPFDocument document) {
        List<XWPFParagraph> paragraphs = document.getParagraphs();
        if (paragraphs.isEmpty()) {
            return;
        }
        XmlCursor cursor = paragraphs.get(0).getCTP().newCursor();
        XWPFParagraph paragraph = document.insertNewParagraph(cursor);
        paragraph.setAlignment(ParagraphAlignment.LEFT);
        XWPFRun run = paragraph.createRun();
        run.setFontFamily("宋体");
        run.setFontSize(10.5);
        run.setText(RECORD_NUMBER_TEXT);
    }
    /**
     * 按标签定位并填写表头,仪器现状打勾,补填评价/审核人
     */
    private void fillTemplateTable(XWPFTable table, DeviceExaminePlanDetails planDetails, DeviceExamineRecordDto recordDto) {
        String checkDate = StringUtils.isNotEmpty(recordDto.getUpdateTimeStr()) ? recordDto.getUpdateTimeStr()
                : (planDetails == null ? "" : planDetails.getCheckTime());
        String checkerName = resolveCheckerName(planDetails, recordDto);
        String reviewerName = resolveUserName(recordDto.getReviewUserId(), recordDto.getReviewUser());
        String appearanceStatus = planDetails == null ? null : planDetails.getAppearanceStatus();
        String runningStatus = planDetails == null ? null : planDetails.getRunningStatus();
        for (XWPFTableRow row : table.getRows()) {
            List<XWPFTableCell> cells = row.getTableCells();
            for (int i = 0; i < cells.size(); i++) {
                String label = normalizeCellLabel(cells.get(i).getText());
                if ("外观情况".equals(label)) {
                    markOptionCells(cells, i + 1, appearanceStatus);
                    continue;
                }
                if ("运行是否正常".equals(label)) {
                    markOptionCells(cells, i + 1, runningStatus);
                    continue;
                }
                if (label.isEmpty() || i + 1 >= cells.size()) {
                    continue;
                }
                XWPFTableCell valueCell = cells.get(i + 1);
                switch (label) {
                    case "仪器名称":
                        setCellText(valueCell, planDetails == null ? recordDto.getDeviceName() : planDetails.getDeviceName(), true);
                        break;
                    case "管理编号":
                    case "设备编号":
                        setCellText(valueCell, planDetails == null ? recordDto.getDeviceNumber() : planDetails.getDeviceNumber(), true);
                        break;
                    case "设备型号":
                        setCellText(valueCell, planDetails == null ? "" : planDetails.getDeviceModel(), true);
                        break;
                    case "检查日期":
                        setCellText(valueCell, checkDate == null ? "" : checkDate, true);
                        break;
                    case "核查人":
                        setCellText(valueCell, checkerName == null ? "" : checkerName, true);
                        break;
                    case "环境温湿度":
                        setCellText(valueCell, planDetails == null ? "" : planDetails.getEnvironment(), true);
                        break;
                    case "核查依据":
                        setCellText(valueCell, planDetails == null ? null : planDetails.getCheckBasis(), false);
                        break;
                    case "核查方式":
                        setCellText(valueCell, planDetails == null ? null : planDetails.getCheckMethod(), false);
                        break;
                    default:
                        break;
                }
            }
        }
        fillEvaluationCell(table, reviewerName, checkDate, appearanceStatus, runningStatus);
    }
    /**
     * 仪器现状三选一:选中项显示☑,其余保持□
     */
    private void markOptionCells(List<XWPFTableCell> cells, int startIndex, String selected) {
        for (int i = startIndex; i < cells.size() && i <= startIndex + 2; i++) {
            String option = normalizeCellLabel(cells.get(i).getText());
            if (!"良好".equals(option) && !"一般".equals(option) && !"损坏".equals(option)
                    && !"正常".equals(option) && !"异常".equals(option) && !"停机".equals(option)) {
                continue;
            }
            setCellText(cells.get(i), option + (option.equals(selected) ? "☑" : "□"), true);
        }
    }
    /**
     * 核查结果评价勾选 + 审核人/时间填写
     */
    private void fillEvaluationCell(XWPFTable table, String reviewerName, String checkDate,
                                    String appearanceStatus, String runningStatus) {
        boolean hasStatus = StringUtils.isNotEmpty(appearanceStatus) || StringUtils.isNotEmpty(runningStatus);
        boolean satisfied = hasStatus && !"损坏".equals(appearanceStatus)
                && !"异常".equals(runningStatus) && !"停机".equals(runningStatus);
        for (XWPFTableRow row : table.getRows()) {
            for (XWPFTableCell cell : row.getTableCells()) {
                for (XWPFParagraph paragraph : cell.getParagraphs()) {
                    String text = paragraph.getText();
                    if (text == null) {
                        continue;
                    }
                    if (text.contains("核查结果评价") && text.contains("满足") && hasStatus) {
                        // 模板里两个复选框依次是“满足”“不满足”
                        markParagraphSymbol(paragraph, satisfied ? 0 : 1);
                    }
                    if (text.contains("审核人")) {
                        rebuildReviewerParagraph(paragraph, reviewerName, checkDate);
                    }
                }
            }
        }
    }
    /**
     * 修改段落里第 targetIndex 个 Wingdings 2 复选框为勾选状态(0x52=☑,0xA3=□)
     */
    private void markParagraphSymbol(XWPFParagraph paragraph, int targetIndex) {
        int index = 0;
        for (XWPFRun run : paragraph.getRuns()) {
            Node runNode = run.getCTR().getDomNode();
            for (Node child = runNode.getFirstChild(); child != null; child = child.getNextSibling()) {
                if (child.getNodeType() == Node.ELEMENT_NODE && "sym".equals(child.getLocalName())) {
                    ((Element) child).setAttributeNS(WORD_NS, "w:char", index == targetIndex ? "0052" : "00A3");
                    index++;
                }
            }
        }
    }
    /**
     * 重写“审核人/时间”行,填入审核人和日期
     */
    private void rebuildReviewerParagraph(XWPFParagraph paragraph, String reviewerName, String checkDate) {
        String text = paragraph.getText();
        int labelIndex = text == null ? -1 : text.indexOf("审核人");
        if (labelIndex < 0) {
            return;
        }
        String prefix = text.substring(0, labelIndex);
        String dateLabel = text.contains("日期") ? "日期" : "时间";
        String newText = prefix + "审核人:" + (reviewerName == null ? "" : reviewerName)
                + "                   " + dateLabel + ":" + (checkDate == null ? "" : checkDate);
        List<XWPFRun> runs = paragraph.getRuns();
        if (runs.isEmpty()) {
            XWPFRun run = paragraph.createRun();
            run.setFontFamily("宋体");
            run.setFontSize(12);
            run.setText(newText);
            return;
        }
        runs.get(0).setText(newText, 0);
        for (int i = runs.size() - 1; i >= 1; i--) {
            paragraph.removeRun(i);
        }
    }
    /**
     * 写入单元格文本;overwriteBlank=false 时保留模板里的固定文字
     */
    private void setCellText(XWPFTableCell cell, String text, boolean overwriteBlank) {
        String value = text == null ? "" : text;
        if (!overwriteBlank && value.trim().isEmpty()) {
            return;
        }
        List<XWPFParagraph> paragraphs = cell.getParagraphs();
        XWPFParagraph paragraph = paragraphs.isEmpty() ? cell.addParagraph() : paragraphs.get(0);
        for (int i = paragraphs.size() - 1; i >= 1; i--) {
            cell.removeParagraph(i);
        }
        List<XWPFRun> runs = paragraph.getRuns();
        if (runs.isEmpty()) {
            XWPFRun run = paragraph.createRun();
            run.setFontFamily("宋体");
            run.setFontSize(12);
            run.setText(value);
            return;
        }
        runs.get(0).setText(value, 0);
        for (int i = runs.size() - 1; i >= 1; i--) {
            paragraph.removeRun(i);
        }
    }
    private String normalizeCellLabel(String text) {
        if (text == null) {
            return "";
        }
        return text.replace(" ", "").replace("\u00A0", "").replace("□", "").replace("☑", "")
                .replace("\n", "").replace("\r", "").trim();
    }
    private String resolveCheckerName(DeviceExaminePlanDetails planDetails, DeviceExamineRecordDto recordDto) {
        if (planDetails != null && StringUtils.isNotBlank(planDetails.getCheckChargerUser())) {
            return planDetails.getCheckChargerUser();
        }
        if (StringUtils.isNotBlank(recordDto.getCheckerUser())) {
            return recordDto.getCheckerUser();
        }
        Integer userId = planDetails != null && planDetails.getCheckChargerUserId() != null
                ? planDetails.getCheckChargerUserId() : recordDto.getCheckerUserId();
        return resolveUserName(userId, null);
    }
    private String resolveUserName(Integer userId, String userName) {
        if (StringUtils.isNotBlank(userName)) {
            return userName;
        }
        if (userId == null) {
            return null;
        }
        User user = userMapper.selectById(userId);
        return user == null ? null : user.getName();
    }
}