瀏覽代碼

下载报告

Gqingci 5 天之前
父節點
當前提交
9a69f2719a

+ 10 - 0
ruoyi-modules/ruoyi-main/pom.xml

@@ -80,6 +80,16 @@
             <artifactId>pdfbox</artifactId>
             <version>3.0.4</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+            <version>5.2.5</version>
+        </dependency>
+        <dependency>
+            <groupId>fr.opensagres.xdocreport</groupId>
+            <artifactId>fr.opensagres.poi.xwpf.converter.pdf</artifactId>
+            <version>2.0.4</version>
+        </dependency>
 
     </dependencies>
 

+ 86 - 226
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/MainBackOrderServiceImpl.java

@@ -6,11 +6,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.PDPage;
-import org.apache.pdfbox.pdmodel.PDPageContentStream;
-import org.apache.pdfbox.pdmodel.common.PDRectangle;
-import org.apache.pdfbox.pdmodel.font.PDType0Font;
 import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.MapstructUtils;
 import org.dromara.common.core.utils.StringUtils;
@@ -60,11 +55,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
 import java.math.BigDecimal;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
@@ -86,10 +77,7 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     private static final String RECORD_STATUS_AUTHORIZED = "已授权";
     private static final String RECORD_STATUS_UNAUTHORIZED = "未授权";
     private static final DateTimeFormatter REPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
-    private static final float REPORT_MARGIN = 52F;
-    private static final float REPORT_TITLE_SIZE = 18F;
-    private static final float REPORT_HEADING_SIZE = 13F;
-    private static final float REPORT_BODY_SIZE = 10.5F;
+
     private static final Set<String> AUTHORIZATION_TERMINAL_STATUSES =
         new HashSet<>(List.of(RECORD_STATUS_AUTHORIZED, RECORD_STATUS_UNAUTHORIZED));
 
@@ -108,6 +96,7 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     private final SysTenantMapper sysTenantMapper;
     private final ISysTenantService tenantService;
     private final ICompanyAccountService companyAccountService;
+    private final WordReportService wordReportService;
 
     @Override
     public TableDataInfo<MainBackOrderVo> queryPageList(MainBackOrderBo bo, PageQuery pageQuery) {
@@ -886,78 +875,90 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     }
 
     private byte[] buildReportPdf(ReportContext context) {
-        try (PDDocument document = new PDDocument();
-             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-            PDType0Font font = loadChineseFont(document);
-            ReportWriter writer = new ReportWriter(document, font);
-
-            writer.addTitle("背景调查报告");
-            writer.addKeyValueSection("候选人基本信息", List.of(
-                "姓名:" + safe(context.student == null ? null : context.student.getName()),
-                "应聘职位:" + safe(resolvePositionName(context)),
-                "调查时间:" + safe(resolveSurveyTime(context))
-            ));
-            writer.addKeyValueSection("学历核实", List.of(
-                "毕业院校:" + safe(resolveSchoolName(context)),
-                "学历证书编号:" + "",
-                "学信网核实:" + safe(resolveEducationVerification(context))
-            ));
-            writer.addKeyValueSection("最近工作单位(上家公司)", List.of(
-                "公司名称:" + safe(context.experience == null ? null : context.experience.getCompany()),
-                "岗位名称:" + safe(resolveLatestJobTitle(context)),
-                "在职时间:" + safe(resolveEmploymentPeriod(context)),
-                "任职状态:" + safe(resolveEmploymentStatus(context)),
-                "离职原因:" + safe(context.review == null ? null : context.review.getLeaveReason())
-            ));
-            writer.addParagraphSection("工作职责/信息来源", safe(resolveWorkContent(context)));
-            writer.addParagraphSection("综合评价", safe(context.review == null ? null : context.review.getTotalRemark()));
-            writer.addRatingSection("能力评价", List.of(
-                buildAbilityLine(context.review == null ? null : context.review.getAbilityAName(),
-                    context.review == null ? null : context.review.getAbilityARate(),
-                    context.review == null ? null : context.review.getAbilityARemark()),
-                buildAbilityLine(context.review == null ? null : context.review.getAbilityBName(),
-                    context.review == null ? null : context.review.getAbilityBRate(),
-                    context.review == null ? null : context.review.getAbilityBRemark()),
-                buildAbilityLine(context.review == null ? null : context.review.getAbilityCName(),
-                    context.review == null ? null : context.review.getAbilityCRate(),
-                    context.review == null ? null : context.review.getAbilityCRemark())
-            ));
-            writer.addQuestionAnswerSection("模板问答整理", List.of(
-                new QaLine("请问你和候选人是什么时候认识的?他的职务是什么?具体工作职责有哪些方面?",
-                    safe(resolveKnownAnswer(context))),
-                new QaLine("你觉得他在工作方面有哪些优势?比较擅长哪方面的工作?", ""),
-                new QaLine("他在职业道德方面的表现怎么样?你有没有看到或听到关于他不太好的现象和评价?", ""),
-                new QaLine("请问他人际关系怎么样?和其他同事相处有没有问题?", ""),
-                new QaLine("你对他的总体评价如何?你觉得他哪些方面需要提高和改进呢?",
-                    safe(context.review == null ? null : context.review.getTotalRemark()))
-            ));
-            writer.addParagraphSection("备注", safe(context.companyName));
-            writer.close();
-
-            document.save(outputStream);
-            return outputStream.toByteArray();
-        } catch (IOException e) {
-            throw new ServiceException("生成报告PDF失败: " + e.getMessage());
-        }
-    }
-
-    private PDType0Font loadChineseFont(PDDocument document) throws IOException {
-        List<String> fontPaths = List.of(
-            "C:/Windows/Fonts/msyh.ttc",
-            "C:/Windows/Fonts/simsun.ttc",
-            "C:/Windows/Fonts/simhei.ttf"
-        );
-        for (String fontPath : fontPaths) {
-            Path path = Path.of(fontPath);
-            if (Files.exists(path)) {
-                try {
-                    return PDType0Font.load(document, Files.newInputStream(path), true);
-                } catch (IOException ignored) {
-                    // 继续尝试下一个可用字体
-                }
-            }
-        }
-        throw new ServiceException("未找到可用的中文字体文件,无法生成报告PDF");
+        Map<String, String> placeholders = new LinkedHashMap<>();
+
+        // 候选人基本信息
+        placeholders.put("candidateName", safe(context.student == null ? null : context.student.getName()));
+        placeholders.put("position", safe(resolvePositionName(context)));
+        placeholders.put("surveyTime", safe(resolveSurveyTime(context)));
+
+        // 学历核实
+        placeholders.put("school", safe(resolveSchoolName(context)));
+        placeholders.put("certNo", " ");
+        placeholders.put("eduVerification", safe(resolveEducationVerification(context)));
+
+        // 最近工作单位
+        placeholders.put("company", safe(context.experience == null ? null : context.experience.getCompany()));
+        placeholders.put("employmentPeriod", safe(resolveEmploymentPeriod(context)));
+        placeholders.put("jobTitle", safe(resolveLatestJobTitle(context)));
+        placeholders.put("lastSalary", " ");
+        placeholders.put("leaveReason", safe(context.review == null ? null : context.review.getLeaveReason()));
+
+        // 工作表现评估
+        placeholders.put("supervisorEval", " ");
+        placeholders.put("strength", safe(resolveWorkContent(context)));
+        placeholders.put("improvement", " ");
+        placeholders.put("professionalAbility",
+            safe(buildAbilityLine(context.review == null ? null : context.review.getAbilityAName(),
+                context.review == null ? null : context.review.getAbilityARate(),
+                context.review == null ? null : context.review.getAbilityARemark())));
+        placeholders.put("workAttitude",
+            safe(buildAbilityLine(context.review == null ? null : context.review.getAbilityBName(),
+                context.review == null ? null : context.review.getAbilityBRate(),
+                context.review == null ? null : context.review.getAbilityBRemark())));
+        placeholders.put("teamwork",
+            safe(buildAbilityLine(context.review == null ? null : context.review.getAbilityCName(),
+                context.review == null ? null : context.review.getAbilityCRate(),
+                context.review == null ? null : context.review.getAbilityCRemark())));
+        placeholders.put("ethics", " ");
+        placeholders.put("colleagueEval", " ");
+        placeholders.put("cooperation", " ");
+        placeholders.put("colleagueAbility", " ");
+        placeholders.put("hrEval", " ");
+        placeholders.put("violation", " ");
+        placeholders.put("handover", " ");
+
+        // 调查结论
+        placeholders.put("conclusionReason", safe(context.review == null ? null : context.review.getTotalRemark()));
+
+        // 调查人 / 日期
+        placeholders.put("investigator", safe(context.companyName));
+        placeholders.put("investigateDate", safe(resolveSurveyTime(context)));
+
+        // 访谈记录(一)—— 使用上家公司数据
+        placeholders.put("interviewee1Name", " ");
+        placeholders.put("interviewee1Relation", "上级主管");
+        placeholders.put("interviewee1Contact", " ");
+        placeholders.put("qa1_a1", safe(resolveKnownAnswer(context)));
+        placeholders.put("qa1_a2", " ");
+        placeholders.put("qa1_a3", " ");
+        placeholders.put("qa1_a4", " ");
+        placeholders.put("qa1_a5", safe(context.review == null ? null : context.review.getTotalRemark()));
+
+        // 访谈记录(二)
+        placeholders.put("interviewee2Name", " ");
+        placeholders.put("interviewee2Relation", "公司HR");
+        placeholders.put("interviewee2Contact", " ");
+        placeholders.put("qa2_a1", " ");
+        placeholders.put("qa2_a2", " ");
+        placeholders.put("qa2_a3", " ");
+        placeholders.put("qa2_a4", " ");
+        placeholders.put("qa2_a5", " ");
+
+        // 访谈记录(三)
+        placeholders.put("interviewee3Name", " ");
+        placeholders.put("interviewee3Relation", "同事");
+        placeholders.put("interviewee3Contact", " ");
+        placeholders.put("qa3_a1", " ");
+        placeholders.put("qa3_a2", " ");
+        placeholders.put("qa3_a3", " ");
+        placeholders.put("qa3_a4", " ");
+        placeholders.put("qa3_a5", " ");
+
+        // 确保所有空值替换为空格,保证模板格式不被破坏
+        placeholders.replaceAll((k, v) -> (v == null || v.isEmpty()) ? " " : v);
+
+        return wordReportService.generatePdf(placeholders);
     }
 
     private String uploadReportPdf(MainBackRecord record, ReportContext context, byte[] pdfBytes) {
@@ -1085,147 +1086,6 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         private MainBackOrder backOrder;
         private String companyName;
     }
-
-    private static class QaLine {
-        private final String question;
-        private final String answer;
-
-        private QaLine(String question, String answer) {
-            this.question = question;
-            this.answer = answer;
-        }
-    }
-
-    private static class ReportWriter {
-        private final PDDocument document;
-        private final PDType0Font font;
-        private PDPage page;
-        private PDPageContentStream stream;
-        private float y;
-
-        private ReportWriter(PDDocument document, PDType0Font font) throws IOException {
-            this.document = document;
-            this.font = font;
-            newPage();
-        }
-
-        private void addTitle(String title) throws IOException {
-            writeWrapped(title, REPORT_TITLE_SIZE, true, 1.2F, 10F);
-            y -= 8F;
-        }
-
-        private void addKeyValueSection(String heading, List<String> lines) throws IOException {
-            addHeading(heading);
-            for (String line : lines) {
-                writeWrapped(line, REPORT_BODY_SIZE, false, 1.5F, 4F);
-            }
-            y -= 4F;
-        }
-
-        private void addParagraphSection(String heading, String content) throws IOException {
-            addHeading(heading);
-            writeWrapped(StringUtils.isBlank(content) ? "" : content, REPORT_BODY_SIZE, false, 1.6F, 6F);
-            y -= 4F;
-        }
-
-        private void addRatingSection(String heading, List<String> lines) throws IOException {
-            addHeading(heading);
-            for (String line : lines) {
-                if (StringUtils.isBlank(line)) {
-                    continue;
-                }
-                writeWrapped("• " + line, REPORT_BODY_SIZE, false, 1.6F, 4F);
-            }
-            y -= 4F;
-        }
-
-        private void addQuestionAnswerSection(String heading, List<QaLine> lines) throws IOException {
-            addHeading(heading);
-            for (QaLine line : lines) {
-                writeWrapped("问:" + safeLine(line.question), REPORT_BODY_SIZE, true, 1.5F, 2F);
-                writeWrapped("答:" + safeLine(line.answer), REPORT_BODY_SIZE, false, 1.6F, 6F);
-            }
-        }
-
-        private void addHeading(String heading) throws IOException {
-            ensureSpace(REPORT_HEADING_SIZE * 2.2F);
-            writeWrapped(heading, REPORT_HEADING_SIZE, true, 1.3F, 6F);
-        }
-
-        private void writeWrapped(String text, float fontSize, boolean bold, float lineFactor, float bottomGap) throws IOException {
-            List<String> lines = wrapText(safeLine(text), fontSize);
-            if (lines.isEmpty()) {
-                lines = List.of("");
-            }
-            float lineHeight = fontSize * lineFactor;
-            ensureSpace(lineHeight * lines.size() + bottomGap);
-            for (String line : lines) {
-                stream.beginText();
-                stream.setFont(font, fontSize);
-                stream.newLineAtOffset(REPORT_MARGIN, y);
-                stream.showText(line);
-                stream.endText();
-                y -= lineHeight;
-            }
-            y -= bottomGap;
-        }
-
-        private List<String> wrapText(String text, float fontSize) throws IOException {
-            float width = PDRectangle.A4.getWidth() - REPORT_MARGIN * 2;
-            List<String> lines = new ArrayList<>();
-            if (StringUtils.isBlank(text)) {
-                lines.add("");
-                return lines;
-            }
-            StringBuilder current = new StringBuilder();
-            for (char ch : text.toCharArray()) {
-                if (ch == '\n') {
-                    lines.add(current.toString());
-                    current.setLength(0);
-                    continue;
-                }
-                String candidate = current.toString() + ch;
-                float candidateWidth = font.getStringWidth(candidate) / 1000 * fontSize;
-                if (candidateWidth > width && current.length() > 0) {
-                    lines.add(current.toString());
-                    current.setLength(0);
-                }
-                current.append(ch);
-            }
-            if (!current.isEmpty()) {
-                lines.add(current.toString());
-            }
-            return lines;
-        }
-
-        private void ensureSpace(float neededHeight) throws IOException {
-            if (y - neededHeight < REPORT_MARGIN) {
-                newPage();
-            }
-        }
-
-        private void newPage() throws IOException {
-            closeCurrentStream();
-            page = new PDPage(PDRectangle.A4);
-            document.addPage(page);
-            stream = new PDPageContentStream(document, page);
-            y = PDRectangle.A4.getHeight() - REPORT_MARGIN;
-        }
-
-        private void closeCurrentStream() throws IOException {
-            if (stream != null) {
-                stream.close();
-            }
-        }
-
-        private void close() throws IOException {
-            closeCurrentStream();
-        }
-
-        private String safeLine(String value) {
-            return value == null ? "" : value.replace('\t', ' ');
-        }
-    }
 }
 
 

+ 150 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/WordReportService.java

@@ -0,0 +1,150 @@
+package org.dromara.main.service.impl;
+
+import org.apache.poi.xwpf.usermodel.*;
+import org.dromara.common.core.exception.ServiceException;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+import fr.opensagres.poi.xwpf.converter.pdf.PdfConverter;
+import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Word 模板报告服务。
+ * <p>
+ * 从 classpath 加载 Word 模板 (templates/report_template.docx),
+ * 将 {{placeholder}} 占位符替换为实际值,然后转换为 PDF 字节数组。
+ */
+@Component
+public class WordReportService {
+
+    private static final String TEMPLATE_PATH = "templates/report_template.docx";
+    private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
+
+    /**
+     * 根据占位符映射生成 PDF。
+     *
+     * @param placeholders key=占位符名称(不含花括号),value=要填入的值
+     * @return PDF 文件字节数组
+     */
+    public byte[] generatePdf(Map<String, String> placeholders) {
+        try (InputStream templateStream = new ClassPathResource(TEMPLATE_PATH).getInputStream();
+             XWPFDocument document = new XWPFDocument(templateStream);
+             ByteArrayOutputStream pdfOut = new ByteArrayOutputStream()) {
+
+            // 1. 替换文档正文段落中的占位符
+            for (XWPFParagraph paragraph : document.getParagraphs()) {
+                replacePlaceholdersInParagraph(paragraph, placeholders);
+            }
+
+            // 2. 替换所有表格中的占位符
+            for (XWPFTable table : document.getTables()) {
+                for (XWPFTableRow row : table.getRows()) {
+                    for (XWPFTableCell cell : row.getTableCells()) {
+                        for (XWPFParagraph paragraph : cell.getParagraphs()) {
+                            replacePlaceholdersInParagraph(paragraph, placeholders);
+                        }
+                    }
+                }
+            }
+
+            // 3. 转换为 PDF
+            PdfOptions options = PdfOptions.create();
+            PdfConverter.getInstance().convert(document, pdfOut, options);
+
+            return pdfOut.toByteArray();
+        } catch (Exception e) {
+            throw new ServiceException("生成报告PDF失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 替换段落中的占位符。
+     * <p>
+     * 需要处理占位符可能被拆分到多个 run 中的情况,
+     * 例如 "{{" 在 run1,"name" 在 run2,"}}" 在 run3。
+     */
+    private void replacePlaceholdersInParagraph(XWPFParagraph paragraph, Map<String, String> placeholders) {
+        // 首先尝试快速路径:拼接所有 run 文本,检查是否包含 {{
+        String fullText = paragraph.getText();
+        if (fullText == null || !fullText.contains("{{")) {
+            return;
+        }
+
+        // 收集所有 run 的文本
+        List<XWPFRun> runs = paragraph.getRuns();
+        if (runs == null || runs.isEmpty()) {
+            return;
+        }
+
+        // 构建完整文本和每个字符对应的 run 索引映射
+        StringBuilder sb = new StringBuilder();
+        int[] charToRunIndex = new int[fullText.length() + 100]; // extra buffer
+        int[] charToOffsetInRun = new int[fullText.length() + 100];
+        int globalPos = 0;
+
+        for (int ri = 0; ri < runs.size(); ri++) {
+            String runText = runs.get(ri).getText(0);
+            if (runText == null) {
+                continue;
+            }
+            for (int ci = 0; ci < runText.length(); ci++) {
+                if (globalPos < charToRunIndex.length) {
+                    charToRunIndex[globalPos] = ri;
+                    charToOffsetInRun[globalPos] = ci;
+                }
+                sb.append(runText.charAt(ci));
+                globalPos++;
+            }
+        }
+
+        String concatenated = sb.toString();
+        Matcher matcher = PLACEHOLDER_PATTERN.matcher(concatenated);
+
+        if (!matcher.find()) {
+            return;
+        }
+
+        // 有占位符需要替换 — 使用 run 合并策略
+        // 将所有文本合并到第一个 run,清空其余 run,然后执行替换
+        String replaced = PLACEHOLDER_PATTERN.matcher(concatenated).replaceAll(mr -> {
+            String key = mr.group(1).trim();
+            String value = placeholders.getOrDefault(key, " ");
+            // 如果值为空字符串,填一个空格保证格式
+            return value.isEmpty() ? " " : Matcher.quoteReplacement(value);
+        });
+
+        // 检查替换后文本是否包含换行——如果有,需要分行处理
+        if (replaced.contains("\n")) {
+            // 对于包含换行的情况,将第一行放在当前 run,其余作为新 run
+            String[] lines = replaced.split("\n", -1);
+            // 设置第一个 run 的文本
+            if (!runs.isEmpty()) {
+                runs.get(0).setText(lines[0], 0);
+                // 清空其余 run
+                for (int i = 1; i < runs.size(); i++) {
+                    runs.get(i).setText("", 0);
+                }
+                // 添加剩余行作为额外文本行
+                for (int i = 1; i < lines.length; i++) {
+                    runs.get(0).addBreak();
+                    runs.get(0).setText(lines[i]);
+                }
+            }
+        } else {
+            // 简单情况:将替换后的完整文本设置到第一个 run
+            if (!runs.isEmpty()) {
+                runs.get(0).setText(replaced, 0);
+                // 清空其余 run 的文本
+                for (int i = 1; i < runs.size(); i++) {
+                    runs.get(i).setText("", 0);
+                }
+            }
+        }
+    }
+}

二進制
ruoyi-modules/ruoyi-main/src/main/resources/templates/report_template.docx