cnas-manage/src/main/java/com/ruoyi/manage/service/impl/ClientSatisfactionServiceImpl.java
@@ -1,5 +1,6 @@
package com.ruoyi.manage.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -9,27 +10,49 @@
import com.deepoove.poi.config.ConfigureBuilder;
import com.deepoove.poi.data.Pictures;
import com.ruoyi.common.core.domain.entity.User;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.QueryWrappers;
import com.ruoyi.framework.exception.ErrorException;
import com.ruoyi.manage.dto.ClientSatisfactionDto;
import com.ruoyi.manage.mapper.ClientSatisfactionAnalyseFileMapper;
import com.ruoyi.manage.mapper.ClientSatisfactionAttachmentMapper;
import com.ruoyi.manage.mapper.ClientSatisfactionMapper;
import com.ruoyi.manage.pojo.ClientSatisfaction;
import com.ruoyi.manage.pojo.ClientSatisfactionAnalyseFile;
import com.ruoyi.manage.pojo.ClientSatisfactionAttachment;
import com.ruoyi.manage.service.ClientSatisfactionService;
import com.ruoyi.system.mapper.UserMapper;
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.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
/**
@@ -43,6 +66,9 @@
    @Resource
    private ClientSatisfactionAnalyseFileMapper clientSatisfactionAnalyseFileMapper;
    @Resource
    private ClientSatisfactionAttachmentMapper clientSatisfactionAttachmentMapper;
    @Resource
    private UserMapper userMapper;
@@ -169,6 +195,182 @@
    }
    /**
     * 客户满意度年度汇总导出
     *
     * @param year     汇总年度
     * @param response
     */
    @Override
    public void exportAnnualSummary(Integer year, HttpServletResponse response) {
        int summaryYear = year == null ? LocalDate.now().getYear() : year;
        List<ClientSatisfaction> list = baseMapper.selectList(new QueryWrapper<ClientSatisfaction>().lambda()
                .ge(ClientSatisfaction::getFillDate, LocalDate.of(summaryYear, 1, 1))
                .le(ClientSatisfaction::getFillDate, LocalDate.of(summaryYear, 12, 31))
                .isNotNull(ClientSatisfaction::getTotalScore)
                .orderByAsc(ClientSatisfaction::getFillDate)
                .orderByAsc(ClientSatisfaction::getClientSatisfactionId));
        if (list.isEmpty()) {
            throw new ErrorException(summaryYear + "年度暂无满意度记录");
        }
        InputStream inputStream = this.getClass().getResourceAsStream("/static/customer-satisfaction-annual-summary.docx");
        if (inputStream == null) {
            throw new ErrorException("年度汇总模板不存在");
        }
        try (InputStream is = inputStream) {
            XWPFDocument loaded = new XWPFDocument(is);
            XWPFDocument document = fillAnnualSummaryTable(loaded, summaryYear, list);
            try (XWPFDocument doc = document) {
                response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
                String fileName = URLEncoder.encode("客户满意度年度汇总表_" + summaryYear + "年.docx", "UTF-8");
                response.setHeader("Content-disposition", "attachment;filename=" + fileName);
                try (OutputStream os = response.getOutputStream()) {
                    doc.write(os);
                    os.flush();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException("导出失败");
        }
    }
    /**
     * 按年度汇总模板填充明细与统计数据
     */
    private XWPFDocument fillAnnualSummaryTable(XWPFDocument document, int year, List<ClientSatisfaction> list) throws IOException {
        XWPFTable table = document.getTables().get(0);
        // 模板固定每页 17 行明细:不足不删空行,超过则另起一页,重复标题与表头后继续填
        int dataStart = 3;
        int templateRows = 17;
        int total = list.size();
        int pages = Math.max(1, (total + templateRows - 1) / templateRows);
        if (pages > 1) {
            // 在 XML 层插入续页行;插入后需重新加载文档,POI 的行/单元格包装类才会刷新
            CTTbl ctTbl = table.getCTTbl();
            CTRow titleRow = table.getRow(0).getCtRow();
            CTRow headerRow1 = table.getRow(1).getCtRow();
            CTRow headerRow2 = table.getRow(2).getCtRow();
            CTRow dataRowSample = table.getRow(dataStart + templateRows - 1).getCtRow();
            int insertPos = dataStart + templateRows;
            for (int page = 1; page < pages; page++) {
                CTRow newTitleRow = ctTbl.insertNewTr(insertPos++);
                newTitleRow.set(titleRow.copy());
                removeBookmarks(newTitleRow);
                pageBreakBefore(newTitleRow);
                CTRow newHeader1 = ctTbl.insertNewTr(insertPos++);
                newHeader1.set(headerRow1.copy());
                CTRow newHeader2 = ctTbl.insertNewTr(insertPos++);
                newHeader2.set(headerRow2.copy());
                for (int r = 0; r < templateRows; r++) {
                    CTRow newRow = ctTbl.insertNewTr(insertPos++);
                    newRow.set(dataRowSample.copy());
                }
            }
            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
            document.write(buffer);
            document.close();
            document = new XWPFDocument(new ByteArrayInputStream(buffer.toByteArray()));
            table = document.getTables().get(0);
        }
        setCellText(table.getRow(0).getCell(0), "汇总年度:" + year + "年");
        for (int page = 1; page < pages; page++) {
            setCellText(table.getRow(page * (templateRows + 3)).getCell(0), "汇总年度:" + year + "年");
        }
        int satisfied = 0;
        for (int i = 0; i < total; i++) {
            ClientSatisfaction item = list.get(i);
            boolean isSatisfied = item.getSatisfactionResult() != null
                    ? item.getSatisfactionResult() == 0
                    : item.getTotalScore() != null && item.getTotalScore() >= 80;
            if (isSatisfied) {
                satisfied++;
            }
            int pageIndex = i / templateRows;
            int indexInPage = i % templateRows;
            XWPFTableRow row = table.getRow(dataStart + pageIndex * (templateRows + 3) + indexInPage);
            setCellText(row.getCell(0), String.valueOf(i + 1));
            setCellText(row.getCell(1), item.getUnitName());
            setCellText(row.getCell(2), isSatisfied ? "√" : "");
            setCellText(row.getCell(3), isSatisfied ? "" : "√");
            String reason = StringUtils.isNotBlank(item.getLabOpinion()) ? item.getLabOpinion() : item.getRemark();
            setCellText(row.getCell(4), isSatisfied ? "" : reason);
        }
        XWPFTableRow statRow = table.getRow(dataStart + (pages - 1) * (templateRows + 3) + templateRows + 2);
        setCellText(statRow.getCell(0), "共 " + total + " 份");
        setCellText(statRow.getCell(1), "共 " + satisfied + " 份");
        setCellText(statRow.getCell(2), "共 " + (total - satisfied) + " 份");
        String percent = String.format(Locale.ROOT, "%.1f", satisfied * 100.0 / total);
        if (percent.endsWith(".0")) {
            percent = percent.substring(0, percent.length() - 2);
        }
        setCellText(statRow.getCell(3), percent + "%");
        return document;
    }
    /**
     * 续页首行设置段前分页,保证每页格子数固定
     */
    private void pageBreakBefore(CTRow row) {
        if (row.sizeOfTcArray() == 0) {
            return;
        }
        CTTc cell = row.getTcArray(0);
        if (cell.sizeOfPArray() == 0) {
            return;
        }
        CTP paragraph = cell.getPArray(0);
        CTPPr pPr = paragraph.isSetPPr() ? paragraph.getPPr() : paragraph.addNewPPr();
        if (!pPr.isSetPageBreakBefore()) {
            pPr.addNewPageBreakBefore();
        }
    }
    /**
     * 删除克隆行内的书签,避免文档出现重复书签
     */
    private void removeBookmarks(CTRow row) {
        removeBookmarkNodes(row.getDomNode());
    }
    private void removeBookmarkNodes(Node node) {
        if (node.getNodeType() == Node.ELEMENT_NODE
                && ("bookmarkStart".equals(node.getLocalName()) || "bookmarkEnd".equals(node.getLocalName()))) {
            Node parent = node.getParentNode();
            if (parent != null) {
                parent.removeChild(node);
            }
            return;
        }
        NodeList children = node.getChildNodes();
        for (int i = children.getLength() - 1; i >= 0; i--) {
            removeBookmarkNodes(children.item(i));
        }
    }
    /**
     * 填写单元格文本,保留原有段落格式
     */
    private void setCellText(XWPFTableCell cell, String text) {
        while (cell.getParagraphs().size() > 1) {
            cell.removeParagraph(cell.getParagraphs().size() - 1);
        }
        XWPFParagraph paragraph = cell.getParagraphs().get(0);
        for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
            paragraph.removeRun(i);
        }
        XWPFRun run = paragraph.createRun();
        run.setText(text == null ? "" : text);
        run.setFontFamily("宋体");
        run.setFontFamily("宋体", XWPFRun.FontCharRange.eastAsia);
        run.setFontSize(10.5);
        cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
    }
    /**
     * 确认客户满意度
     *
     * @param clientSatisfaction 要修改客户满意度的状态对象
@@ -184,5 +386,76 @@
        clientSatisfaction.setConfirmStatus(1);
        baseMapper.updateById(clientSatisfaction);
    }
    /**
     * 查询满意度附件
     *
     * @param clientSatisfactionId 满意度记录id
     * @return
     */
    @Override
    public List<ClientSatisfactionAttachment> selectAttachments(Integer clientSatisfactionId) {
        return clientSatisfactionAttachmentMapper.selectList(new QueryWrapper<ClientSatisfactionAttachment>().lambda()
                .eq(ClientSatisfactionAttachment::getClientSatisfactionId, clientSatisfactionId)
                .orderByAsc(ClientSatisfactionAttachment::getSort, ClientSatisfactionAttachment::getId));
    }
    /**
     * 上传满意度附件
     *
     * @param clientSatisfactionId 满意度记录id
     * @param file
     * @return
     */
    @Override
    public ClientSatisfactionAttachment uploadAttachment(Integer clientSatisfactionId, MultipartFile file) {
        if (baseMapper.selectCount(new QueryWrapper<ClientSatisfaction>()
                .eq("client_satisfaction_id", clientSatisfactionId)) == 0) {
            throw new BaseException("客户满意度调查不存在");
        }
        if (file == null || file.isEmpty()) {
            throw new BaseException("附件不能为空");
        }
        // 系统全局上传上限为200MB,这里提前给出友好提示
        if (file.getSize() > 200L * 1024 * 1024) {
            throw new BaseException("附件不能超过200MB");
        }
        String originalName = file.getOriginalFilename();
        if (originalName == null || originalName.trim().isEmpty()) {
            originalName = "未命名文件";
        }
        // 个别浏览器可能带上路径前缀,只保留文件名本身
        originalName = originalName.replaceAll(".*[/\\\\]", "");
        try {
            // 图片放图片目录(/img 静态路径),其余任意格式放 word 目录(/word 静态路径)
            boolean image = originalName.matches("(?i).+\\.(jpg|jpeg|png|gif)$");
            File directory = new File(image ? imgUrl : wordUrl);
            if (!directory.exists()) {
                directory.mkdirs();
            }
            String fileUrl = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMMddHHmmssSSS")) + "-" + originalName;
            file.transferTo(new File(directory, fileUrl));
            ClientSatisfactionAttachment attachment = new ClientSatisfactionAttachment();
            attachment.setClientSatisfactionId(clientSatisfactionId);
            attachment.setFileName(originalName);
            attachment.setFileUrl(fileUrl);
            attachment.setSort(selectAttachments(clientSatisfactionId).size());
            clientSatisfactionAttachmentMapper.insert(attachment);
            return attachment;
        } catch (IOException e) {
            throw new BaseException("附件上传失败");
        }
    }
    /**
     * 删除满意度附件
     *
     * @param id 附件id
     * @return
     */
    @Override
    public boolean deleteAttachment(Long id) {
        return clientSatisfactionAttachmentMapper.deleteById(id) > 0;
    }
}