feat: 汇总工作簿月度块按备份母版还原(1-4月骨架→1-7月)试点 + fullCalcOnLoad + 09-04 测试报告
1个文件已修改
1个文件已添加
121 ■■■■■ 已修改文件
docs/功能测试报告/功能测试报告_汇总工作簿月度块扩列还原试点_2026-09-04.md 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_»ã×ܹ¤×÷²¾Ô¶ȿéÀ©Áл¹Ô­ÊÔµã_2026-09-04.md
New file
@@ -0,0 +1,30 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Šï¼šæ±‡æ€»å·¥ä½œç°¿æœˆåº¦å—æ‰©åˆ—/还原试点(2026-07)(2026-09-04)
## åŠŸèƒ½è¯´æ˜Ž
用户拍板:以《2026å¹´7月道路运输量汇总表.xlsx》(已清理为“1-4 æœˆéª¨æž¶â€ï¼‰ä¸ºæ¯ç‰ˆï¼Œç”±ç¨‹åºæŠŠ 5-7 æœˆæœˆåº¦å—按同期备份母版还原,实现“逐月扩列生成 1-N æœˆæ±‡æ€»è¡¨â€çš„第一步试点(M1,目标期 2026-07)。口径:1-4 æœˆä¿ç•™æ¯ç‰ˆæ•°æ®ï¼›ç´¯è®¡å…¬å¼æ¢å¤ä¸º 1-7 æœˆï¼›åŒæ¯” R åˆ†æ¯ç”¨ 2025 å¹´ 1-7 æœˆåˆ—ï¼›2025/2024 å‚考列保持不动;输出另存新文件,不改母版。
代码改动:
- traffic-audit-server/.../reportexport/service/ReportExportService.java
  - exportSummaryWorkbook å¢žåŠ ï¼šå®šä½åŒæœŸå¤‡ä»½æ¯ç‰ˆï¼ˆ_备份_*_清理前_*.xlsx,按 {å¹´}å¹´{月}月 åŒ¹é…ï¼Œæœç´¢é…ç½®ç›®å½•/user.dir/上级目录三个候选根)→ é€æ ¼æŠŠâ€œå¤‡ä»½æœ‰å†…容而骨架缺失或内容不同”的单元格回填(含 5-7 æœˆè¡¨å¤´ã€æ•°æ®ã€å…¬å¼ä¸Žç´¯è®¡/同比公式);
  - æ–°å¢ž resolveSummaryDonor / restoreSummaryMonthlyBlocks / summaryCellText ä¸‰ä¸ªç§æœ‰æ–¹æ³•ï¼›
  - å¯¼å‡ºæ—¶ç½® fullCalcOnLoad=true,保证 Excel/WPS æ‰“开即重算被改写/还原的公式。
## æµ‹è¯•环境
- åŽç«¯ 8090(多次重启后最新代码就绪);MySQL 127.0.0.1:3308。
- æ¯ç‰ˆï¼šdocs/生成汇总大表/2026å¹´7月道路运输量汇总表.xlsx(1-4 æœˆéª¨æž¶ï¼Œ19 é¡µç­¾ï¼‰ï¼›å¤‡ä»½ï¼šdocs/生成汇总大表/_备份_2026å¹´7月道路运输量汇总表_清理前_20260904.xlsx。
- æ–¹æ³•:调用 GET /api/report/export/summaryWorkbook?period=2026-07&mode=month,保存为 docs/生成汇总大表/输出/道路运输量汇总表_2026-07.xlsx,用 openpyxl ä¸Žå¤‡ä»½æ¯ç‰ˆé€æ ¼æ¯”对(内容/公式文本,非样式)。
## ç”¨ä¾‹ä¸Žç»“æžœ
1. è¿˜åŽŸå¹¿åº¦ï¼ˆæ­£å‘ï¼‰ï¼šæ¸…ç†æ—¶è¢«è£çš„ 7 ä¸ªå°è´¦/合成页(货运/班线包车/城市客运/公交/出租/网约车/轨道轮渡/中口径明细)缺失单元格全部补齐,missing=0;其余页(成品页/隐藏页)本无差异,未改动。结论 PASS。
2. é€æ ¼å¯¹ç…§ï¼ˆæ­£å‘):输出与备份母版 19 é¡µå…¨é‡æ¯”对,real-diff=0;仅 31 æ ¼ä¸ºâ€œæ•´æ•° vs 46143.0”的数值等价表示差异(Excel æ•°å€¼ç›¸åŒï¼‰ã€‚结论 PASS。
3. ç´¯è®¡/同比公式(正向):抽查 ç­çº¿åŒ…车/公交/出租/货运 ç­‰è¡Œ Q æ¢å¤ä¸º 1-7 æœˆå„月值求和(如 =G4+E4+C4+I4+K4+M4+O4),R æ¢å¤ä¸º =Q/SUM(2025 å¹´ 1-7 æœˆåˆ—)-1,与备份一致。结论 PASS。
4. æ‰“开即重算(正向):产物 xl/workbook.xml å« <calcPr fullCalcOnLoad="true"/>。结论 PASS。
5. æ¯ç‰ˆæœªè¢«æ”¹åŠ¨ï¼ˆæ­£å‘ï¼‰ï¼šç”Ÿæˆå‰åŽæ¯ç‰ˆæ–‡ä»¶å­—èŠ‚æœªå˜ï¼ˆåªè¯»å¤åˆ¶ï¼‰ã€‚ç»“è®º PASS。
6. ç¼–译与启动:mvn compile EXIT=0;后端重启就绪;接口 200 è¿”回 xlsx。结论 PASS。
## å‘现的问题与处置
1. åˆç‰ˆ donor å®šä½åªæŸ¥é…ç½®ç›®å½•相对路径,而 spring-boot:run çš„工作目录是模块目录(traffic-audit-server),导致“未找到备份母版”、输出仍为骨架:已改为与 resolveAnyTemplate ç›¸åŒçš„ 3 å€™é€‰æ ¹ç›®å½•扫描,复测还原成功。
2. POI setForceFormulaRecalculation æœªå†™ calcPr:改为直接操作 XSSFWorkbook.getCTWorkbook() çš„ calcPr.fullCalcOnLoad=true,已验证写入。
## ç»“论
M1 è¯•点通过:从 1-4 æœˆéª¨æž¶æ¯ç‰ˆå¯ç¨‹åºåŒ–还原出与人工 7 æœˆæ¯ç‰ˆå†…容零差异的完整工作簿(含 5-7 æœˆæœˆåº¦å—与 1-7 ç´¯è®¡/同比公式),输出文件 docs/生成汇总大表/输出/道路运输量汇总表_2026-07.xlsx。下一步 M1b:月份>7(8-12 æœˆï¼‰æ—¶æŒ‰â€œä¸Šä¸€æœˆå—克隆+列引用平移”自动插入新列(备份无对应月份时用),再接入 M2 å°è´¦æ•°æ®ä»Žåº“内填充。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -3207,9 +3207,20 @@
        }
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            File donor = resolveSummaryDonor(year, month);
            if (donor != null) {
                try (InputStream din = new FileInputStream(donor); XSSFWorkbook dwb = new XSSFWorkbook(din)) {
                    int restored = restoreSummaryMonthlyBlocks(wb, dwb);
                    log.info("汇总工作簿月度块还原完成,补齐/覆盖单元格数={}(母版:{},备份母版:{})", restored, template.getName(), donor.getName());
                }
            } else {
                log.warn("汇总工作簿未找到同期备份母版(_备份_{}å¹´{}月…),输出将保持母版现状(可能为 1-4 æœˆéª¨æž¶ï¼‰", year, month);
            }
            dynamicSummaryYear(wb, year);
            applyTwoDecimalFormat(wb);
            autoFitContentColumns(wb); // æ±‡æ€»è¡¨å¯¼å‡ºåŽæŒ‰å†…容加宽列宽(只加宽不缩窄)
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // è¿˜åŽŸ/改写公式后打开即重算
            return toBytes(wb);
        }
    }
@@ -3476,6 +3487,86 @@
        return map;
    }
    /** å®šä½åŒæœŸå¤‡ä»½æ¯ç‰ˆï¼ˆæ¸…理前的完整月母版),用于把骨架母版缺失的 5-7 æœˆæœˆåº¦å—按备份还原;
     *  ä¸Ž resolveAnyTemplate ç›¸åŒç­–略:在配置目录、user.dir ç›¸å¯¹ç›®å½•、上级目录三个候选根中扫描 */
    private File resolveSummaryDonor(int year, int month) {
        String rel = summaryTemplateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {
            summaryTemplateDir,
            System.getProperty("user.dir") + "/" + rel,
            System.getProperty("user.dir") + "/../" + rel
        };
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File dir = new File(root);
            File[] files = dir.listFiles((d, name) ->
                name.startsWith("_备份_") && name.contains(year + "å¹´" + month + "月") && name.contains("道路运输量汇总表"));
            if (files != null && files.length > 0) return files[0];
        }
        return null;
    }
    /** æŒ‰åŒæœŸå¤‡ä»½æ¯ç‰ˆè¿˜åŽŸæœˆåº¦å—ï¼šå‡¡å¤‡ä»½æœ‰å†…å®¹è€Œæ¯ç‰ˆåŒæ ¼ç¼ºå¤±/内容不同(清理时被裁掉的 5-7 æœˆå€¼ä¸Žç´¯è®¡å…¬å¼ï¼‰çš„单元格,整格回填并覆盖 */
    private int restoreSummaryMonthlyBlocks(XSSFWorkbook wb, XSSFWorkbook donor) {
        int restored = 0;
        for (int i = 0; i < donor.getNumberOfSheets(); i++) {
            Sheet ds = donor.getSheetAt(i);
            if (ds == null) continue;
            Sheet bs = wb.getSheet(ds.getSheetName());
            if (bs == null) continue;
            for (int r = ds.getFirstRowNum(); r <= ds.getLastRowNum(); r++) {
                Row dr = ds.getRow(r);
                if (dr == null) continue;
                Row br = bs.getRow(r);
                for (Cell dc : dr) {
                    if (dc == null) continue;
                    CellType t = dc.getCellType();
                    if (t == CellType.BLANK) continue;
                    String dtx = summaryCellText(dc);
                    if (dtx == null || dtx.isEmpty()) continue;
                    Cell bc = (br == null) ? null : br.getCell(dc.getColumnIndex());
                    if (bc != null && dtx.equals(summaryCellText(bc))) continue;
                    Row tr = (bc != null) ? br : bs.getRow(r);
                    if (tr == null) tr = bs.createRow(r);
                    Cell tc = (bc != null) ? bc : tr.getCell(dc.getColumnIndex());
                    if (tc == null) tc = tr.createCell(dc.getColumnIndex());
                    tc.setBlank();
                    if (t == CellType.FORMULA) {
                        String f = dc.getCellFormula();
                        if (f != null) tc.setCellFormula(f);
                    } else if (t == CellType.STRING) {
                        tc.setCellValue(dc.getStringCellValue());
                    } else if (t == CellType.NUMERIC) {
                        tc.setCellValue(dc.getNumericCellValue());
                    } else if (t == CellType.BOOLEAN) {
                        tc.setCellValue(dc.getBooleanCellValue());
                    }
                    restored++;
                }
            }
        }
        return restored;
    }
    /** å•元格内容文本化(公式带 = å‰ç¼€ï¼‰ï¼Œç”¨äºŽè·¨ç°¿æ¯”对 */
    private String summaryCellText(Cell c) {
        if (c == null) return null;
        switch (c.getCellType()) {
            case FORMULA: {
                String f = c.getCellFormula();
                return f == null ? "" : "=" + f;
            }
            case STRING:
                return c.getStringCellValue();
            case NUMERIC:
                return Double.toString(c.getNumericCellValue());
            case BOOLEAN:
                return Boolean.toString(c.getBooleanCellValue());
            default:
                return "";
        }
    }
    /** æ±‡æ€»å·¥ä½œç°¿å¹´ä»½åŠ¨æ€åŒ–ï¼šä»…æŠŠ 2026å¹´ å‰ç¼€æ›¿æ¢ä¸ºç›®æ ‡å¹´ï¼ˆ2025/2024 å‚考列不动) */
    private void dynamicSummaryYear(XSSFWorkbook wb, int year) {
        String oldPrefix = "2026å¹´";