Bläddra i källkod

评估-发票台账处理功能开发

徐滕 1 vecka sedan
förälder
incheckning
f814434792

+ 17 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/controller/FinanceInvoiceController.java

@@ -1173,4 +1173,21 @@ public class FinanceInvoiceController {
         }
     }
 
+    /**
+     * 台账导入导出
+     * 上传评估公司台账Excel → 按发票号匹配数据库 → 补充报告号/归档/红冲信息 → 一对多拆行 → 生成Excel返回
+     */
+    @ApiLog("台账导入导出")
+    @PostMapping("/importLedger")
+    @PreAuthorize("hasAuthority('finance:invoice:ledgerImportExport')")
+    @ApiOperation(value = "台账导入导出")
+    public void importLedger(@RequestParam("file") MultipartFile file, HttpServletResponse response) {
+        try {
+            financeInvoiceService.importAndExportLedger(file, response);
+        } catch (Exception e) {
+            log.error("台账导入导出失败", e);
+            throw new RuntimeException("台账导入导出失败:" + e.getMessage());
+        }
+    }
+
 }

+ 760 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/FinanceInvoiceService.java

@@ -16,7 +16,9 @@ import com.jeeplus.assess.invoice.service.dto.*;
 import com.jeeplus.assess.invoice.thread.ApprovalThread;
 import com.jeeplus.assess.invoice.thread.RedApprovalThread;
 import com.jeeplus.assess.invoice.utils.DingTalkUtil;
+import com.jeeplus.assess.invoice.utils.ExcelCellUtil;
 import com.jeeplus.assess.invoice.utils.OMS.FileHandlingUtil;
+import com.jeeplus.assess.program.configuration.projectList.domain.ProgramArchive;
 import com.jeeplus.assess.program.configuration.projectList.mapper.ProgramArchiveMapper;
 import com.jeeplus.common.TokenProvider;
 import com.jeeplus.core.query.QueryWrapperGenerator;
@@ -41,8 +43,18 @@ import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.web.multipart.MultipartFile;
+
 import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import java.io.*;
+import java.lang.reflect.Field;
+import java.lang.reflect.Type;
 import java.math.BigDecimal;
+import java.net.URLEncoder;
 import java.text.SimpleDateFormat;
 import java.time.Instant;
 import java.time.ZoneId;
@@ -1907,4 +1919,752 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             e.printStackTrace();
         }
     }
+
+    // ==================== 台账导入导出 ====================
+
+    /**
+     * 台账导入导出核心方法
+     * 解析上传的Excel → 按发票号匹配数据库 → 补充报告号/归档/红冲信息 → 一对多拆行 → 生成Excel返回
+     *
+     * @param file 上传的XLS/XLSX文件
+     * @param response HTTP响应对象,用于输出文件流
+     */
+    public void importAndExportLedger(MultipartFile file, HttpServletResponse response) throws Exception {
+        // 第1步:解析Excel,识别数据sheet,读取有效行
+        List<InvoiceLedgerImportDTO> ledgerRows = parseLedgerExcel(file);
+
+        // 第2步:收集所有发票号,批量查询数据库
+        Map<String, LedgerMatchResultDTO> matchMap = batchMatchInvoices(ledgerRows);
+
+        // 第3步:数据组装 + 一对多拆行
+        List<InvoiceLedgerImportDTO> exportRows = assembleExportRows(ledgerRows, matchMap);
+
+        // 第4步:生成Excel并返回
+        generateLedgerExcel(exportRows, response);
+    }
+
+    /**
+     * 第1步:解析Excel文件,识别数据sheet,读取有效行
+     */
+    private List<InvoiceLedgerImportDTO> parseLedgerExcel(MultipartFile file) throws Exception {
+        List<InvoiceLedgerImportDTO> rows = new ArrayList<>();
+
+        Workbook workbook = WorkbookFactory.create(file.getInputStream());
+        try {
+            // 遍历所有sheet,找表头包含"发票号码"的sheet
+            Sheet dataSheet = null;
+            int headerRowIndex = -1;
+
+            for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+                Sheet sheet = workbook.getSheetAt(i);
+                for (int r = 0; r <= Math.min(sheet.getLastRowNum(), 5); r++) {
+                    Row row = sheet.getRow(r);
+                    if (row == null) continue;
+                    for (int c = 0; c < row.getLastCellNum(); c++) {
+                        Cell cell = row.getCell(c);
+                        if (cell != null && ExcelCellUtil.getCellStringValue(cell) != null
+                                && ExcelCellUtil.getCellStringValue(cell).contains("发票号码")) {
+                            dataSheet = sheet;
+                            headerRowIndex = r;
+                            break;
+                        }
+                    }
+                    if (dataSheet != null) break;
+                }
+                if (dataSheet != null) break;
+            }
+
+            if (dataSheet == null) {
+                throw new RuntimeException("未找到包含'发票号码'表头的sheet,请确认文件格式正确");
+            }
+
+            // 从表头行的下一行开始读取数据
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+            for (int r = headerRowIndex + 1; r <= dataSheet.getLastRowNum(); r++) {
+                Row row = dataSheet.getRow(r);
+                if (row == null) continue;
+
+                InvoiceLedgerImportDTO dto = new InvoiceLedgerImportDTO();
+
+                // A列 - 开票日期
+                Cell cellA = row.getCell(0);
+                if (cellA != null) {
+                    if (cellA.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cellA)) {
+                        dto.setBillingDate(sdf.format(cellA.getDateCellValue()));
+                    } else {
+                        dto.setBillingDate(ExcelCellUtil.getCellStringValue(cellA));
+                    }
+                }
+
+                // C列 - 购方企业名称
+                Cell cellC = row.getCell(2);
+                dto.setBuyerName(ExcelCellUtil.getCellStringValue(cellC));
+
+                // D列 - 商品名称
+                Cell cellD = row.getCell(3);
+                dto.setProductName(ExcelCellUtil.getCellStringValue(cellD));
+
+                // E列 - 单价
+                Cell cellE = row.getCell(4);
+                dto.setUnitPrice(ExcelCellUtil.getCellNumericString(cellE));
+
+                // F列 - 金额
+                Cell cellF = row.getCell(5);
+                dto.setAmount(ExcelCellUtil.getCellNumericString(cellF));
+
+                // G列 - 税额
+                Cell cellG = row.getCell(6);
+                dto.setTaxAmount(ExcelCellUtil.getCellNumericString(cellG));
+
+                // H列 - 开票人
+                Cell cellH = row.getCell(7);
+                dto.setIssuer(ExcelCellUtil.getCellStringValue(cellH));
+
+                // I列 - 收款日期
+                Cell cellI = row.getCell(8);
+                if (cellI != null) {
+                    if (cellI.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cellI)) {
+                        dto.setReceivablesDate(sdf.format(cellI.getDateCellValue()));
+                    } else {
+                        dto.setReceivablesDate(ExcelCellUtil.getCellStringValue(cellI));
+                    }
+                }
+
+                // J列 - 收款凭证
+                Cell cellJ = row.getCell(9);
+                dto.setReceiptVoucher(ExcelCellUtil.getCellStringValue(cellJ));
+
+                // K列 - 客户编码(可能因VLOOKUP引用缺失而显示#REF!或乱码)
+                Cell cellK = row.getCell(10);
+                String customerCode = ExcelCellUtil.getCellStringValue(cellK);
+                if (customerCode != null && (customerCode.contains("#REF") || customerCode.contains("#VALUE"))) {
+                    customerCode = ""; // 引用失效时置空
+                }
+                dto.setCustomerCode(customerCode);
+
+                // B列 - 发票号码(关键匹配字段)
+                Cell cellB = row.getCell(1);
+                String invoiceNumber = ExcelCellUtil.getCellStringValue(cellB);
+
+                if (StringUtils.isBlank(invoiceNumber)) {
+                    // 发票号码为空:检查是否为月度分隔行(如"一月"、"2月"等)
+                    boolean hasContent = StringUtils.isNotBlank(dto.getBillingDate())
+                            || StringUtils.isNotBlank(dto.getBuyerName())
+                            || StringUtils.isNotBlank(dto.getProductName())
+                            || StringUtils.isNotBlank(dto.getAmount());
+                    if (hasContent) {
+                        dto.setSectionHeader(true);
+                        dto.setHighlightType(3);
+                        rows.add(dto);
+                    }
+                    // 完全空行则跳过
+                    continue;
+                }
+
+                invoiceNumber = invoiceNumber.trim();
+                dto.setInvoiceNumber(invoiceNumber);
+
+                // 处理多发票号码(用"、"或","分隔的情况)
+                List<String> splitNumbers = Arrays.stream(invoiceNumber.split("[、,,/]"))
+                        .map(String::trim)
+                        .filter(StringUtils::isNotBlank)
+                        .collect(Collectors.toList());
+                dto.setSplitInvoiceNumbers(splitNumbers);
+
+                rows.add(dto);
+            }
+        } finally {
+            workbook.close();
+        }
+
+        return rows;
+    }
+
+    /**
+     * 第2步:批量查询数据库,按发票号匹配(支持8位后缀匹配20位数电票、多发票号拆分、被红冲反向查找)
+     */
+    private Map<String, LedgerMatchResultDTO> batchMatchInvoices(List<InvoiceLedgerImportDTO> rows) {
+        Map<String, LedgerMatchResultDTO> matchMap = new HashMap<>();
+
+        // 1. 收集所有需要查询的发票号(拆分多发票号情况)
+        Set<String> allLedgerNumbers = new LinkedHashSet<>();
+        for (InvoiceLedgerImportDTO row : rows) {
+            if (row.isSectionHeader()) continue;
+            if (row.getSplitInvoiceNumbers() != null) {
+                allLedgerNumbers.addAll(row.getSplitInvoiceNumbers());
+            } else if (StringUtils.isNotBlank(row.getInvoiceNumber())) {
+                allLedgerNumbers.add(row.getInvoiceNumber().trim());
+            }
+        }
+        if (allLedgerNumbers.isEmpty()) return matchMap;
+
+        List<String> numberList = new ArrayList<>(allLedgerNumbers);
+
+        // 2. 精确匹配
+        List<FinanceInvoiceDetail> exactDetails = financeInvoiceDetailMapper.selectList(
+                new LambdaQueryWrapper<FinanceInvoiceDetail>()
+                        .in(FinanceInvoiceDetail::getNumber, numberList)
+                        .eq(FinanceInvoiceDetail::getDelFlag, "0")
+        );
+        Map<String, String> exactNumberToInvoiceId = new HashMap<>();
+        for (FinanceInvoiceDetail d : exactDetails) {
+            if (!exactNumberToInvoiceId.containsKey(d.getNumber())) {
+                exactNumberToInvoiceId.put(d.getNumber(), d.getInvoiceId());
+            }
+        }
+
+        // 3. 对未精确匹配的号码,做后缀匹配(台账号可能是7位、8位等,数电票是20+位)
+        Map<String, String> suffixNumberToInvoiceId = new HashMap<>();
+        List<String> unmatchedNumbers = numberList.stream()
+                .filter(n -> !exactNumberToInvoiceId.containsKey(n))
+                .collect(Collectors.toList());
+
+        if (!unmatchedNumbers.isEmpty()) {
+            // 收集台账号码的长度集合,只构建需要的后缀长度
+            Set<Integer> neededLengths = unmatchedNumbers.stream()
+                    .map(String::length).collect(Collectors.toSet());
+
+            List<FinanceInvoiceDetail> allDetails = financeInvoiceDetailMapper.selectList(
+                    new LambdaQueryWrapper<FinanceInvoiceDetail>()
+                            .eq(FinanceInvoiceDetail::getDelFlag, "0")
+            );
+            // 按长度分组构建后缀索引:suffixLength -> (suffix -> invoiceId)
+            Map<Integer, Map<String, String>> lengthSuffixIndex = new HashMap<>();
+            for (int len : neededLengths) {
+                Map<String, String> index = new HashMap<>();
+                for (FinanceInvoiceDetail d : allDetails) {
+                    String num = d.getNumber();
+                    if (num != null && num.length() > len) {
+                        index.put(num.substring(num.length() - len), d.getInvoiceId());
+                    }
+                }
+                lengthSuffixIndex.put(len, index);
+            }
+            for (String num : unmatchedNumbers) {
+                Map<String, String> index = lengthSuffixIndex.get(num.length());
+                if (index != null && index.containsKey(num)) {
+                    suffixNumberToInvoiceId.put(num, index.get(num));
+                }
+            }
+        }
+
+        // 4. 合并所有匹配
+        Map<String, String> allMatched = new HashMap<>();
+        allMatched.putAll(exactNumberToInvoiceId);
+        allMatched.putAll(suffixNumberToInvoiceId);
+        if (allMatched.isEmpty()) return matchMap;
+
+        // 5. 批量查询发票主表(红冲信息)
+        Set<String> allInvoiceIds = new LinkedHashSet<>(allMatched.values());
+        List<FinanceInvoice> invoices = financeInvoiceMapper.selectBatchIds(new ArrayList<>(allInvoiceIds));
+        Map<String, FinanceInvoice> invoiceMap = invoices.stream()
+                .collect(Collectors.toMap(FinanceInvoice::getId, inv -> inv, (a, b) -> a));
+
+        // 6. 批量查询发票-项目关联(报告号)
+        List<FinanceInvoiceBase> bases = financeInvoiceBaseMapper.selectList(
+                new LambdaQueryWrapper<FinanceInvoiceBase>()
+                        .in(FinanceInvoiceBase::getInvoiceId, allInvoiceIds)
+                        .eq(FinanceInvoiceBase::getDelFlag, "0")
+        );
+        Map<String, List<String>> invoiceIdToReportNos = bases.stream()
+                .filter(b -> StringUtils.isNotBlank(b.getReportNo()))
+                .collect(Collectors.groupingBy(
+                        FinanceInvoiceBase::getInvoiceId,
+                        Collectors.mapping(FinanceInvoiceBase::getReportNo, Collectors.toList())
+                ));
+
+        // 7. 批量查询归档信息
+        Set<String> programIds = bases.stream()
+                .map(FinanceInvoiceBase::getProgramId)
+                .filter(StringUtils::isNotBlank)
+                .collect(Collectors.toSet());
+        Map<String, String> programIdToArchiveStatus = new HashMap<>();
+        for (String pid : programIds) {
+            try {
+                ProgramArchive archive = programArchiveMapper.selectOne(
+                        new LambdaQueryWrapper<ProgramArchive>()
+                                .eq(ProgramArchive::getProgramId, pid)
+                                .eq(ProgramArchive::getDelFlag, "0")
+                                .last("LIMIT 1")
+                );
+                if (archive != null) {
+                    programIdToArchiveStatus.put(pid, archive.getStatus());
+                }
+            } catch (Exception e) {
+                // 单个项目归档查询失败不影响整体
+            }
+        }
+
+        // 8. 构建"被红冲"反向映射
+        // 收集所有已匹配发票的no和number,建立映射关系
+        Set<String> matchedNos = new HashSet<>();
+        Map<String, String> noToNumberMap = new HashMap<>(); // no → number
+        Map<String, String> numberToNoMap = new HashMap<>(); // number → no
+        for (FinanceInvoice inv : invoices) {
+            if (StringUtils.isNotBlank(inv.getNo())) {
+                matchedNos.add(inv.getNo());
+                // 获取该发票的detail.number
+                List<FinanceInvoiceDetail> details = financeInvoiceDetailMapper.selectList(
+                        new LambdaQueryWrapper<FinanceInvoiceDetail>()
+                                .eq(FinanceInvoiceDetail::getInvoiceId, inv.getId())
+                                .eq(FinanceInvoiceDetail::getDelFlag, "0")
+                                .last("LIMIT 1")
+                );
+                if (!details.isEmpty() && StringUtils.isNotBlank(details.get(0).getNumber())) {
+                    String number = details.get(0).getNumber();
+                    noToNumberMap.put(inv.getNo(), number);
+                    numberToNoMap.put(number, inv.getNo());
+                }
+            }
+        }
+        // 查所有红冲发票,找出引用了已匹配发票number的(redInvoiceRelevancyNumber存储的是number而非no)
+        Map<String, String> originalNoToRedNoMap = new HashMap<>();
+        Set<String> matchedNumbers = new HashSet<>(numberToNoMap.keySet());
+        if (!matchedNumbers.isEmpty()) {
+            List<FinanceInvoice> allRedInvoices = financeInvoiceMapper.selectList(
+                    new LambdaQueryWrapper<FinanceInvoice>()
+                            .eq(FinanceInvoice::getRedInvoiceFlag, "1")
+                            .in(FinanceInvoice::getRedInvoiceRelevancyNumber, matchedNumbers)
+                            .eq(FinanceInvoice::getDelFlag, "0")
+            );
+            for (FinanceInvoice redInv : allRedInvoices) {
+                // redInvoiceRelevancyNumber存储的是原发票的number,需要转换回no
+                String originalNo = numberToNoMap.get(redInv.getRedInvoiceRelevancyNumber());
+                if (originalNo != null) {
+                    originalNoToRedNoMap.put(originalNo, redInv.getNo());
+                }
+            }
+        }
+        // 同时从已匹配集合中补充(红冲方也在台账中的情况)
+        for (FinanceInvoice inv : invoices) {
+            if ("1".equals(inv.getRedInvoiceFlag()) && StringUtils.isNotBlank(inv.getRedInvoiceRelevancyNumber())) {
+                // 尝试通过number找到原发票的no
+                String originalNo = numberToNoMap.get(inv.getRedInvoiceRelevancyNumber());
+                if (originalNo != null) {
+                    originalNoToRedNoMap.putIfAbsent(originalNo, inv.getNo());
+                } else {
+                    // 如果numberToNoMap中没有,尝试直接用redInvoiceRelevancyNumber作为no
+                    originalNoToRedNoMap.putIfAbsent(inv.getRedInvoiceRelevancyNumber(), inv.getNo());
+                }
+            }
+        }
+
+        // 获取被红冲发票的detail.number(用于判断台账号码是否匹配到被红冲的发票)
+        Set<String> redInvoicedDetailNumbers = new HashSet<>();
+        if (!originalNoToRedNoMap.isEmpty()) {
+            Set<String> targetNos = originalNoToRedNoMap.keySet();
+            List<FinanceInvoice> redTargetInvoices = financeInvoiceMapper.selectList(
+                    new LambdaQueryWrapper<FinanceInvoice>()
+                            .in(FinanceInvoice::getNo, targetNos)
+                            .eq(FinanceInvoice::getDelFlag, "0")
+            );
+            if (!redTargetInvoices.isEmpty()) {
+                List<String> redTargetIds = redTargetInvoices.stream()
+                        .map(FinanceInvoice::getId).collect(Collectors.toList());
+                List<FinanceInvoiceDetail> redTargetDetails = financeInvoiceDetailMapper.selectList(
+                        new LambdaQueryWrapper<FinanceInvoiceDetail>()
+                                .in(FinanceInvoiceDetail::getInvoiceId, redTargetIds)
+                                .eq(FinanceInvoiceDetail::getDelFlag, "0")
+                );
+                redTargetDetails.forEach(d -> redInvoicedDetailNumbers.add(d.getNumber()));
+            }
+        }
+        // 后缀也加入被红冲集合(只生成台账中实际出现的后缀长度)
+        Set<Integer> ledgerNumberLengths = allLedgerNumbers.stream()
+                .map(String::length).collect(Collectors.toSet());
+        Set<String> redInvoicedSuffixes = new HashSet<>();
+        for (String num : redInvoicedDetailNumbers) {
+            if (num != null) {
+                for (int len : ledgerNumberLengths) {
+                    if (num.length() > len) {
+                        redInvoicedSuffixes.add(num.substring(num.length() - len));
+                    }
+                }
+            }
+            redInvoicedSuffixes.add(num);
+        }
+        // 构建 台账号码后缀 → 红冲方no 的映射(使用number而非no,因为台账中是发票号)
+        Map<String, String> suffixToRedNo = new HashMap<>();
+        for (Map.Entry<String, String> entry : originalNoToRedNoMap.entrySet()) {
+            String originalNo = entry.getKey();
+            String redNo = entry.getValue();
+            // 通过no找到对应的number
+            String originalNumber = noToNumberMap.get(originalNo);
+            if (originalNumber != null) {
+                // 用number建立映射
+                suffixToRedNo.put(originalNumber, redNo);
+                for (int len : ledgerNumberLengths) {
+                    if (originalNumber.length() > len) {
+                        suffixToRedNo.put(originalNumber.substring(originalNumber.length() - len), redNo);
+                    }
+                }
+            } else {
+                // 如果没有number,用no作为备选
+                suffixToRedNo.put(originalNo, redNo);
+                for (int len : ledgerNumberLengths) {
+                    if (originalNo.length() > len) {
+                        suffixToRedNo.put(originalNo.substring(originalNo.length() - len), redNo);
+                    }
+                }
+            }
+        }
+
+        // 8.5 构建 number → no 的映射(用于将红冲发票的redInvoiceRelevancyNumber转换为开票编号)
+        // 收集所有红冲发票的redInvoiceRelevancyNumber(存储的是原发票的number)
+        Set<String> redRelevancyNumbers = new HashSet<>();
+        for (FinanceInvoice inv : invoices) {
+            if ("1".equals(inv.getRedInvoiceFlag()) && StringUtils.isNotBlank(inv.getRedInvoiceRelevancyNumber())) {
+                redRelevancyNumbers.add(inv.getRedInvoiceRelevancyNumber());
+            }
+        }
+        // 查询这些number对应的发票,获取其no
+        Map<String, String> relevancyNumberToNoMap = new HashMap<>();
+        if (!redRelevancyNumbers.isEmpty()) {
+            // 通过detail.number查找对应的发票
+            List<FinanceInvoiceDetail> relevancyDetails = financeInvoiceDetailMapper.selectList(
+                    new LambdaQueryWrapper<FinanceInvoiceDetail>()
+                            .in(FinanceInvoiceDetail::getNumber, redRelevancyNumbers)
+                            .eq(FinanceInvoiceDetail::getDelFlag, "0")
+            );
+            if (!relevancyDetails.isEmpty()) {
+                Set<String> relevancyInvoiceIds = relevancyDetails.stream()
+                        .map(FinanceInvoiceDetail::getInvoiceId).collect(Collectors.toSet());
+                List<FinanceInvoice> relevancyInvoices = financeInvoiceMapper.selectList(
+                        new LambdaQueryWrapper<FinanceInvoice>()
+                                .in(FinanceInvoice::getId, relevancyInvoiceIds)
+                                .eq(FinanceInvoice::getDelFlag, "0")
+                );
+                // 建立 number → no 映射
+                Map<String, String> invoiceIdToNo = new HashMap<>();
+                for (FinanceInvoice inv : relevancyInvoices) {
+                    invoiceIdToNo.put(inv.getId(), inv.getNo());
+                }
+                for (FinanceInvoiceDetail detail : relevancyDetails) {
+                    String no = invoiceIdToNo.get(detail.getInvoiceId());
+                    if (no != null) {
+                        relevancyNumberToNoMap.put(detail.getNumber(), no);
+                    }
+                }
+            }
+        }
+
+        // 9. 组装每个发票号的匹配结果
+        for (Map.Entry<String, String> entry : allMatched.entrySet()) {
+            String ledgerNumber = entry.getKey();
+            String invoiceId = entry.getValue();
+
+            LedgerMatchResultDTO result = new LedgerMatchResultDTO();
+            result.setInvoiceId(invoiceId);
+            result.setMatchedNumber(ledgerNumber);
+
+            FinanceInvoice invoice = invoiceMap.get(invoiceId);
+            if (invoice != null) {
+                result.setInvoiceNo(invoice.getNo());
+                result.setRedInvoiceFlag(invoice.getRedInvoiceFlag());
+                // 将redInvoiceRelevancyNumber(number)转换为对应的开票编号(no)
+                String relevancyNumber = invoice.getRedInvoiceRelevancyNumber();
+                if (StringUtils.isNotBlank(relevancyNumber)) {
+                    String relevancyNo = relevancyNumberToNoMap.get(relevancyNumber);
+                    result.setRedInvoiceRelevancyNumber(relevancyNo != null ? relevancyNo : relevancyNumber);
+                }
+            }
+
+            result.setReportNos(invoiceIdToReportNos.getOrDefault(invoiceId, new ArrayList<>()));
+
+            List<FinanceInvoiceBase> invoiceBases = bases.stream()
+                    .filter(b -> invoiceId.equals(b.getInvoiceId()))
+                    .collect(Collectors.toList());
+            if (!invoiceBases.isEmpty()) {
+                String firstProgramId = invoiceBases.get(0).getProgramId();
+                if (StringUtils.isNotBlank(firstProgramId)) {
+                    result.setArchiveStatus(programIdToArchiveStatus.get(firstProgramId));
+                }
+            }
+
+            // 被红冲判断
+            boolean isRedInvoiced = redInvoicedDetailNumbers.contains(ledgerNumber)
+                    || redInvoicedSuffixes.contains(ledgerNumber);
+            result.setRedInvoiced(isRedInvoiced);
+            if (isRedInvoiced) {
+                result.setRedInvoiceNo(suffixToRedNo.get(ledgerNumber));
+            }
+
+            matchMap.put(ledgerNumber, result);
+        }
+
+        return matchMap;
+    }
+
+    /**
+     * 归档状态字典值转中文
+     */
+    private String convertArchiveStatus(String status) {
+        if (StringUtils.isBlank(status)) {
+            return "未归档";
+        }
+        switch (status) {
+            case "0": return "未归档";
+            case "1": return "待审核";
+            case "2": return "审核中";
+            case "3": return "已撤回";
+            case "4": return "已驳回";
+            case "5": return "已归档";
+            case "6": return "超期未归档";
+            case "7": return "超期已归档";
+            default: return "未归档";
+        }
+    }
+
+    /**
+     * 第3步:数据组装 + 一对多拆行 + 多发票号拆行
+     */
+    private List<InvoiceLedgerImportDTO> assembleExportRows(
+            List<InvoiceLedgerImportDTO> ledgerRows,
+            Map<String, LedgerMatchResultDTO> matchMap) {
+
+        List<InvoiceLedgerImportDTO> exportRows = new ArrayList<>();
+
+        for (InvoiceLedgerImportDTO row : ledgerRows) {
+            // 月度分隔行:原样保留,不做匹配
+            if (row.isSectionHeader()) {
+                row.setInvoiceNo("");
+                row.setReportNo("");
+                row.setArchiveStatus("");
+                row.setIsRedInvoice("");
+                row.setRedInvoiceTarget("");
+                exportRows.add(row);
+                continue;
+            }
+
+            // 收集本行所有匹配结果(支持多发票号拆分)
+            // 同一invoiceId的多个号码应合并报告号,不拆成多行
+            Map<String, LedgerMatchResultDTO> matchByIdMap = new LinkedHashMap<>();
+
+            List<String> splitNumbers = row.getSplitInvoiceNumbers();
+            if (splitNumbers != null) {
+                for (String num : splitNumbers) {
+                    LedgerMatchResultDTO match = matchMap.get(num);
+                    if (match != null) {
+                        String iid = match.getInvoiceId();
+                        if (matchByIdMap.containsKey(iid)) {
+                            // 同一invoiceId:合并报告号
+                            LedgerMatchResultDTO existing = matchByIdMap.get(iid);
+                            for (String rno : match.getReportNos()) {
+                                if (!existing.getReportNos().contains(rno)) {
+                                    existing.getReportNos().add(rno);
+                                }
+                            }
+                        } else {
+                            matchByIdMap.put(iid, match);
+                        }
+                    }
+                }
+            } else if (StringUtils.isNotBlank(row.getInvoiceNumber())) {
+                LedgerMatchResultDTO match = matchMap.get(row.getInvoiceNumber().trim());
+                if (match != null) {
+                    matchByIdMap.put(match.getInvoiceId(), match);
+                }
+            }
+
+            List<LedgerMatchResultDTO> matches = new ArrayList<>(matchByIdMap.values());
+
+            if (matches.isEmpty()) {
+                // 全部匹配不上:淡蓝色
+                row.setHighlightType(2);
+                row.setInvoiceNo("");
+                row.setReportNo("");
+                row.setArchiveStatus("");
+                row.setIsRedInvoice("");
+                row.setRedInvoiceTarget("");
+                exportRows.add(row);
+            } else {
+                // 有匹配结果:为每个不同的invoiceId生成导出行
+                for (LedgerMatchResultDTO match : matches) {
+                    populateMatchedRow(row, match, exportRows);
+                }
+            }
+        }
+
+        return exportRows;
+    }
+
+    /**
+     * 填充匹配行的数据(处理报告号一对多拆行 + 被红冲状态)
+     */
+    private void populateMatchedRow(InvoiceLedgerImportDTO row, LedgerMatchResultDTO match,
+                                     List<InvoiceLedgerImportDTO> exportRows) {
+        // 开票编号
+        String invoiceNo = StringUtils.defaultString(match.getInvoiceNo(), "");
+
+        // 红冲信息
+        String isRedInvoice;
+        String redInvoiceTarget;
+        if ("1".equals(match.getRedInvoiceFlag())) {
+            isRedInvoice = "是";
+            // 本发票是红冲发票,红冲对象 = 被红冲发票的开票编号
+            redInvoiceTarget = StringUtils.defaultString(match.getRedInvoiceRelevancyNumber(), "");
+        } else if (match.isRedInvoiced()) {
+            isRedInvoice = "被红冲";
+            // 本发票被别人红冲了,红冲对象 = 红冲方的开票编号
+            redInvoiceTarget = StringUtils.defaultString(match.getRedInvoiceNo(), "");
+        } else {
+            isRedInvoice = "否";
+            redInvoiceTarget = "";
+        }
+        String archiveStatus = convertArchiveStatus(match.getArchiveStatus());
+
+        // 报告号拆行
+        List<String> reportNos = match.getReportNos();
+        if (reportNos.isEmpty()) {
+            InvoiceLedgerImportDTO out = copyRow(row);
+            out.setInvoiceNo(invoiceNo);
+            out.setReportNo("");
+            out.setArchiveStatus(archiveStatus);
+            out.setIsRedInvoice(isRedInvoice);
+            out.setRedInvoiceTarget(redInvoiceTarget);
+            out.setHighlightType(0);
+            exportRows.add(out);
+        } else if (reportNos.size() == 1) {
+            InvoiceLedgerImportDTO out = copyRow(row);
+            out.setInvoiceNo(invoiceNo);
+            out.setReportNo(reportNos.get(0));
+            out.setArchiveStatus(archiveStatus);
+            out.setIsRedInvoice(isRedInvoice);
+            out.setRedInvoiceTarget(redInvoiceTarget);
+            out.setHighlightType(0);
+            exportRows.add(out);
+        } else {
+            // 多个报告号,拆成多行,淡黄色
+            for (String reportNo : reportNos) {
+                InvoiceLedgerImportDTO splitRow = copyRow(row);
+                splitRow.setInvoiceNo(invoiceNo);
+                splitRow.setReportNo(reportNo);
+                splitRow.setArchiveStatus(archiveStatus);
+                splitRow.setIsRedInvoice(isRedInvoice);
+                splitRow.setRedInvoiceTarget(redInvoiceTarget);
+                splitRow.setHighlightType(1);
+                exportRows.add(splitRow);
+            }
+        }
+    }
+
+    /**
+     * 复制行数据(A-K列)
+     */
+    private InvoiceLedgerImportDTO copyRow(InvoiceLedgerImportDTO source) {
+        InvoiceLedgerImportDTO copy = new InvoiceLedgerImportDTO();
+        copy.setBillingDate(source.getBillingDate());
+        copy.setInvoiceNumber(source.getInvoiceNumber());
+        copy.setBuyerName(source.getBuyerName());
+        copy.setProductName(source.getProductName());
+        copy.setUnitPrice(source.getUnitPrice());
+        copy.setAmount(source.getAmount());
+        copy.setTaxAmount(source.getTaxAmount());
+        copy.setIssuer(source.getIssuer());
+        copy.setReceivablesDate(source.getReceivablesDate());
+        copy.setReceiptVoucher(source.getReceiptVoucher());
+        copy.setCustomerCode(source.getCustomerCode());
+        return copy;
+    }
+
+    /**
+     * 第4步:生成Excel并输出到response
+     */
+    private void generateLedgerExcel(List<InvoiceLedgerImportDTO> rows, HttpServletResponse response) throws Exception {
+        Workbook workbook = new XSSFWorkbook();
+        Sheet sheet = workbook.createSheet("评估公司台账");
+
+        // 样式定义
+        CellStyle headerStyle = workbook.createCellStyle();
+        Font headerFont = workbook.createFont();
+        headerFont.setBold(true);
+        headerFont.setFontHeightInPoints((short) 11);
+        headerStyle.setFont(headerFont);
+        headerStyle.setAlignment(HorizontalAlignment.CENTER);
+        headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+        headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+        headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+        ExcelCellUtil.setFullBorder(headerStyle);
+
+        CellStyle dataStyle = workbook.createCellStyle();
+        dataStyle.setAlignment(HorizontalAlignment.CENTER);
+        dataStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+        ExcelCellUtil.setFullBorder(dataStyle);
+
+        // 淡黄色样式(一对多拆行)
+        CellStyle yellowStyle = workbook.createCellStyle();
+        yellowStyle.cloneStyleFrom(dataStyle);
+        yellowStyle.setFillForegroundColor(IndexedColors.LEMON_CHIFFON.getIndex());
+        yellowStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+
+        // 淡蓝色样式(未匹配)
+        CellStyle blueStyle = workbook.createCellStyle();
+        blueStyle.cloneStyleFrom(dataStyle);
+        blueStyle.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
+        blueStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+
+        // 表头(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象)
+        String[] headers = {"开票日期", "发票号码", "购方企业名称", "商品名称", "单价", "金额",
+                "税额", "开票人", "收款日期", "收款凭证", "客户编码",
+                "开票编号", "报告文号", "归档情况", "是否红冲", "红冲对象"};
+
+        Row headerRow = sheet.createRow(0);
+        headerRow.setHeightInPoints(25);
+        for (int i = 0; i < headers.length; i++) {
+            Cell cell = headerRow.createCell(i);
+            cell.setCellValue(headers[i]);
+            cell.setCellStyle(headerStyle);
+        }
+
+        // 设置列宽
+        int[] colWidths = {15, 18, 35, 25, 12, 15, 12, 10, 15, 15, 12, 18, 30, 12, 10, 20};
+        for (int i = 0; i < colWidths.length; i++) {
+            sheet.setColumnWidth(i, colWidths[i] * 256);
+        }
+
+        // 数据行
+        for (int i = 0; i < rows.size(); i++) {
+            InvoiceLedgerImportDTO row = rows.get(i);
+            Row dataRow = sheet.createRow(i + 1);
+            dataRow.setHeightInPoints(20);
+
+            CellStyle rowStyle;
+            if (row.getHighlightType() == 1) {
+                rowStyle = yellowStyle;
+            } else if (row.getHighlightType() == 2) {
+                rowStyle = blueStyle;
+            } else {
+                rowStyle = dataStyle;
+            }
+
+            ExcelCellUtil.setCellValue(dataRow.createCell(0), row.getBillingDate(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(1), row.getInvoiceNumber(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(2), row.getBuyerName(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(3), row.getProductName(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(4), row.getUnitPrice(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(5), row.getAmount(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(6), row.getTaxAmount(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(7), row.getIssuer(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(8), row.getReceivablesDate(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(9), row.getReceiptVoucher(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(10), row.getCustomerCode(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(11), row.getInvoiceNo(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(12), row.getReportNo(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(13), row.getArchiveStatus(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(14), row.getIsRedInvoice(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(15), row.getRedInvoiceTarget(), rowStyle);
+        }
+
+        // 输出到response
+        String fileName = "评估公司台账_" + new SimpleDateFormat("yyyy-MM-dd").format(new Date()) + ".xlsx";
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
+
+        OutputStream os = response.getOutputStream();
+        workbook.write(os);
+        os.flush();
+        os.close();
+        workbook.close();
+    }
 }

+ 70 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/InvoiceLedgerImportDTO.java

@@ -0,0 +1,70 @@
+package com.jeeplus.assess.invoice.service.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 评估公司台账导入导出DTO
+ * 对应台账Excel的A-P列数据(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象)
+ */
+@Data
+public class InvoiceLedgerImportDTO {
+
+    /** A列 - 开票日期 */
+    private String billingDate;
+
+    /** B列 - 发票号码(匹配键,可能是多个号码用、分隔) */
+    private String invoiceNumber;
+
+    /** 拆分后的单个发票号码列表(用于数据库匹配) */
+    private List<String> splitInvoiceNumbers;
+
+    /** C列 - 购方企业名称 */
+    private String buyerName;
+
+    /** D列 - 商品名称 */
+    private String productName;
+
+    /** E列 - 单价 */
+    private String unitPrice;
+
+    /** F列 - 金额 */
+    private String amount;
+
+    /** G列 - 税额 */
+    private String taxAmount;
+
+    /** H列 - 开票人 */
+    private String issuer;
+
+    /** I列 - 收款日期 */
+    private String receivablesDate;
+
+    /** J列 - 收款凭证 */
+    private String receiptVoucher;
+
+    /** K列 - 客户编码 */
+    private String customerCode;
+
+    /** L列 - 开票编号(从数据库补充,FinanceInvoice.no) */
+    private String invoiceNo;
+
+    /** M列 - 报告文号(从数据库补充) */
+    private String reportNo;
+
+    /** N列 - 归档情况(从数据库补充) */
+    private String archiveStatus;
+
+    /** O列 - 是否红冲(从数据库补充:是/否/被红冲) */
+    private String isRedInvoice;
+
+    /** P列 - 红冲对象(从数据库补充) */
+    private String redInvoiceTarget;
+
+    /** 行高亮类型:0=正常白色,1=淡黄色(一对多拆行),2=淡蓝色(未匹配),3=月度分隔行 */
+    private int highlightType;
+
+    /** 是否为月度分隔行(如"一月"、"2月"等) */
+    private boolean sectionHeader;
+}

+ 41 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/LedgerMatchResultDTO.java

@@ -0,0 +1,41 @@
+package com.jeeplus.assess.invoice.service.dto;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 台账导入-数据库匹配结果DTO
+ * 存储按发票号匹配后从数据库查出的关联信息
+ */
+@Data
+public class LedgerMatchResultDTO {
+
+    /** 发票主键ID */
+    private String invoiceId;
+
+    /** 匹配到的实际发票号码(数据库中number字段,可能是20位数电票号) */
+    private String matchedNumber;
+
+    /** 本发票的开票编号(FinanceInvoice.no) */
+    private String invoiceNo;
+
+    /** 是否红字发票(1是 0否) */
+    private String redInvoiceFlag;
+
+    /** 红字发票关联发票号码(本发票红冲了谁的开票编号no) */
+    private String redInvoiceRelevancyNumber;
+
+    /** 关联报告号列表(一对多时会拆行) */
+    private List<String> reportNos = new ArrayList<>();
+
+    /** 归档状态(字典值) */
+    private String archiveStatus;
+
+    /** 是否被其他发票红冲(反向查找结果) */
+    private boolean redInvoiced;
+
+    /** 红冲本发票的那个发票的开票编号(被红冲时填充) */
+    private String redInvoiceNo;
+}

+ 100 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/utils/ExcelCellUtil.java

@@ -0,0 +1,100 @@
+package com.jeeplus.assess.invoice.utils;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.ss.usermodel.*;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+
+/**
+ * Excel单元格读写工具类
+ * 处理单元格格式转换、样式设置等通用操作,与业务无关
+ */
+public class ExcelCellUtil {
+
+    private ExcelCellUtil() {}
+
+    /**
+     * 设置单元格值(字符串)
+     */
+    public static void setCellValue(Cell cell, String value, CellStyle style) {
+        cell.setCellStyle(style);
+        if (StringUtils.isNotBlank(value)) {
+            cell.setCellValue(value);
+        }
+    }
+
+    /**
+     * 设置全边框
+     */
+    public static void setFullBorder(CellStyle style) {
+        style.setBorderBottom(BorderStyle.THIN);
+        style.setBorderTop(BorderStyle.THIN);
+        style.setBorderLeft(BorderStyle.THIN);
+        style.setBorderRight(BorderStyle.THIN);
+    }
+
+    /**
+     * 获取单元格的字符串值(兼容各种类型)
+     */
+    public static String getCellStringValue(Cell cell) {
+        if (cell == null) return null;
+        switch (cell.getCellType()) {
+            case STRING:
+                return cell.getStringCellValue();
+            case NUMERIC:
+                if (DateUtil.isCellDateFormatted(cell)) {
+                    return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue());
+                }
+                // 数字转字符串,去掉小数点后的0
+                double numVal = cell.getNumericCellValue();
+                if (numVal == Math.floor(numVal) && !Double.isInfinite(numVal)) {
+                    return String.valueOf((long) numVal);
+                }
+                return String.valueOf(numVal);
+            case BOOLEAN:
+                return String.valueOf(cell.getBooleanCellValue());
+            case FORMULA:
+                try {
+                    return cell.getStringCellValue();
+                } catch (Exception e) {
+                    try {
+                        return String.valueOf(cell.getNumericCellValue());
+                    } catch (Exception e2) {
+                        return cell.getCellFormula();
+                    }
+                }
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * 获取单元格的数值字符串(用于金额等)
+     */
+    public static String getCellNumericString(Cell cell) {
+        if (cell == null) return "";
+        switch (cell.getCellType()) {
+            case NUMERIC:
+                double val = cell.getNumericCellValue();
+                if (val == Math.floor(val) && !Double.isInfinite(val)) {
+                    return String.valueOf((long) val);
+                }
+                return new BigDecimal(val).setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString();
+            case STRING:
+                return cell.getStringCellValue();
+            case FORMULA:
+                try {
+                    double fVal = cell.getNumericCellValue();
+                    if (fVal == Math.floor(fVal) && !Double.isInfinite(fVal)) {
+                        return String.valueOf((long) fVal);
+                    }
+                    return new BigDecimal(fVal).setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString();
+                } catch (Exception e) {
+                    return cell.getStringCellValue();
+                }
+            default:
+                return "";
+        }
+    }
+}