Jelajahi Sumber

评估-开票项目添加开票金额 以及历史数据处理

徐滕 4 hari lalu
induk
melakukan
794198f52d
14 mengubah file dengan 331 tambahan dan 29 penghapusan
  1. 109 0
      docs/sql/addsql/20261109.sql
  2. 12 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/controller/FinanceInvoiceController.java
  3. 11 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/domain/FinanceInvoiceBase.java
  4. 3 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/mapper/xml/FinanceInvoiceBaseMapper.xml
  5. 2 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/mapper/xml/FinanceInvoiceMapper.xml
  6. 100 5
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/FinanceInvoiceService.java
  7. 6 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/FinanceInvoiceBaseDTO.java
  8. 8 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/FinanceInvoiceDTO.java
  9. 8 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/InvoiceLedgerImportDTO.java
  10. 9 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/LedgerMatchResultDTO.java
  11. 1 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/domain/ProgramProjectListInfo.java
  12. 2 2
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/service/ProjectListService.java
  13. 49 17
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/utils/ProvincialFilingExcelUtils.java
  14. 11 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/utils/dto/ProvincialFilingRowData.java

+ 109 - 0
docs/sql/addsql/20261109.sql

@@ -0,0 +1,109 @@
+-- 评估端发票申请:基本信息行级开票金额(手工在目标库执行,仓库无迁移框架)
+-- 目标库:xg_saas
+--
+-- 【部署顺序 —— 必须严格遵守】
+--   1) 先执行本脚本的 ALTER TABLE
+--   2) 再执行回填 UPDATE
+--   3) 然后才部署后端(jeeplus-assess)
+--   4) 最后部署前端
+--
+-- 原因:FinanceInvoiceBase 实体新增 account 字段后,MyBatis-Plus 的 selectList/selectById
+-- 会按实体字段拼 SELECT 列清单。若先上代码后加列,以下 7 处查询会全部报
+-- Unknown column 'account' in 'field list':
+--   FinanceInvoiceService.java:233 / 257   findList——发票列表按项目名、报告号筛选
+--   FinanceInvoiceService.java:1313 / 1337 findRedList——红冲发票列表
+--   FinanceInvoiceService.java:1963        updateProjectArchiveByInvoiceId——开票后回写归档
+--                                          is_invoice / invoice_date(外层 try/catch 吞异常,会静默失效)
+--   FinanceInvoiceService.java:2238        batchMatchInvoices——台账导入导出的发票匹配
+--   ProjectListService.java:2513           importProvincialFiling——项目备案导入
+-- 反之,先加列不上代码则完全无影响(列可空,旧代码不读它)。
+
+
+-- ----------------------------
+-- 1. 新增列
+-- ----------------------------
+-- 类型对齐会计端 cw_finance_invoice_base.account(varchar(64)),
+-- 实体与 DTO 均为 String,MapStruct 同名自动映射。
+ALTER TABLE `finance_invoice_base`
+    ADD COLUMN `account` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL
+    COMMENT '发票金额(元)——该条项目/报告行的开票金额,各行合计应等于 finance_invoice.account'
+    AFTER `report_no`;
+
+
+-- ----------------------------
+-- 2. 历史数据回填
+-- ----------------------------
+-- 只回填「仅有一条基本信息行」的历史单据:这类单据不存在金额分配歧义,
+-- 行金额必然等于主表全额,回填后完全对齐新模型。
+--
+-- 一票挂多个项目/报告的历史单据(base 行数 > 1)保持 NULL:
+-- 没有任何数据能推断各项目分别开了多少,只能由业务人工补录。
+-- 后端校验与前端汇总对这类单据均放行(全 NULL 视为历史未拆分单据,
+-- 保留主表 account 原值,不覆盖为 0)。
+--
+-- 用派生表而非同表子查询:MySQL 不允许在 UPDATE 的子查询里引用被更新的同一张表。
+-- WHERE b.account IS NULL 保证脚本可重复执行。
+UPDATE `finance_invoice_base` b
+JOIN (
+    SELECT `invoice_id`
+    FROM `finance_invoice_base`
+    WHERE `del_flag` = 0
+    GROUP BY `invoice_id`
+    HAVING COUNT(*) = 1
+) single ON single.`invoice_id` = b.`invoice_id`
+JOIN `finance_invoice` f ON f.`id` = b.`invoice_id` AND f.`del_flag` = 0
+SET b.`account` = f.`account`
+WHERE b.`del_flag` = 0
+  AND b.`account` IS NULL
+  AND f.`account` IS NOT NULL
+  AND TRIM(f.`account`) <> '';
+
+
+-- ----------------------------
+-- 3. 执行后核对(只读,不改数据)
+-- ----------------------------
+-- 3.1 回填覆盖情况:按 base 行数分组统计
+-- SELECT
+--     CASE WHEN cnt = 1 THEN '单行单据' ELSE '多行单据(需人工补录)' END AS 类型,
+--     COUNT(*) AS 发票数,
+--     SUM(filled) AS 已填行金额的发票数
+-- FROM (
+--     SELECT b.invoice_id,
+--            COUNT(*) AS cnt,
+--            MAX(CASE WHEN b.account IS NOT NULL AND TRIM(b.account) <> '' THEN 1 ELSE 0 END) AS filled
+--     FROM finance_invoice_base b
+--     WHERE b.del_flag = 0
+--     GROUP BY b.invoice_id
+-- ) t
+-- GROUP BY CASE WHEN cnt = 1 THEN '单行单据' ELSE '多行单据(需人工补录)' END;
+
+-- 3.2 一致性校验:已填行金额的单据,各行合计是否等于主表金额
+--     正常应返回 0 行。若有返回,说明历史数据本身主表与明细就不一致,需人工核对。
+-- SELECT b.invoice_id,
+--        f.account AS 主表金额,
+--        SUM(CAST(NULLIF(TRIM(b.account), '') AS DECIMAL(18,2))) AS 行金额合计
+-- FROM finance_invoice_base b
+-- JOIN finance_invoice f ON f.id = b.invoice_id AND f.del_flag = 0
+-- WHERE b.del_flag = 0
+--   AND b.account IS NOT NULL AND TRIM(b.account) <> ''
+-- GROUP BY b.invoice_id, f.account
+-- HAVING ABS(IFNULL(SUM(CAST(NULLIF(TRIM(b.account), '') AS DECIMAL(18,2))), 0)
+--            - CAST(NULLIF(TRIM(f.account), '') AS DECIMAL(18,2))) > 0.005;
+
+-- 3.3 列出需要人工补录的多行历史单据(供业务排期)
+-- SELECT f.no AS 发票申请编号, f.account AS 发票总金额, COUNT(*) AS 项目行数,
+--        GROUP_CONCAT(b.program_name SEPARATOR ' / ') AS 关联项目
+-- FROM finance_invoice_base b
+-- JOIN finance_invoice f ON f.id = b.invoice_id AND f.del_flag = 0
+-- WHERE b.del_flag = 0
+-- GROUP BY f.no, f.account, b.invoice_id
+-- HAVING COUNT(*) > 1
+--    AND MAX(CASE WHEN b.account IS NOT NULL AND TRIM(b.account) <> '' THEN 1 ELSE 0 END) = 0;
+
+
+-- ----------------------------
+-- 4. 回滚(仅在部署失败需要退回旧代码时执行)
+-- ----------------------------
+-- 旧代码不读该列,正常情况下无需回滚;加列本身对旧版本无害。
+-- 若确实要移除:
+-- ALTER TABLE `finance_invoice_base` DROP COLUMN `account`;

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

@@ -423,6 +423,18 @@ public class FinanceInvoiceController {
                     .filter(StringUtils::isNotBlank)
                     .collect(Collectors.joining(","));
                 record.setReportNo(reportNoStr);
+                // 项目开票金额:按基本信息行顺序拼接各行开票金额。
+                // 全部为空(历史一票多项目未拆分)时保持空白,避免误认为金额为 0。
+                boolean anyRowAccount = record.getFinanceInvoiceBaseDTOList().stream()
+                    .anyMatch(item -> StringUtils.isNotBlank(item.getAccount()));
+                if (anyRowAccount) {
+                    String programAccountStr = record.getFinanceInvoiceBaseDTOList().stream()
+                        .map(item -> StringUtils.isNotBlank(item.getAccount()) ? item.getAccount().trim() : "")
+                        .collect(Collectors.joining(","));
+                    record.setProgramAccount(programAccountStr);
+                } else {
+                    record.setProgramAccount("");
+                }
             }
         }
         EasyPoiUtil.exportExcel ( result, sheetName,  sheetName, FinanceInvoiceDTO.class, fileName, response );

+ 11 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/domain/FinanceInvoiceBase.java

@@ -1,5 +1,7 @@
 package com.jeeplus.assess.invoice.domain;
 
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.jeeplus.core.domain.BaseEntity;
 import lombok.Data;
@@ -59,5 +61,14 @@ public class FinanceInvoiceBase extends BaseEntity {
      */
     private String reportNo;
 
+    /**
+     * 发票金额(元)——该条项目/报告行的开票金额。
+     * 各行合计应等于主表 finance_invoice.account。
+     * 历史一票多项目单据该值为 NULL,表示金额未拆分,由前端与后端校验放行。
+     * updateStrategy=IGNORED:允许把已填金额清空为 null(默认 NOT_NULL 策略会跳过 null 导致旧值残留)。
+     */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String account;
+
     private static final long serialVersionUID = 1L;
 }

+ 3 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/mapper/xml/FinanceInvoiceBaseMapper.xml

@@ -20,6 +20,7 @@
             <result property="clientName" column="client_name" jdbcType="VARCHAR"/>
             <result property="clientId" column="client_id" jdbcType="VARCHAR"/>
             <result property="reportNo" column="report_no" jdbcType="VARCHAR"/>
+            <result property="account" column="account" jdbcType="VARCHAR"/>
     </resultMap>
 
     <sql id="Base_Column_List">
@@ -37,6 +38,7 @@
         fib.program_no,
         fib.client_name,
         fib.client_id,
-        fib.report_no
+        fib.report_no,
+        fib.account
     </sql>
 </mapper>

+ 2 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/mapper/xml/FinanceInvoiceMapper.xml

@@ -175,7 +175,8 @@
         fib.program_no,
         fib.client_name,
         fib.client_id,
-        fib.report_no
+        fib.report_no,
+        fib.account
     </sql>
 
     <sql id="Base_Column_List">

+ 100 - 5
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/FinanceInvoiceService.java

@@ -501,6 +501,8 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
      * @throws Exception
      */
     public String saveFormOnly(FinanceInvoiceDTO financeInvoiceDTO) throws Exception{
+        // 行级开票金额一致性校验,与 saveForm 保持一致
+        validateBaseAccount(financeInvoiceDTO);
         //添加钉钉通知处理
         boolean isDDNotice = false;
         String isOms = "0";
@@ -687,6 +689,8 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
 
 
     public String saveForm(FinanceInvoiceDTO financeInvoiceDTO) throws Exception{
+        // 行级开票金额一致性校验:必须在 handleDoInvoice(触发真实开票)与任何写库之前拦截
+        validateBaseAccount(financeInvoiceDTO);
         //添加钉钉通知处理
         boolean isDDNotice = false;
         if(financeInvoiceDTO.getStatus().equals("2")){
@@ -911,6 +915,69 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
         return financeInvoice.getId();
     }
 
+    /**
+     * 基本信息行级开票金额校验。
+     * <p>
+     * 规则:
+     * 1. 仅在送审(status=2)与开票审核通过(status=5)时校验,暂存允许金额不完整;
+     * 2. 基本信息为空(如作废/红冲、收款登记等场景不回传 base)时直接放行;
+     * 3. 所有行的 account 均为空时,视为历史"一票多项目未拆分"单据,放行且不改写主表 account,
+     *    避免卡死审批节点(审批表单的基本信息表格是禁用的,审批人无法补录金额);
+     * 4. 只要有一行填了金额,就要求每行都填,且各行合计必须等于主表发票金额。
+     */
+    private void validateBaseAccount(FinanceInvoiceDTO dto) {
+        if (dto == null) {
+            return;
+        }
+        String status = dto.getStatus();
+        if (!"2".equals(status) && !"5".equals(status)) {
+            return;
+        }
+        List<FinanceInvoiceBaseDTO> baseList = dto.getFinanceInvoiceBaseDTOList();
+        if (CollectionUtil.isEmpty(baseList)) {
+            return;
+        }
+        boolean anyFilled = false;
+        for (FinanceInvoiceBaseDTO base : baseList) {
+            if (StringUtils.isNotBlank(base.getAccount())) {
+                anyFilled = true;
+                break;
+            }
+        }
+        // 历史未拆分单据放行
+        if (!anyFilled) {
+            return;
+        }
+
+        BigDecimal sum = BigDecimal.ZERO;
+        for (int i = 0; i < baseList.size(); i++) {
+            String rowAccount = baseList.get(i).getAccount();
+            if (StringUtils.isBlank(rowAccount)) {
+                throw new RuntimeException("基本信息第 " + (i + 1) + " 行的开票金额未填写,请补全后再提交");
+            }
+            try {
+                sum = sum.add(new BigDecimal(rowAccount.trim()));
+            } catch (NumberFormatException e) {
+                throw new RuntimeException("基本信息第 " + (i + 1) + " 行的开票金额格式不正确:" + rowAccount);
+            }
+        }
+
+        String total = dto.getAccount();
+        if (StringUtils.isBlank(total)) {
+            throw new RuntimeException("发票金额不能为空");
+        }
+        BigDecimal totalValue;
+        try {
+            totalValue = new BigDecimal(total.trim());
+        } catch (NumberFormatException e) {
+            throw new RuntimeException("发票金额格式不正确:" + total);
+        }
+        if (sum.compareTo(totalValue) != 0) {
+            throw new RuntimeException("基本信息各行开票金额合计 " + sum.stripTrailingZeros().toPlainString()
+                    + " 与发票金额 " + totalValue.stripTrailingZeros().toPlainString() + " 不一致,请检查");
+        }
+    }
+
     public String addDingtalkNotice(FinanceInvoiceDTO dto){
         dingTalkUtil.addDingtalkNotice(dto);
         return "ok";
@@ -2179,6 +2246,14 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
                         FinanceInvoiceBase::getInvoiceId,
                         Collectors.mapping(FinanceInvoiceBase::getReportNo, Collectors.toList())
                 ));
+        // 与 invoiceIdToReportNos 完全相同的过滤条件和遍历顺序,保证 baseAccounts 与 reportNos 下标对齐。
+        // account 为 null(历史未拆分)时映射为空串,导出时显示为空白。
+        Map<String, List<String>> invoiceIdToBaseAccounts = bases.stream()
+                .filter(b -> StringUtils.isNotBlank(b.getReportNo()))
+                .collect(Collectors.groupingBy(
+                        FinanceInvoiceBase::getInvoiceId,
+                        Collectors.mapping(b -> b.getAccount() == null ? "" : b.getAccount(), Collectors.toList())
+                ));
 
         // 7. 批量查询归档信息
         Set<String> programIds = bases.stream()
@@ -2368,6 +2443,7 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             if (invoice != null) {
                 result.setInvoiceNo(invoice.getNo());
                 result.setRedInvoiceFlag(invoice.getRedInvoiceFlag());
+                result.setMainAccount(invoice.getAccount());
                 // 将redInvoiceRelevancyNumber(number)转换为对应的开票编号(no)
                 String relevancyNumber = invoice.getRedInvoiceRelevancyNumber();
                 if (StringUtils.isNotBlank(relevancyNumber)) {
@@ -2377,6 +2453,7 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             }
 
             result.setReportNos(invoiceIdToReportNos.getOrDefault(invoiceId, new ArrayList<>()));
+            result.setBaseAccounts(invoiceIdToBaseAccounts.getOrDefault(invoiceId, new ArrayList<>()));
 
             List<FinanceInvoiceBase> invoiceBases = bases.stream()
                     .filter(b -> invoiceId.equals(b.getInvoiceId()))
@@ -2520,6 +2597,11 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
         }
         String archiveStatus = convertArchiveStatus(match.getArchiveStatus());
 
+        // 整票总额(Q列),拆行时仅首行填充(与报告备案只填首行口径一致)
+        String mainAccount = StringUtils.defaultString(match.getMainAccount(), "");
+        // 各行开票金额(R列),与 reportNos 下标对齐
+        List<String> baseAccounts = match.getBaseAccounts();
+
         // 报告号拆行
         List<String> reportNos = match.getReportNos();
         if (reportNos.isEmpty()) {
@@ -2529,6 +2611,8 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             out.setArchiveStatus(archiveStatus);
             out.setIsRedInvoice(isRedInvoice);
             out.setRedInvoiceTarget(redInvoiceTarget);
+            out.setProjectAccount("");
+            out.setInvoiceTotalAccount(mainAccount);
             out.setHighlightType(0);
             exportRows.add(out);
         } else if (reportNos.size() == 1) {
@@ -2538,17 +2622,25 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             out.setArchiveStatus(archiveStatus);
             out.setIsRedInvoice(isRedInvoice);
             out.setRedInvoiceTarget(redInvoiceTarget);
+            out.setProjectAccount(baseAccounts != null && !baseAccounts.isEmpty()
+                    ? StringUtils.defaultString(baseAccounts.get(0), "") : "");
+            out.setInvoiceTotalAccount(mainAccount);
             out.setHighlightType(0);
             exportRows.add(out);
         } else {
             // 多个报告号,拆成多行,淡黄色
-            for (String reportNo : reportNos) {
+            for (int i = 0; i < reportNos.size(); i++) {
                 InvoiceLedgerImportDTO splitRow = copyRow(row);
                 splitRow.setInvoiceNo(invoiceNo);
-                splitRow.setReportNo(reportNo);
+                splitRow.setReportNo(reportNos.get(i));
                 splitRow.setArchiveStatus(archiveStatus);
                 splitRow.setIsRedInvoice(isRedInvoice);
                 splitRow.setRedInvoiceTarget(redInvoiceTarget);
+                // R列:当前报告号对应行的开票金额(下标越界或历史未拆分则留空)
+                splitRow.setProjectAccount(baseAccounts != null && i < baseAccounts.size()
+                        ? StringUtils.defaultString(baseAccounts.get(i), "") : "");
+                // Q列:整票总额只在首行填,其余留空,保证整列求和等于发票总额(与报告备案只填首行口径一致)
+                splitRow.setInvoiceTotalAccount(i == 0 ? mainAccount : "");
                 splitRow.setHighlightType(1);
                 exportRows.add(splitRow);
             }
@@ -2610,10 +2702,11 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
         blueStyle.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
         blueStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
 
-        // 表头(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象)
+        // 表头(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象 + Q开票总金额 + R项目开票金额)
         String[] headers = {"开票日期", "发票号码", "购方企业名称", "商品名称", "单价", "金额",
                 "税额", "开票人", "收款日期", "收款凭证", "客户编码",
-                "开票编号", "报告文号", "归档情况", "是否红冲", "红冲对象"};
+                "开票编号", "报告文号", "归档情况", "是否红冲", "红冲对象",
+                "开票总金额", "项目开票金额"};
 
         Row headerRow = sheet.createRow(0);
         headerRow.setHeightInPoints(25);
@@ -2624,7 +2717,7 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
         }
 
         // 设置列宽
-        int[] colWidths = {15, 18, 35, 25, 12, 15, 12, 10, 15, 15, 12, 18, 30, 12, 10, 20};
+        int[] colWidths = {15, 18, 35, 25, 12, 15, 12, 10, 15, 15, 12, 18, 30, 12, 10, 20, 16, 16};
         for (int i = 0; i < colWidths.length; i++) {
             sheet.setColumnWidth(i, colWidths[i] * 256);
         }
@@ -2660,6 +2753,8 @@ public class FinanceInvoiceService extends ServiceImpl<FinanceInvoiceMapper, Fin
             ExcelCellUtil.setCellValue(dataRow.createCell(13), row.getArchiveStatus(), rowStyle);
             ExcelCellUtil.setCellValue(dataRow.createCell(14), row.getIsRedInvoice(), rowStyle);
             ExcelCellUtil.setCellValue(dataRow.createCell(15), row.getRedInvoiceTarget(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(16), row.getInvoiceTotalAccount(), rowStyle);
+            ExcelCellUtil.setCellValue(dataRow.createCell(17), row.getProjectAccount(), rowStyle);
         }
 
         // 输出到response

+ 6 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/FinanceInvoiceBaseDTO.java

@@ -58,6 +58,12 @@ public class FinanceInvoiceBaseDTO extends BaseDTO {
     private String reportNo;
 
     /**
+     * 发票金额(元)——该条项目/报告行的开票金额,各行合计等于主表发票金额。
+     * 历史一票多项目单据为 NULL,表示未拆分。
+     */
+    private String account;
+
+    /**
      * 一级校审id
      */
     private String auditId1;

+ 8 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/FinanceInvoiceDTO.java

@@ -73,6 +73,14 @@ public class FinanceInvoiceDTO extends BaseDTO {
     private String account;
 
     /**
+     * 项目开票金额(元)——基本信息各行开票金额按项目顺序拼接。
+     * 一票多项目且已拆分时形如 "1000.00,2000.00";历史未拆分单据为空。
+     * 纯展示字段,不参与数据库映射(无对应列,由 Controller 导出前拼装)。
+     */
+    @Excel(name = "项目开票金额(元)",width = 22)
+    private String programAccount;
+
+    /**
      * 发票金额(元)
      */
     @Excel(name = "发票金额(元)",width = 16,type = 10)

+ 8 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/invoice/service/dto/InvoiceLedgerImportDTO.java

@@ -6,7 +6,8 @@ import java.util.List;
 
 /**
  * 评估公司台账导入导出DTO
- * 对应台账Excel的A-P列数据(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象)
+ * 对应台账Excel的A-R列数据(A-K原有 + L开票编号 + M报告文号 + N归档情况 + O是否红冲 + P红冲对象
+ * + Q开票总金额 + R项目开票金额)
  */
 @Data
 public class InvoiceLedgerImportDTO {
@@ -62,6 +63,12 @@ public class InvoiceLedgerImportDTO {
     /** P列 - 红冲对象(从数据库补充) */
     private String redInvoiceTarget;
 
+    /** R列 - 项目开票金额(从数据库补充:当前报告号对应基本信息行的开票金额,历史未拆分为空) */
+    private String projectAccount;
+
+    /** Q列 - 开票总金额(从数据库补充:发票主表金额,拆行时仅首行填充,其余留空以便整列求和;与报告备案只填首行口径一致) */
+    private String invoiceTotalAccount;
+
     /** 行高亮类型:0=正常白色,1=淡黄色(一对多拆行),2=淡蓝色(未匹配),3=月度分隔行 */
     private int highlightType;
 

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

@@ -30,6 +30,15 @@ public class LedgerMatchResultDTO {
     /** 关联报告号列表(一对多时会拆行) */
     private List<String> reportNos = new ArrayList<>();
 
+    /**
+     * 各基本信息行的开票金额,与 reportNos 按相同过滤条件(report_no 非空)和相同顺序构建,
+     * 因此与 reportNos 下标一一对应。历史一票多项目未拆分单据对应位置为空串。
+     */
+    private List<String> baseAccounts = new ArrayList<>();
+
+    /** 发票主表金额(整票总额),对应台账导出「开票总金额」列 */
+    private String mainAccount;
+
     /** 归档状态(字典值) */
     private String archiveStatus;
 

+ 1 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/domain/ProgramProjectListInfo.java

@@ -166,7 +166,7 @@ public class ProgramProjectListInfo extends BaseEntity {
     private String status;
 
     /**
-     * 备案是否作废(省级备案导入时从Excel同步)
+     * 备案是否作废(报告备案导入时从Excel同步)
      */
     private String recordCancel;
 

+ 2 - 2
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/service/ProjectListService.java

@@ -2488,7 +2488,7 @@ public class ProjectListService {
     }
 
     /**
-     * 省级项目备案数据导入:读取原始Excel,按报告文号匹配系统和发票数据,末尾追加7列后输出
+     * 省级项目备案数据导入:读取原始Excel,按报告文号匹配系统和发票数据,末尾追加9列后输出
      */
     @SuppressWarnings("unchecked")
     public void importProvincialFiling(InputStream inputStream, String fileName, HttpServletResponse response) throws Exception {
@@ -2597,7 +2597,7 @@ public class ProjectListService {
             }
         }
 
-        // 6. 使用工具类追加7列并输出下载
+        // 6. 使用工具类追加9列并输出下载
         ProvincialFilingExcelUtils.appendColumnsAndDownload(workbook, lastColIndex, rowReportNoMap,
                 dataMap, fileName, response);
     }

+ 49 - 17
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/utils/ProvincialFilingExcelUtils.java

@@ -22,8 +22,8 @@ import java.util.*;
  */
 public class ProvincialFilingExcelUtils {
 
-    /** 追加的8列详细表头 */
-    private static final String[] NEW_HEADERS = {"是否归档", "收费标准", "责任人", "开票编号", "开票金额", "是否开票", "开票状态", "开票时间"};
+    /** 追加的9列详细表头 */
+    private static final String[] NEW_HEADERS = {"是否归档", "收费标准", "责任人", "开票编号", "开票总金额", "开票金额", "是否开票", "开票状态", "开票时间"};
     /** 大类表头名称 */
     private static final String CATEGORY_HEADER = "项目与开票信息";
     /** 需要匹配的列名 */
@@ -92,7 +92,7 @@ public class ProvincialFilingExcelUtils {
     }
 
     /**
-     * 将查询到的数据追加到Excel末尾7列,并输出下载
+     * 将查询到的数据追加到Excel末尾9列,并输出下载
      */
     public static void appendColumnsAndDownload(
             Workbook workbook,
@@ -163,6 +163,7 @@ public class ProvincialFilingExcelUtils {
             String projectManagerName = (rowData != null) ? rowData.getProjectManagerName() : "";
             String invoiceNos = (rowData != null && rowData.getInvoiceNos() != null) ? String.join(",", rowData.getInvoiceNos()) : "";
             Double invoiceAmount = (rowData != null) ? rowData.getInvoiceAmount() : null;
+            Double invoiceTotalAmount = (rowData != null) ? rowData.getInvoiceTotalAmount() : null;
             String isInvoiced = (rowData != null) ? rowData.getIsInvoiced() : "否";
             String invoiceStatuses = (rowData != null && rowData.getInvoiceStatuses() != null) ? String.join(",", rowData.getInvoiceStatuses()) : "";
             String invoiceDates = (rowData != null && rowData.getInvoiceDates() != null) ? String.join(",", rowData.getInvoiceDates()) : "";
@@ -175,18 +176,24 @@ public class ProvincialFilingExcelUtils {
             setCell(dataRow, newStartCol + 2, projectManagerName, colStyle);
             // 开票编号
             setCell(dataRow, newStartCol + 3, invoiceNos, colStyle);
-            // 开票金额
-            Cell amountCell = dataRow.createCell(newStartCol + 4);
+            // 开票总金额(整票总额,仅该发票首次出现的报告行填充,其余留空;与台账只填首行口径一致)
+            Cell totalAmountCell = dataRow.createCell(newStartCol + 4);
+            if (invoiceTotalAmount != null) {
+                totalAmountCell.setCellValue(invoiceTotalAmount);
+            }
+            totalAmountCell.setCellStyle(colStyle);
+            // 开票金额(本报告行基本信息行的开票金额合计)
+            Cell amountCell = dataRow.createCell(newStartCol + 5);
             if (invoiceAmount != null) {
                 amountCell.setCellValue(invoiceAmount);
             }
             amountCell.setCellStyle(colStyle);
             // 是否开票
-            setCell(dataRow, newStartCol + 5, isInvoiced, colStyle);
+            setCell(dataRow, newStartCol + 6, isInvoiced, colStyle);
             // 开票状态
-            setCell(dataRow, newStartCol + 6, invoiceStatuses, colStyle);
+            setCell(dataRow, newStartCol + 7, invoiceStatuses, colStyle);
             // 开票时间
-            setCell(dataRow, newStartCol + 7, invoiceDates, colStyle);
+            setCell(dataRow, newStartCol + 8, invoiceDates, colStyle);
 
             // 如果需要标黄,将整行所有单元格设为黄色(覆盖绿色)
             if (needHighlight) {
@@ -223,6 +230,9 @@ public class ProvincialFilingExcelUtils {
             Map<String, ProgramArchive> archiveMap
     ) {
         Map<String, ProvincialFilingRowData> dataMap = new HashMap<>();
+        // 跨报告行去重:同一发票的整票总额只在其首次出现的报告行填充(只填首行口径,
+        // 与台账拆行只填首行一致,保证整列求和等于各发票总额之和)
+        Set<String> shownInvoiceIds = new HashSet<>();
 
         for (String reportNo : reportNoList) {
             ProvincialFilingRowData rowData = new ProvincialFilingRowData();
@@ -250,16 +260,33 @@ public class ProvincialFilingExcelUtils {
                 // 遍历关联的发票
                 Set<String> processedInvoiceIds = new HashSet<>();
                 for (FinanceInvoiceBase base : bases) {
+                    // 开票金额(第6列):按基本信息行累计 base.account,每个报告只计自己那一行,
+                    // 避免一票多报告时把整票金额重复计入多个报告行。base.account 为空(历史未拆分)时不计入。
+                    if (StringUtils.isNotBlank(base.getAccount())) {
+                        try {
+                            double currentAmount = rowData.getInvoiceAmount() != null ? rowData.getInvoiceAmount() : 0;
+                            rowData.setInvoiceAmount(currentAmount + Double.parseDouble(base.getAccount().trim()));
+                        } catch (NumberFormatException e) {
+                            // 忽略无法解析的金额
+                        }
+                    }
+
                     String invoiceId = base.getInvoiceId();
                     if (StringUtils.isBlank(invoiceId) || processedInvoiceIds.contains(invoiceId)) {
                         continue;
                     }
                     processedInvoiceIds.add(invoiceId);
 
-                    // 检查该发票是否关联了多个不同的报告文号(多对多标黄)
+                    // 检查该发票是否在当前 Excel 中关联了多个不同的报告文号(多对多标黄)
                     Set<String> linkedReportNos = invoiceToReportNosMap.get(invoiceId);
-                    if (linkedReportNos != null && linkedReportNos.size() > 1) {
-                        rowData.setNeedHighlight(true);
+                    if (linkedReportNos != null) {
+                        // 只统计当前 Excel 导入范围内的报告文号
+                        long countInCurrentImport = linkedReportNos.stream()
+                                .filter(rn -> reportNoList.contains(rn))
+                                .count();
+                        if (countInCurrentImport > 1) {
+                            rowData.setNeedHighlight(true);
+                        }
                     }
 
                     FinanceInvoice inv = invoiceMap.get(invoiceId);
@@ -267,12 +294,17 @@ public class ProvincialFilingExcelUtils {
                         if (StringUtils.isNotBlank(inv.getNo())) {
                             rowData.getInvoiceNos().add(inv.getNo());
                         }
-                        if (StringUtils.isNotBlank(inv.getAccount())) {
-                            try {
-                                double currentAmount = rowData.getInvoiceAmount() != null ? rowData.getInvoiceAmount() : 0;
-                                rowData.setInvoiceAmount(currentAmount + Double.parseDouble(inv.getAccount()));
-                            } catch (NumberFormatException e) {
-                                // 忽略无法解析的金额
+                        // 开票总金额(第5列):仅在该发票首次出现的报告行填充(跨行去重),
+                        // 其余报告行留空,保证整列求和等于各发票总额之和;与台账拆行只填首行口径一致。
+                        if (!shownInvoiceIds.contains(invoiceId)) {
+                            shownInvoiceIds.add(invoiceId);
+                            if (StringUtils.isNotBlank(inv.getAccount())) {
+                                try {
+                                    double currentTotal = rowData.getInvoiceTotalAmount() != null ? rowData.getInvoiceTotalAmount() : 0;
+                                    rowData.setInvoiceTotalAmount(currentTotal + Double.parseDouble(inv.getAccount().trim()));
+                                } catch (NumberFormatException e) {
+                                    // 忽略无法解析的金额
+                                }
                             }
                         }
                         if (StringUtils.isNotBlank(inv.getStatus())) {

+ 11 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/program/configuration/projectList/utils/dto/ProvincialFilingRowData.java

@@ -23,9 +23,19 @@ public class ProvincialFilingRowData {
     /** 开票编号列表 */
     private List<String> invoiceNos = new ArrayList<>();
 
-    /** 开票金额合计 */
+    /**
+     * 开票金额合计——本报告文号对应各基本信息行开票金额(base.account)之和。
+     * 一票多报告时每个报告只累计自己那一行,避免把整票金额重复计入多个报告行。
+     * 历史未拆分单据(base.account 全为 NULL)时保持为 null,导出显示空白。
+     */
     private Double invoiceAmount = null;
 
+    /**
+     * 开票总金额——本报告文号关联的各张发票主表金额(account)按 invoiceId 去重后的合计。
+     * 同一发票跨多个报告行时,总额只在其首次出现的报告行填充,其余留空,保证整列求和等于各发票总额之和。
+     */
+    private Double invoiceTotalAmount = null;
+
     /** 是否开票(是/否) */
     private String isInvoiced = "否";