Gqingci 6 dienas atpakaļ
vecāks
revīzija
ec1bf1a4f8

+ 3 - 3
ruoyi-admin/src/main/resources/application-dev.yml

@@ -96,8 +96,8 @@ spring:
 spring.data:
   redis:
     # 地址
-    host: localhost
-#    password: 123456
+    host: 192.168.194.130
+    password: 123456
     # 端口,默认为6379
     port: 6379
     # 数据库索引
@@ -284,7 +284,7 @@ tencent:
   map:
     geo:
       # 企业入驻办公地址转经纬度所需的腾讯位置服务 WebService Key
-      key: "LA5BZ-BDQL7-NHIXK-P1H5G-EIVSH-GPBQY"
+      key: "CUVBZ-UZJWB-YD7U2-JI2O3-C7DYT-PCBMI"
 
 manage:
   jumpTo: http://localhost:90/

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

@@ -75,6 +75,11 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-websocket</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+            <version>3.0.4</version>
+        </dependency>
 
     </dependencies>
 

+ 9 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/PortalCheckController.java

@@ -162,6 +162,15 @@ public class PortalCheckController extends BaseController {
         return R.ok(mainBackOrderService.confirmPortalCandidateAuthorization(recordId, tenantId));
     }
 
+    @GetMapping("/order/candidate/{recordId}/report")
+    public R<String> generatePortalCandidateReport(@PathVariable Long recordId) {
+        String tenantId = LoginHelper.getTenantId();
+        if (tenantId == null || tenantId.isBlank()) {
+            return R.fail("未登录或token已失效");
+        }
+        return R.ok("操作成功", mainBackOrderService.generatePortalCandidateReport(recordId, tenantId));
+    }
+
     @PostMapping("/order/create")
     public R<MainBackOrderVo> createOrder(@Validated @RequestBody PortalCheckOrderCreateBo bo) {
         String tenantId = LoginHelper.getTenantId();

+ 50 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainPostCandidateReview.java

@@ -0,0 +1,50 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("main_post_candidate_review")
+public class MainPostCandidateReview extends BaseEntity {
+
+    @TableId(value = "id")
+    private Long id;
+
+    private Long candidateId;
+    private String tenantId;
+    private Long postId;
+    private Long studentId;
+
+    private String employmentStatus;
+    private Date entryTime;
+    private Date leaveTime;
+    private String leaveReason;
+
+    private BigDecimal totalRate;
+    private String totalRemark;
+
+    private String abilityAName;
+    private BigDecimal abilityARate;
+    private String abilityARemark;
+
+    private String abilityBName;
+    private BigDecimal abilityBRate;
+    private String abilityBRemark;
+
+    private String abilityCName;
+    private BigDecimal abilityCRate;
+    private String abilityCRemark;
+
+    private String reviewStatus;
+
+    @TableLogic
+    private String delFlag;
+}

+ 7 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/MainPostCandidateReviewMapper.java

@@ -0,0 +1,7 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.MainPostCandidateReview;
+
+public interface MainPostCandidateReviewMapper extends BaseMapperPlus<MainPostCandidateReview, MainPostCandidateReview> {
+}

+ 5 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/IMainBackOrderService.java

@@ -48,4 +48,9 @@ public interface IMainBackOrderService {
      * 门户打印授权书后确认候选人授权
      */
     MainBackOrderCandidateVo confirmPortalCandidateAuthorization(Long recordId, String tenantId);
+
+    /**
+     * 生成或获取门户候选人报告
+     */
+    String generatePortalCandidateReport(Long recordId, String tenantId);
 }

+ 447 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/MainBackOrderServiceImpl.java

@@ -6,21 +6,33 @@ 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;
 import org.dromara.common.json.utils.JsonUtils;
 import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.oss.core.OssClient;
+import org.dromara.common.oss.entity.UploadResult;
+import org.dromara.common.oss.factory.OssFactory;
 import org.dromara.main.domain.MainBackCandidate;
 import org.dromara.main.domain.MainBackCategory;
 import org.dromara.main.domain.MainBackClause;
 import org.dromara.main.domain.MainBackOrder;
 import org.dromara.main.domain.MainBackRecord;
 import org.dromara.main.domain.MainOrder;
+import org.dromara.main.domain.MainPosition;
+import org.dromara.main.domain.MainPostCandidateReview;
 import org.dromara.main.domain.bo.MainBackOrderBo;
 import org.dromara.main.domain.bo.PortalCheckOrderCreateBo;
 import org.dromara.main.domain.MainStudent;
+import org.dromara.main.domain.MainStudentEducation;
+import org.dromara.main.domain.MainStudentExperience;
 import org.dromara.main.domain.Payment;
 import org.dromara.main.domain.vo.MainBackClauseVo;
 import org.dromara.main.domain.vo.MainBackOrderCandidateVo;
@@ -31,6 +43,10 @@ import org.dromara.main.mapper.MainBackClauseMapper;
 import org.dromara.main.mapper.MainBackOrderMapper;
 import org.dromara.main.mapper.MainBackRecordMapper;
 import org.dromara.main.mapper.MainOrderMapper;
+import org.dromara.main.mapper.MainPositionMapper;
+import org.dromara.main.mapper.MainPostCandidateReviewMapper;
+import org.dromara.main.mapper.MainStudentEducationMapper;
+import org.dromara.main.mapper.MainStudentExperienceMapper;
 import org.dromara.main.mapper.MainStudentMapper;
 import org.dromara.main.mapper.PaymentMapper;
 import org.dromara.main.service.ICompanyAccountService;
@@ -43,8 +59,14 @@ import org.dromara.system.service.ISysTenantService;
 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;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -63,6 +85,11 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     private static final String RECORD_STATUS_PENDING_AUTH = "未完成";
     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));
 
@@ -73,6 +100,10 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     private final MainBackClauseMapper mainBackClauseMapper;
     private final MainBackCandidateMapper mainBackCandidateMapper;
     private final MainStudentMapper mainStudentMapper;
+    private final MainStudentEducationMapper mainStudentEducationMapper;
+    private final MainStudentExperienceMapper mainStudentExperienceMapper;
+    private final MainPostCandidateReviewMapper mainPostCandidateReviewMapper;
+    private final MainPositionMapper mainPositionMapper;
     private final PaymentMapper paymentMapper;
     private final SysTenantMapper sysTenantMapper;
     private final ISysTenantService tenantService;
@@ -516,6 +547,41 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         return buildPortalCandidateVo(record);
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String generatePortalCandidateReport(Long recordId, String tenantId) {
+        if (recordId == null) {
+            throw new ServiceException("候选人记录不能为空");
+        }
+        if (StringUtils.isBlank(tenantId)) {
+            throw new ServiceException("未登录或token已失效");
+        }
+
+        MainBackRecord record = recordMapper.selectById(recordId);
+        if (record == null) {
+            throw new ServiceException("候选人记录不存在");
+        }
+
+        MainBackOrder backOrder = baseMapper.selectById(record.getOrderId());
+        if (backOrder == null || !StringUtils.equals(backOrder.getTenantId(), tenantId)) {
+            throw new ServiceException("无权查看该候选人的报告");
+        }
+
+        if (StringUtils.isNotBlank(record.getReportUrl())) {
+            return record.getReportUrl();
+        }
+
+        ReportContext context = buildReportContext(record, backOrder);
+        byte[] pdfBytes = buildReportPdf(context);
+        String reportUrl = uploadReportPdf(record, context, pdfBytes);
+
+        MainBackRecord update = new MainBackRecord();
+        update.setId(record.getId());
+        update.setReportUrl(reportUrl);
+        recordMapper.updateById(update);
+        return reportUrl;
+    }
+
     private void fillPortalFields(MainBackOrderVo vo, MainOrder mainOrder) {
         Long count = recordMapper.selectCount(
             new LambdaQueryWrapper<MainBackRecord>()
@@ -779,6 +845,387 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
             return defaultValue;
         }
     }
+
+    private ReportContext buildReportContext(MainBackRecord record, MainBackOrder backOrder) {
+        MainBackCandidate candidate = mainBackCandidateMapper.selectById(record.getCandidateId());
+        MainStudent student = candidate == null ? null : mainStudentMapper.selectById(candidate.getStudentId());
+        MainPostCandidateReview review = mainPostCandidateReviewMapper.selectOne(
+            Wrappers.<MainPostCandidateReview>lambdaQuery()
+                .eq(MainPostCandidateReview::getCandidateId, record.getCandidateId())
+                .last("limit 1")
+        );
+        MainStudentEducation education = candidate == null ? null : mainStudentEducationMapper.selectOne(
+            Wrappers.<MainStudentEducation>lambdaQuery()
+                .eq(MainStudentEducation::getStudentId, candidate.getStudentId())
+                .orderByDesc(MainStudentEducation::getEndTime)
+                .orderByDesc(MainStudentEducation::getCreateTime)
+                .last("limit 1")
+        );
+        MainStudentExperience experience = candidate == null ? null : mainStudentExperienceMapper.selectOne(
+            Wrappers.<MainStudentExperience>lambdaQuery()
+                .eq(MainStudentExperience::getStudentId, candidate.getStudentId())
+                .orderByDesc(MainStudentExperience::getEndTime)
+                .orderByDesc(MainStudentExperience::getCreateTime)
+                .last("limit 1")
+        );
+
+        Long postId = review != null && review.getPostId() != null ? review.getPostId() : candidate == null ? null : candidate.getPostId();
+        MainPosition position = postId == null ? null : mainPositionMapper.selectById(postId);
+        SysTenantVo tenant = tenantService.queryByTenantId(backOrder.getTenantId());
+
+        ReportContext context = new ReportContext();
+        context.student = student;
+        context.review = review;
+        context.education = education;
+        context.experience = experience;
+        context.position = position;
+        context.record = record;
+        context.backOrder = backOrder;
+        context.companyName = tenant == null ? null : tenant.getCompanyName();
+        return context;
+    }
+
+    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");
+    }
+
+    private String uploadReportPdf(MainBackRecord record, ReportContext context, byte[] pdfBytes) {
+        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(pdfBytes)) {
+            OssClient ossClient = OssFactory.instance();
+            String key = "report/check/" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM"))
+                + "/" + record.getId() + "-" + sanitizeFileName(safe(context.student == null ? null : context.student.getName()), "candidate")
+                + ".pdf";
+            UploadResult uploadResult = ossClient.upload(inputStream, key, (long) pdfBytes.length, "application/pdf");
+            return uploadResult.getUrl();
+        } catch (Exception e) {
+            throw new ServiceException("上传报告PDF失败: " + e.getMessage());
+        }
+    }
+
+    private String resolvePositionName(ReportContext context) {
+        if (context.position != null && StringUtils.isNotBlank(context.position.getPostName())) {
+            return context.position.getPostName();
+        }
+        return resolveLatestJobTitle(context);
+    }
+
+    private String resolveLatestJobTitle(ReportContext context) {
+        return context.experience == null ? null : context.experience.getJobTitle();
+    }
+
+    private String resolveSurveyTime(ReportContext context) {
+        if (context.record.getFinishTime() != null) {
+            return context.record.getFinishTime().format(REPORT_TIME_FORMATTER);
+        }
+        if (context.record.getCreateTime() != null) {
+            return context.record.getCreateTime().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()
+                .format(REPORT_TIME_FORMATTER);
+        }
+        return LocalDateTime.now().format(REPORT_TIME_FORMATTER);
+    }
+
+    private String resolveSchoolName(ReportContext context) {
+        if (context.education != null && StringUtils.isNotBlank(context.education.getSchool())) {
+            return context.education.getSchool();
+        }
+        return context.student == null ? null : context.student.getSchoolName();
+    }
+
+    private String resolveEducationVerification(ReportContext context) {
+        return "";
+    }
+
+    private String resolveEmploymentPeriod(ReportContext context) {
+        if (context.review != null && (context.review.getEntryTime() != null || context.review.getLeaveTime() != null)) {
+            return joinPeriod(
+                context.review.getEntryTime() == null ? null : cn.hutool.core.date.DateUtil.format(context.review.getEntryTime(), "yyyy-MM-dd"),
+                context.review.getLeaveTime() == null ? null : cn.hutool.core.date.DateUtil.format(context.review.getLeaveTime(), "yyyy-MM-dd")
+            );
+        }
+        if (context.experience != null) {
+            return joinPeriod(context.experience.getStartTime(), context.experience.getEndTime());
+        }
+        return "";
+    }
+
+    private String joinPeriod(String start, String end) {
+        if (StringUtils.isBlank(start) && StringUtils.isBlank(end)) {
+            return "";
+        }
+        return safe(start) + " 至 " + safe(end);
+    }
+
+    private String resolveEmploymentStatus(ReportContext context) {
+        if (context.review == null || StringUtils.isBlank(context.review.getEmploymentStatus())) {
+            return "";
+        }
+        return "left".equalsIgnoreCase(context.review.getEmploymentStatus()) ? "已离职" : "在职";
+    }
+
+    private String resolveWorkContent(ReportContext context) {
+        return context.experience == null ? null : context.experience.getWorkContent();
+    }
+
+    private String resolveKnownAnswer(ReportContext context) {
+        List<String> parts = new ArrayList<>();
+        if (context.experience != null && StringUtils.isNotBlank(context.experience.getStartTime())) {
+            parts.add("可确认最晚于 " + context.experience.getStartTime() + " 已在前司任职");
+        }
+        if (StringUtils.isNotBlank(resolveLatestJobTitle(context))) {
+            parts.add("岗位为" + resolveLatestJobTitle(context));
+        }
+        if (StringUtils.isNotBlank(resolveWorkContent(context))) {
+            parts.add("工作职责包括:" + resolveWorkContent(context));
+        }
+        return String.join(";", parts);
+    }
+
+    private String buildAbilityLine(String name, BigDecimal rate, String remark) {
+        if (StringUtils.isBlank(name) && rate == null && StringUtils.isBlank(remark)) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append(safe(name));
+        if (rate != null) {
+            sb.append("(").append(rate.stripTrailingZeros().toPlainString()).append("分)");
+        }
+        if (StringUtils.isNotBlank(remark)) {
+            sb.append(":").append(remark);
+        }
+        return sb.toString();
+    }
+
+    private String safe(String value) {
+        return value == null ? "" : value;
+    }
+
+    private String sanitizeFileName(String value, String fallback) {
+        String source = StringUtils.isBlank(value) ? fallback : value;
+        return source.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
+    }
+
+    private static class ReportContext {
+        private MainStudent student;
+        private MainPostCandidateReview review;
+        private MainStudentEducation education;
+        private MainStudentExperience experience;
+        private MainPosition position;
+        private MainBackRecord record;
+        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', ' ');
+        }
+    }
 }
 
 

BIN
背景调查报告-供下家参考.docx


BIN
背景调查表-上家填写-copy.docx


BIN
背景调查表-上家填写.docx