diff --git a/ccdi-project/pom.xml b/ccdi-project/pom.xml index 77febc35..37eb6a99 100644 --- a/ccdi-project/pom.xml +++ b/ccdi-project/pom.xml @@ -49,6 +49,12 @@ easyexcel + + + org.apache.pdfbox + pdfbox + + org.springframework.boot diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java index 605fe824..2bbc9e4d 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java @@ -29,6 +29,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -181,4 +182,14 @@ public class CcdiProjectOverviewController extends BaseController { public void exportRiskDetails(HttpServletResponse response, Long projectId) { overviewService.exportRiskDetails(response, projectId); } + + /** + * 一键导出结果总览报告 + */ + @RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST }) + @Operation(summary = "一键导出结果总览报告") + @PreAuthorize("@ss.hasPermi('ccdi:project:query')") + public void exportOverviewReport(HttpServletResponse response, Long projectId) { + overviewService.exportOverviewReport(response, projectId); + } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportModelSummaryVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportModelSummaryVO.java new file mode 100644 index 00000000..792cef84 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportModelSummaryVO.java @@ -0,0 +1,20 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import lombok.Data; + +/** + * 结果总览报告风险模型汇总 + */ +@Data +public class CcdiProjectOverviewReportModelSummaryVO { + + private String modelCode; + + private String modelName; + + private Integer warningCount; + + private Integer peopleCount; + + private String peopleNames; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportParamVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportParamVO.java new file mode 100644 index 00000000..4fce77d3 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportParamVO.java @@ -0,0 +1,20 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import lombok.Data; + +/** + * 结果总览报告参数配置项 + */ +@Data +public class CcdiProjectOverviewReportParamVO { + + private String modelName; + + private String paramName; + + private String paramValue; + + private String paramUnit; + + private String paramDesc; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportSuspiciousTransactionVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportSuspiciousTransactionVO.java new file mode 100644 index 00000000..44d28923 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportSuspiciousTransactionVO.java @@ -0,0 +1,35 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import java.math.BigDecimal; +import lombok.Data; + +/** + * 结果总览报告涉疑交易明细 + */ +@Data +public class CcdiProjectOverviewReportSuspiciousTransactionVO { + + private Long bankStatementId; + + private String trxDate; + + private String leAccountNo; + + private String leAccountName; + + private String customerAccountName; + + private String customerAccountNo; + + private String relatedStaffName; + + private String relatedStaffCode; + + private String userMemo; + + private String cashType; + + private String hitTags; + + private BigDecimal displayAmount; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportUploadSubjectVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportUploadSubjectVO.java new file mode 100644 index 00000000..98da9f5b --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportUploadSubjectVO.java @@ -0,0 +1,22 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import lombok.Data; + +/** + * 结果总览报告上传主体汇总 + */ +@Data +public class CcdiProjectOverviewReportUploadSubjectVO { + + private String subjectName; + + private String accountNos; + + private String minTrxDate; + + private String maxTrxDate; + + private Integer fileCount; + + private String dataPeriod; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportVO.java new file mode 100644 index 00000000..fa7b0576 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewReportVO.java @@ -0,0 +1,33 @@ +package com.ruoyi.ccdi.project.domain.vo; + +import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * 结果总览一键导出报告 + */ +@Data +public class CcdiProjectOverviewReportVO { + + private CcdiProject project; + + private List uploadSubjects = new ArrayList<>(); + + private List params = new ArrayList<>(); + + private CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO(); + + private List modelSummaries = new ArrayList<>(); + + private List riskPeople = new ArrayList<>(); + + private List suspiciousTransactions = new ArrayList<>(); + + private List illegalPeople = new ArrayList<>(); + + private List abnormalAccounts = new ArrayList<>(); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java index 496d145c..4fb4b43a 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java @@ -13,6 +13,9 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO; @@ -72,6 +75,40 @@ public interface CcdiProjectOverviewMapper { */ List selectRiskModelCardsByProjectId(@Param("projectId") Long projectId); + /** + * 查询报告上传主体汇总 + * + * @param projectId 项目ID + * @return 上传主体汇总 + */ + List selectReportUploadSubjects(@Param("projectId") Long projectId); + + /** + * 查询报告风险模型汇总 + * + * @param projectId 项目ID + * @return 风险模型汇总 + */ + List selectReportRiskModelSummaries(@Param("projectId") Long projectId); + + /** + * 查询报告风险人员与异常点 + * + * @param projectId 项目ID + * @return 风险人员与异常点 + */ + List selectReportRiskPeople(@Param("projectId") Long projectId); + + /** + * 查询报告涉疑交易明细 + * + * @param query 查询条件 + * @return 涉疑交易明细 + */ + List selectReportSuspiciousTransactionList( + @Param("query") CcdiProjectSuspiciousTransactionQueryDTO query + ); + /** * 分页查询风险模型命中人员 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java index 854906b4..4c53fc5f 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java @@ -125,6 +125,15 @@ public interface ICcdiProjectOverviewService { default void exportRiskDetails(HttpServletResponse response, Long projectId) { } + /** + * 一键导出结果总览报告 + * + * @param response 响应流 + * @param projectId 项目ID + */ + default void exportOverviewReport(HttpServletResponse response, Long projectId) { + } + /** * 导出项目员工负面征信 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java new file mode 100644 index 00000000..9310d10f --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java @@ -0,0 +1,603 @@ +package com.ruoyi.ccdi.project.service.impl; + +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel; +import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.file.FileUtils; +import jakarta.servlet.http.HttpServletResponse; +import java.awt.Color; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import org.apache.fontbox.ttf.TrueTypeCollection; +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.springframework.stereotype.Component; + +/** + * 结果总览PDF报告导出器 + */ +@Component +public class CcdiProjectOverviewReportPdfExporter { + + private static final String CONTENT_TYPE = "application/pdf"; + private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00"); + + public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException { + response.setContentType(CONTENT_TYPE); + FileUtils.setAttachmentResponseHeader( + response, + safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf" + ); + + try (PDDocument document = new PDDocument()) { + PDType0Font font = loadChineseFont(document); + PdfPageWriter writer = new PdfPageWriter(document, font); + writer.newPage(); + writeCover(writer, report); + writeUploadSubjects(writer, report.getUploadSubjects()); + writeParams(writer, report.getParams()); + writeRiskModels(writer, report); + writeRiskDetails(writer, report); + writer.close(); + document.save(response.getOutputStream()); + } + } + + private void writeCover(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException { + writer.title("初核结果报告"); + writer.text("项目名称:" + safeText(report.getProject().getProjectName()), 12, Color.GRAY); + writer.text("导出时间:" + LocalDateTime.now().format(EXPORT_TIME_FORMATTER), 12, Color.GRAY); + writer.separator(); + } + + private void writeUploadSubjects( + PdfPageWriter writer, + List rows + ) throws IOException { + writer.section("一、上传文件"); + writer.table( + List.of("序号", "主体名称", "主体账号", "数据周期", "文件数"), + indexedRows(rows).stream() + .map(item -> List.of( + item.index(), + safeText(item.row().getSubjectName()), + maskAccountList(item.row().getAccountNos()), + safeText(item.row().getDataPeriod()), + formatCount(item.row().getFileCount(), "个") + )) + .collect(Collectors.toList()), + new float[] { 0.07F, 0.2F, 0.45F, 0.14F, 0.14F }, + "暂无上传文件数据" + ); + } + + private void writeParams(PdfPageWriter writer, List rows) throws IOException { + writer.section("二、参数配置"); + writer.table( + List.of("模型名称", "监测项", "参数值", "单位", "描述"), + rows.stream() + .map(item -> List.of( + safeText(item.getModelName()), + safeText(item.getParamName()), + safeText(item.getParamValue()), + safeText(item.getParamUnit()), + safeText(item.getParamDesc()) + )) + .collect(Collectors.toList()), + new float[] { 0.18F, 0.26F, 0.14F, 0.12F, 0.3F }, + "暂无参数配置数据" + ); + } + + private void writeRiskModels(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException { + writer.section("三、风险模型"); + writer.metrics(report.getDashboard().getStats()); + writer.subsection("风险模型汇总"); + writer.table( + List.of("模型名称", "预警数量", "涉及人员"), + report.getModelSummaries().stream() + .map(item -> List.of( + safeText(item.getModelName()), + String.valueOf(defaultZero(item.getWarningCount())), + formatPeopleSummary(item) + )) + .collect(Collectors.toList()), + new float[] { 0.26F, 0.14F, 0.6F }, + "暂无风险模型汇总数据" + ); + + writer.subsection("风险人员与异常点"); + writer.table( + List.of("姓名", "工号", "身份证号", "所属部门", "命中模型", "异常标签"), + report.getRiskPeople().stream() + .map(item -> List.of( + safeText(item.getStaffName()), + safeText(item.getStaffCode()), + maskIdCard(item.getIdNo()), + safeText(item.getDepartment()), + joinText(item.getModelNames()), + formatHitTags(item.getHitTagList()) + )) + .collect(Collectors.toList()), + new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F }, + "暂无风险人员与异常点数据" + ); + } + + private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException { + writer.section("四、风险明细"); + writer.subsection("1. 涉疑交易明细表(共" + report.getSuspiciousTransactions().size() + "条)"); + writer.table( + List.of("交易时间", "本方账户", "对方账户", "关联员工", "摘要/交易类型", "异常标签", "交易金额"), + report.getSuspiciousTransactions().stream() + .map(item -> List.of( + safeText(item.getTrxDate()), + formatAccount(item.getLeAccountNo(), item.getLeAccountName()), + formatAccount(item.getCustomerAccountNo(), item.getCustomerAccountName()), + formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()), + formatSummaryAndCashType(item.getUserMemo(), item.getCashType()), + safeText(item.getHitTags()), + formatMoney(item.getDisplayAmount()) + )) + .collect(Collectors.toList()), + new float[] { 0.14F, 0.16F, 0.16F, 0.12F, 0.17F, 0.16F, 0.09F }, + "暂无涉疑交易明细" + ); + + writer.subsection("2. 违法信息人员表(共" + report.getIllegalPeople().size() + "人)"); + writer.table( + List.of("姓名", "身份证号", "最近查询日期", "民事案件笔数", "民事案件金额", "强制执行笔数", "强制执行金额", "行政处罚笔数", "行政处罚金额"), + report.getIllegalPeople().stream() + .map(item -> List.of( + safeText(item.getPersonName()), + maskIdCard(item.getPersonId()), + safeText(item.getQueryDate()), + String.valueOf(defaultZero(item.getCivilCnt())), + formatMoney(item.getCivilLmt()), + String.valueOf(defaultZero(item.getEnforceCnt())), + formatMoney(item.getEnforceLmt()), + String.valueOf(defaultZero(item.getAdmCnt())), + formatMoney(item.getAdmLmt()) + )) + .collect(Collectors.toList()), + new float[] { 0.09F, 0.15F, 0.12F, 0.1F, 0.11F, 0.1F, 0.11F, 0.1F, 0.12F }, + "暂无违法信息人员数据" + ); + + writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)"); + writer.table( + List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态"), + report.getAbnormalAccounts().stream() + .map(item -> List.of( + maskAccount(item.getAccountNo()), + safeText(item.getAccountName()), + safeText(item.getBankName()), + safeText(item.getAbnormalType()), + safeText(item.getAbnormalTime()), + safeText(item.getStatus()) + )) + .collect(Collectors.toList()), + new float[] { 0.18F, 0.13F, 0.2F, 0.23F, 0.14F, 0.12F }, + "暂无异常账户信息" + ); + } + + private PDType0Font loadChineseFont(PDDocument document) throws IOException { + List candidates = List.of( + "C:/Windows/Fonts/NotoSansSC-VF.ttf", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/simsunb.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" + ); + for (String path : candidates) { + File file = new File(path); + if (!file.exists() || !file.isFile()) { + continue; + } + String lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".ttf")) { + return PDType0Font.load(document, file); + } + if (lowerPath.endsWith(".ttc")) { + PDType0Font font = loadFirstCollectionFont(document, file); + if (font != null) { + return font; + } + } + } + throw new ServiceException("未找到可用中文字体,无法导出PDF报告"); + } + + private PDType0Font loadFirstCollectionFont(PDDocument document, File file) throws IOException { + AtomicReference font = new AtomicReference<>(); + try (TrueTypeCollection collection = new TrueTypeCollection(file)) { + collection.processAllFonts(typeFont -> { + if (font.get() == null) { + font.set(PDType0Font.load(document, typeFont, true)); + } + }); + } + return font.get(); + } + + private List indexedRows(List rows) { + List result = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + result.add(new IndexedUploadSubject(String.valueOf(i + 1), rows.get(i))); + } + return result; + } + + private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) { + String names = safeText(item.getPeopleNames()); + if ("-".equals(names)) { + return names; + } + List people = Arrays.stream(names.split("、")) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + if (people.size() <= 4) { + return String.join("、", people); + } + return String.join("、", people.subList(0, 4)) + "等" + defaultZero(item.getPeopleCount()) + "人"; + } + + private String formatHitTags(List tags) { + if (tags == null || tags.isEmpty()) { + return "-"; + } + String text = tags.stream() + .map(CcdiProjectRiskHitTagVO::getRuleName) + .filter(Objects::nonNull) + .filter(value -> !value.isBlank()) + .distinct() + .collect(Collectors.joining("、")); + return text.isBlank() ? "-" : text; + } + + private String joinText(List values) { + if (values == null || values.isEmpty()) { + return "-"; + } + String text = values.stream() + .filter(Objects::nonNull) + .filter(value -> !value.isBlank()) + .distinct() + .collect(Collectors.joining("、")); + return text.isBlank() ? "-" : text; + } + + private String formatRelatedStaff(String name, String code) { + if (name == null || name.isBlank()) { + return "-"; + } + if (code == null || code.isBlank()) { + return name; + } + return name + "(" + code + ")"; + } + + private String formatSummaryAndCashType(String summary, String cashType) { + return safeText(summary) + "/" + safeText(cashType); + } + + private String formatAccount(String accountNo, String accountName) { + String masked = maskAccount(accountNo); + String name = safeText(accountName); + if ("-".equals(name)) { + return masked; + } + return masked + "\n" + name; + } + + private String maskAccountList(String value) { + if (value == null || value.isBlank()) { + return "-"; + } + return Arrays.stream(value.split("、|,|,")) + .map(String::trim) + .filter(item -> !item.isBlank()) + .map(this::maskAccount) + .distinct() + .collect(Collectors.joining("、")); + } + + private String maskAccount(String value) { + if (value == null || value.isBlank()) { + return "-"; + } + String text = value.trim().replaceAll("\\s+", ""); + if (text.length() <= 8) { + return text.length() <= 4 ? text : text.substring(0, 2) + "****" + text.substring(text.length() - 2); + } + return text.substring(0, 4) + "****" + text.substring(text.length() - 4); + } + + private String maskIdCard(String value) { + if (value == null || value.isBlank()) { + return "-"; + } + String text = value.trim(); + if (text.length() < 10) { + return text; + } + return text.substring(0, 3) + "***********" + text.substring(text.length() - 4); + } + + private String formatCount(Integer value, String unit) { + return defaultZero(value) + unit; + } + + private String formatMoney(BigDecimal value) { + if (value == null) { + return "-"; + } + return MONEY_FORMAT.format(value); + } + + private Integer defaultZero(Integer value) { + return value == null ? 0 : value; + } + + private String safeText(String value) { + return value == null || value.isBlank() ? "-" : value; + } + + private String safeFileName(String value) { + String text = safeText(value); + return text.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + private record IndexedUploadSubject(String index, CcdiProjectOverviewReportUploadSubjectVO row) { + } + + private static class PdfPageWriter { + + private static final float MARGIN = 36F; + private static final PDRectangle LANDSCAPE_A4 = new PDRectangle( + PDRectangle.A4.getHeight(), + PDRectangle.A4.getWidth() + ); + private static final float CONTENT_WIDTH = LANDSCAPE_A4.getWidth() - MARGIN * 2; + private static final float PAGE_TOP = LANDSCAPE_A4.getHeight() - MARGIN; + private static final float PAGE_BOTTOM = MARGIN; + private static final float BODY_FONT_SIZE = 9F; + private static final float HEADER_FONT_SIZE = 9F; + private static final float TITLE_FONT_SIZE = 22F; + private static final float SECTION_FONT_SIZE = 15F; + private static final float SUBSECTION_FONT_SIZE = 12F; + private static final float LINE_HEIGHT = 12F; + private static final float CELL_PADDING = 5F; + + private final PDDocument document; + private final PDType0Font font; + private PDPageContentStream content; + private float y; + + PdfPageWriter(PDDocument document, PDType0Font font) { + this.document = document; + this.font = font; + } + + void newPage() throws IOException { + close(); + PDPage page = new PDPage(LANDSCAPE_A4); + document.addPage(page); + content = new PDPageContentStream(document, page); + y = PAGE_TOP; + } + + void close() throws IOException { + if (content != null) { + content.close(); + content = null; + } + } + + void title(String text) throws IOException { + writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F); + } + + void text(String text, float fontSize, Color color) throws IOException { + writeLine(text, fontSize, color, 0F, 18F); + } + + void section(String text) throws IOException { + ensureSpace(32F); + writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F); + } + + void subsection(String text) throws IOException { + ensureSpace(26F); + writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F); + } + + void separator() throws IOException { + ensureSpace(16F); + content.setStrokingColor(new Color(31, 78, 121)); + content.setLineWidth(1.4F); + content.moveTo(MARGIN, y); + content.lineTo(MARGIN + CONTENT_WIDTH, y); + content.stroke(); + y -= 22F; + } + + void metrics(List stats) throws IOException { + ensureSpace(58F); + float cellWidth = CONTENT_WIDTH / Math.max(stats.size(), 1); + float x = MARGIN; + float rowHeight = 50F; + for (CcdiProjectOverviewStatVO stat : stats) { + drawRect(x, y - rowHeight, cellWidth, rowHeight, null); + drawCenteredText(String.valueOf(stat.getValue()), x, y - 18F, cellWidth, 18F, new Color(31, 78, 121)); + drawCenteredText(stat.getLabel(), x, y - 36F, cellWidth, 10F, Color.GRAY); + x += cellWidth; + } + y -= rowHeight + 18F; + } + + void table( + List headers, + List> rows, + float[] widthRatios, + String emptyText + ) throws IOException { + List> safeRows = rows.isEmpty() + ? List.of(List.of(emptyText)) + : rows; + List safeHeaders = rows.isEmpty() + ? List.of(headers.get(0)) + : headers; + float[] widths = rows.isEmpty() + ? new float[] { CONTENT_WIDTH } + : calculateWidths(widthRatios); + + drawHeader(safeHeaders, widths); + for (List row : safeRows) { + drawRow(row, widths, false); + } + y -= 8F; + } + + private float[] calculateWidths(float[] ratios) { + float[] widths = new float[ratios.length]; + for (int i = 0; i < ratios.length; i++) { + widths[i] = CONTENT_WIDTH * ratios[i]; + } + return widths; + } + + private void drawHeader(List headers, float[] widths) throws IOException { + drawRow(headers, widths, true); + } + + private void drawRow(List cells, float[] widths, boolean header) throws IOException { + List> wrappedCells = new ArrayList<>(); + float rowHeight = 0F; + for (int i = 0; i < widths.length; i++) { + String text = i < cells.size() ? cells.get(i) : ""; + List lines = wrapText(text, widths[i] - CELL_PADDING * 2, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE); + wrappedCells.add(lines); + rowHeight = Math.max(rowHeight, lines.size() * LINE_HEIGHT + CELL_PADDING * 2); + } + rowHeight = Math.max(rowHeight, 24F); + ensureSpace(rowHeight + 4F); + + float x = MARGIN; + for (int i = 0; i < widths.length; i++) { + Color background = header ? new Color(234, 241, 248) : null; + drawRect(x, y - rowHeight, widths[i], rowHeight, background); + drawCellText(wrappedCells.get(i), x + CELL_PADDING, y - CELL_PADDING - (header ? HEADER_FONT_SIZE : BODY_FONT_SIZE), header); + x += widths[i]; + } + y -= rowHeight; + } + + private void drawRect(float x, float bottomY, float width, float height, Color fill) throws IOException { + if (fill != null) { + content.setNonStrokingColor(fill); + content.addRect(x, bottomY, width, height); + content.fill(); + } + content.setStrokingColor(new Color(205, 217, 229)); + content.setLineWidth(0.5F); + content.addRect(x, bottomY, width, height); + content.stroke(); + } + + private void drawCellText(List lines, float x, float startY, boolean header) throws IOException { + content.beginText(); + content.setNonStrokingColor(header ? new Color(24, 59, 90) : new Color(31, 41, 55)); + content.setFont(font, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE); + content.newLineAtOffset(x, startY); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + content.newLineAtOffset(0, -LINE_HEIGHT); + } + content.showText(lines.get(i)); + } + content.endText(); + } + + private void drawCenteredText(String text, float x, float baselineY, float width, float fontSize, Color color) + throws IOException { + float textWidth = textWidth(text, fontSize); + content.beginText(); + content.setNonStrokingColor(color); + content.setFont(font, fontSize); + content.newLineAtOffset(x + (width - textWidth) / 2F, baselineY); + content.showText(text); + content.endText(); + } + + private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException { + ensureSpace(advance); + content.beginText(); + content.setNonStrokingColor(color); + content.setFont(font, fontSize); + content.newLineAtOffset(MARGIN + indent, y); + content.showText(text); + content.endText(); + y -= advance; + } + + private void ensureSpace(float height) throws IOException { + if (y - height < PAGE_BOTTOM) { + newPage(); + } + } + + private List wrapText(String text, float maxWidth, float fontSize) throws IOException { + String safeText = text == null || text.isBlank() ? "-" : text; + List result = new ArrayList<>(); + for (String part : safeText.split("\\n")) { + wrapPart(part, maxWidth, fontSize, result); + } + return result.isEmpty() ? List.of("-") : result; + } + + private void wrapPart(String text, float maxWidth, float fontSize, List result) throws IOException { + StringBuilder current = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + String next = String.valueOf(text.charAt(i)); + if (textWidth(current + next, fontSize) > maxWidth && current.length() > 0) { + result.add(current.toString()); + current.setLength(0); + } + current.append(next); + } + if (current.length() > 0) { + result.add(current.toString()); + } + } + + private float textWidth(String text, float fontSize) throws IOException { + return font.getStringWidth(text) / 1000F * fontSize; + } + } +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java index ab6570f8..2747e5e1 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java @@ -26,6 +26,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO; +import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO; @@ -37,15 +39,18 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO; +import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper; +import com.ruoyi.ccdi.project.service.ICcdiModelParamService; import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService; import com.ruoyi.common.exception.ServiceException; import jakarta.servlet.http.HttpServletResponse; import jakarta.annotation.Resource; import java.io.IOException; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -81,6 +86,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi @Resource private CcdiProjectRiskDetailWorkbookExporter workbookExporter; + @Resource + private CcdiProjectOverviewReportPdfExporter reportPdfExporter; + + @Resource + private ICcdiModelParamService modelParamService; + @Override public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) { CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId); @@ -303,6 +314,38 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi } } + @Override + public void exportOverviewReport(HttpServletResponse response, Long projectId) { + CcdiProject project = getRequiredProject(projectId); + + CcdiProjectSuspiciousTransactionQueryDTO suspiciousQuery = new CcdiProjectSuspiciousTransactionQueryDTO(); + suspiciousQuery.setProjectId(projectId); + suspiciousQuery.setSuspiciousType("ALL"); + + CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO(); + report.setProject(project); + report.setDashboard(getDashboard(projectId)); + report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream() + .peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate()))) + .toList()); + report.setParams(buildReportParams(projectId)); + report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId))); + report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream() + .peek(item -> item.setActionLabel(ACTION_LABEL)) + .toList()); + report.setSuspiciousTransactions(defaultList( + overviewMapper.selectReportSuspiciousTransactionList(suspiciousQuery) + )); + report.setIllegalPeople(exportEmployeeCreditNegative(projectId)); + report.setAbnormalAccounts(exportAbnormalAccountPeople(projectId)); + + try { + reportPdfExporter.export(response, report); + } catch (IOException e) { + throw new ServiceException("导出结果总览报告失败"); + } + } + @Override public List exportEmployeeCreditNegative(Long projectId) { ensureProjectExists(projectId); @@ -511,6 +554,31 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi return row; } + private List buildReportParams(Long projectId) { + ModelParamAllVO response = modelParamService.selectAllParams(projectId); + return defaultList(response == null ? null : response.getModels()).stream() + .flatMap(model -> defaultList(model.getParams()).stream().map(param -> { + CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO(); + row.setModelName(model.getModelName()); + row.setParamName(param.getParamName()); + row.setParamValue(param.getParamValue()); + row.setParamUnit(param.getParamUnit()); + row.setParamDesc(param.getParamDesc()); + return row; + })) + .toList(); + } + + private String formatDataPeriod(String minTrxDate, String maxTrxDate) { + if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) { + return "-"; + } + LocalDate start = LocalDate.parse(minTrxDate); + LocalDate end = LocalDate.parse(maxTrxDate); + int months = (end.getYear() - start.getYear()) * 12 + end.getMonthValue() - start.getMonthValue() + 1; + return Math.max(months, 1) + "个月"; + } + private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) { if (relatedStaffName == null || relatedStaffName.isBlank()) { return null; diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml index 68d9fe13..84344fdd 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml @@ -57,6 +57,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select 0 as digit union all select 1 @@ -338,6 +372,87 @@ order by warning_count desc, model_code asc + + + + + + + +