xyc
2026-09-21 abf468c9946c21512ddf2093895b30910e09e536
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -226,6 +226,36 @@
                    }
                    break;
                }
                case "investPassengerDetail":
                case "investLogisticsDetail": {
                    boolean paxDetail = "investPassengerDetail".equals(t);
                    String catName = paxDetail ? "客运站场" : "物流园区";
                    String catLabel = paxDetail ? "客运站场投资月报" : "物流园区投资月报";
                    List<InvestmentProject> catProjects = investProjectMapper.selectList(
                        new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, catName));
                    final List<Long> catIds = new ArrayList<>();
                    for (InvestmentProject cp : catProjects) catIds.add(cp.getId());
                    if (catIds.isEmpty()) {
                        rows = 0;
                        notes.add("缺数据:" + catLabel);
                    } else {
                        rows = addReadyCount(() -> investMonthlyMapper.selectCount(
                            periodQw(period, mode, InvestmentMonthly::getReportPeriod)
                                .in(InvestmentMonthly::getProjectId, catIds)), catLabel, notes);
                    }
                    String detTpl = paxDetail ? "模板_X月客运站投资明细表.xlsx" : "模板_X月物流站场投资明细.xls";
                    try {
                        if (resolveAnyTemplate(investTemplateDir, detTpl) == null) {
                            rows = 0;
                            notes.add("docs/投资/模板 下缺少《" + detTpl + "》");
                        }
                    } catch (Exception ex) {
                        rows = 0;
                        notes.add("母版定位失败:" + ex.getMessage());
                    }
                    auditTypes.add("INVEST");
                    break;
                }
                default:
                    rows = 0;
                    notes.add("未知报表类型:" + t);
@@ -381,7 +411,9 @@
                coverage.addAll(investCover);
                // 投资为当月报表(无 1..N 累计口径),只提示当月缺失
                if (!investCover.contains(month)) missing.add(month);
                investPreview(period, mode, summary, problems);
                if ("investPassengerDetail".equals(t)) investPreview(period, mode, "客运站场", summary, problems);
                else if ("investLogisticsDetail".equals(t)) investPreview(period, mode, "物流园区", summary, problems);
                else investPreview(period, mode, summary, problems);
            } else if ("energySummary".equals(t)) {
                Long ec = energyMapper.selectCount(periodQw(period, mode, EnergyVehicleQuarterly::getReportPeriod));
                if (ec != null && ec > 0) coverage.add(month);
@@ -412,7 +444,8 @@
    }
    private boolean isInvestType(String t) {
        return "investPlan".equals(t) || "investFiveYearLogistics".equals(t) || "investBillion".equals(t) || "investCounty".equals(t);
        return "investPlan".equals(t) || "investFiveYearLogistics".equals(t) || "investBillion".equals(t) || "investCounty".equals(t)
            || "investPassengerDetail".equals(t) || "investLogisticsDetail".equals(t);
    }
    private boolean isEnergyType(String t) {
@@ -668,9 +701,23 @@
        if (allZero) problems.add("全省累计值全为 0(公交/出租月报可能未导入)");
    }
    /** 投资摘要:项目数 + 本月完成/自年初/自开始累计 */
    /** 投资摘要:项目数 + 本月完成/自年初/自开始累计(全投资月报合计,不分类别) */
    private void investPreview(String period, String mode, List<Map<String, Object>> summary, List<String> problems) {
        List<InvestmentMonthly> list = investMonthlyMapper.selectList(periodQw(period, mode, InvestmentMonthly::getReportPeriod));
        investPreview(period, mode, null, summary, problems);
    }
    /** 投资摘要(category 非空时只统计该类别,如客运站场/物流园区投资明细表) */
    private void investPreview(String period, String mode, String category, List<Map<String, Object>> summary, List<String> problems) {
        LambdaQueryWrapper<InvestmentMonthly> qw = periodQw(period, mode, InvestmentMonthly::getReportPeriod);
        if (category != null) {
            List<InvestmentProject> ps = investProjectMapper.selectList(
                new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, category));
            List<Long> ids = new ArrayList<>();
            for (InvestmentProject p : ps) ids.add(p.getId());
            if (ids.isEmpty()) { problems.add("缺数据:库内没有「" + category + "」项目主档"); return; }
            qw.in(InvestmentMonthly::getProjectId, ids);
        }
        List<InvestmentMonthly> list = investMonthlyMapper.selectList(qw);
        Set<Long> proj = new HashSet<>();
        double monthDone = 0.0, yearCum = 0.0, startCum = 0.0;
        for (InvestmentMonthly m : list) {
@@ -2634,9 +2681,19 @@
     * 生成_中口径排名.xlsx:单块累计(选 1 个月=当月,选 1-6 月=累计),RANK/SUM 公式保留。
     */
    public byte[] exportPassengerMidRank(String period, String mode) throws Exception {
        return exportPassengerMidRank(period, mode, null);
    }
    /**
     * 方案 A(2026-09-21):中口径排名的累计同比基数与“中口径明细”保持同源。
     * 明细页的 2025 年列是母版静态定稿值,排名页原先查库现算,两者在 2025-03/04 网约车拆分上不同。
     * summaryWb 非空时直接复用当前正在生成的汇总工作簿;独立导出时再打开对应母版。
     */
    private byte[] exportPassengerMidRank(String period, String mode, XSSFWorkbook summaryWb) throws Exception {
        int maxMonth = monthOf(period, mode);
        int currentYear = Integer.parseInt(period.substring(0, 4));
        File template = resolvePassengerTemplate("生成_中口径排名.xlsx");
        Map<String, double[]> lastYearCumBase = loadMidRankStaticBase(period, mode, summaryWb, currentYear, maxMonth);
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
@@ -2656,8 +2713,8 @@
                title.getCell(10).setCellValue(currentYear + "年" + maxMonth + "月全省分市州完成道路客运生产情况");
            }
            // 全省行 r3:B3/F3 为 SUM 公式保留;D3/H3 同比填值(0-based 列 3/7)
            setValOrBlank(sheet, 2, 3, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, true));
            setValOrBlank(sheet, 2, 7, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, false));
            setValOrBlank(sheet, 2, 3, midRankCumYoy(mid, currentYear, "湖北省", maxMonth, true, lastYearCumBase));
            setValOrBlank(sheet, 2, 7, midRankCumYoy(mid, currentYear, "湖北省", maxMonth, false, lastYearCumBase));
            // 右块全省行 N3/R3:当月同比(L3/P3 为 SUM 公式保留)
            setValOrBlank(sheet, 2, 13, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, true));
            setValOrBlank(sheet, 2, 17, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, false));
@@ -2668,17 +2725,13 @@
                int r0 = 3 + i;
                double pass = midCumClass(mid, currentYear, city, maxMonth, 0, true);
                double turn = midCumClass(mid, currentYear, city, maxMonth, 0, false);
                double lastPass = midCumClass(mid, currentYear - 1, city, maxMonth, 0, true);
                double lastTurn = midCumClass(mid, currentYear - 1, city, maxMonth, 0, false);
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : pass);
                setValOrBlank(sheet, r0, 3, midCumYoy(mid, currentYear, city, maxMonth, 0, true));
                setValOrBlank(sheet, r0, 3, midRankCumYoy(mid, currentYear, city, maxMonth, true, lastYearCumBase));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : turn);
                setValOrBlank(sheet, r0, 7, midCumYoy(mid, currentYear, city, maxMonth, 0, false));
                setValOrBlank(sheet, r0, 7, midRankCumYoy(mid, currentYear, city, maxMonth, false, lastYearCumBase));
                // 右块当月:L/P 值、N/R 当月同比(M/O/Q/S 排名公式保留)
                double mPass = midClassVal(mid, currentYear, maxMonth, city, 0, true);
                double mTurn = midClassVal(mid, currentYear, maxMonth, city, 0, false);
                double mLastPass = midClassVal(mid, currentYear - 1, maxMonth, city, 0, true);
                double mLastTurn = midClassVal(mid, currentYear - 1, maxMonth, city, 0, false);
                setValOrBlank(sheet, r0, 11, mPass == 0 ? null : mPass);
                setValOrBlank(sheet, r0, 13, mPass == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, city, 0, true));
                setValOrBlank(sheet, r0, 15, mTurn == 0 ? null : mTurn);
@@ -2687,6 +2740,112 @@
            recalc(wb);
            return toBytes(wb);
        }
    }
    /** 读取母版《中口径明细》的 2025 年逐月静态值,汇总成排名页所需的去年同期累计基数。 */
    private Map<String, double[]> readMidDetailLastYearCumulative(XSSFWorkbook sourceWb, int currentYear, int maxMonth) {
        Map<String, double[]> out = new LinkedHashMap<>();
        if (sourceWb == null || maxMonth <= 0) return out;
        Sheet sheet = sourceWb.getSheet("中口径明细");
        if (sheet == null) return out;
        Map<Integer, Integer> monthCols = new LinkedHashMap<>();
        java.util.regex.Pattern monthHeader = java.util.regex.Pattern.compile("^(\\d{4})年(\\d{1,2})月$");
        int headerEnd = Math.min(sheet.getLastRowNum(), sheet.getFirstRowNum() + 5);
        for (int r = sheet.getFirstRowNum(); r <= headerEnd; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                String text = cellText(row.getCell(c));
                if (text == null) continue;
                java.util.regex.Matcher matcher = monthHeader.matcher(text.trim());
                if (!matcher.matches()) continue;
                int y = Integer.parseInt(matcher.group(1));
                int m = Integer.parseInt(matcher.group(2));
                if (y == currentYear - 1 && m >= 1 && m <= maxMonth) monthCols.putIfAbsent(m, c);
            }
        }
        if (monthCols.size() < maxMonth) return out;
        for (int r = sheet.getFirstRowNum(); r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            String city = RegionUtil.normalizeCityName(cellText(row.getCell(0)));
            if (city == null || !RegionUtil.CITY_LIST.contains(city)) continue;
            String metric = cellText(row.getCell(1));
            if (metric == null) continue;
            int idx = metric.contains("总旅客周转量") ? 1 : (metric.contains("总客运量") ? 0 : -1);
            if (idx < 0) continue;
            double sum = 0.0;
            boolean complete = true;
            for (int m = 1; m <= maxMonth; m++) {
                Cell c = row.getCell(monthCols.get(m));
                if (c == null) { complete = false; break; }
                if (c.getCellType() == CellType.NUMERIC) {
                    sum += c.getNumericCellValue();
                } else if (c.getCellType() == CellType.FORMULA && c.getCachedFormulaResultType() == CellType.NUMERIC) {
                    sum += c.getNumericCellValue();
                } else {
                    complete = false;
                    break;
                }
            }
            double[] values = out.computeIfAbsent(city, k -> new double[]{Double.NaN, Double.NaN});
            values[idx] = complete ? sum : Double.NaN;
        }
        double[] province = new double[2];
        for (String city : RegionUtil.CITY_LIST) {
            double[] values = out.get(city);
            if (values == null || Double.isNaN(values[0]) || Double.isNaN(values[1])) return new LinkedHashMap<>();
            province[0] += values[0];
            province[1] += values[1];
        }
        out.put("湖北省", province);
        return out;
    }
    /** 独立导出时从对应汇总母版读取静态基数;汇总导出时直接复用内存中的工作簿,避免重复 I/O。 */
    private Map<String, double[]> loadMidRankStaticBase(String period, String mode, XSSFWorkbook summaryWb,
                                                       int currentYear, int maxMonth) {
        if (summaryWb != null) {
            Map<String, double[]> base = readMidDetailLastYearCumulative(summaryWb, currentYear, maxMonth);
            if (!base.isEmpty()) return base;
            log.warn("中口径排名:当前汇总工作簿的中口径明细未读到完整 2025 静态累计基数,回退库内现算");
            return null;
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        ZipSecureFile.setMinInflateRatio(0.0001);
        try {
            File mother = resolveSummaryMother(period, mode);
            if (mother == null || !mother.isFile()) {
                log.warn("中口径排名:未找到 {} 的汇总母版,2025 累计基数回退库内现算", period);
                return null;
            }
            try (InputStream in = new FileInputStream(mother); XSSFWorkbook wb = new XSSFWorkbook(in)) {
                Map<String, double[]> base = readMidDetailLastYearCumulative(wb, currentYear, maxMonth);
                if (base.isEmpty()) {
                    log.warn("中口径排名:母版 {} 的中口径明细未读到完整 2025 静态累计基数,回退库内现算", mother.getName());
                    return null;
                }
                log.info("中口径排名:2025 累计基数改用母版静态列({})", mother.getName());
                return base;
            }
        } catch (Exception e) {
            log.warn("中口径排名:读取母版 2025 静态累计基数失败,回退库内现算:{}", e.getMessage());
            return null;
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /** 中口径排名累计同比:方案 A 有静态基数时与其对齐,否则保留原库内现算逻辑。 */
    private Double midRankCumYoy(Map<Integer, Map<String, double[][]>> mid, int year, String area,
                                 int monthCount, boolean volume, Map<String, double[]> staticBase) {
        if (staticBase == null) return midCumYoy(mid, year, area, monthCount, 0, volume);
        double[] values = staticBase.get(area);
        if (values == null) return null;
        double current = midCumClass(mid, year, area, monthCount, 0, volume);
        if (current == 0.0) return null;
        double base = volume ? values[0] : values[1];
        return base == 0.0 ? null : yoyOf(current, base);
    }
    /** 写数值或清空(公式单元格保留不动) */
@@ -3460,7 +3619,7 @@
     * 保留全部页签/版式/公式/2025 年缓存值,仅把表头年份动态化为目标年;数据填充按 P2 起逐步接入。
     */
    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
        return exportSummaryWorkbook(period, mode, null);
        return exportSummaryWorkbook(period, mode, null, null);
    }
    /** 生成_道路运输量汇总表.xlsx(P0-P1 骨架,支持 toMonth 裁剪):
@@ -3468,6 +3627,13 @@
     *  toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头,
     *  累计与累计同比公式改为按 toMonth 月重算),用于“先生成 1-6 月、1-7 月扩列”验证。 */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth) throws Exception {
        return exportSummaryWorkbook(period, mode, toMonth, null);
    }
    /** 同上,额外通过 progress 回调报告进度(异步导出任务用:步骤百分比 + 步骤文案) */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth,
                                        java.util.function.BiConsumer<Integer, String> progress) throws Exception {
        reportProgress(progress, 2, "校验报表期与母版");
        int year = Integer.parseInt(period.contains("-") ? period.split("-")[0] : period);
        int month = period.contains("-") ? monthOf(period, mode) : 12;
        File template = resolveSummaryMother(period, mode);
@@ -3475,9 +3641,11 @@
            throw new RuntimeException("未找到汇总表母版《" + year + "年" + month + "月道路运输量汇总表.xlsx》(docs/生成汇总大表)");
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        reportProgress(progress, 6, "读取母版工作簿");
        ZipSecureFile.setMinInflateRatio(0.0001); // 新版母版 styles.xml 由 openpyxl 高压缩(0.0099<0.01)触发 POI Zip-bomb 误报,读取后还原
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            reportProgress(progress, 12, "还原同期月度块");
            File donor = resolveSummaryDonor(year, month);
            if (donor != null) {
                try (InputStream din = new FileInputStream(donor); XSSFWorkbook dwb = new XSSFWorkbook(din)) {
@@ -3496,26 +3664,42 @@
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            reportProgress(progress, 22, "回填当月数据");
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);
            int monthFormulaFilled = summaryWorkbookFiller.ensureMonthFormulas(wb, year, effMonth);
            // 累计同比分母修正:母版把「去年各月」与「去年1-M月累计」辅助列同时计入(重复计/漏当月)
            int cumYoyFixed = summaryWorkbookFiller.repairCumulativeYoyFormulas(wb, year, effMonth);
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新
            int widthApplied = applySummaryReferenceLayout(wb);
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
            applyTwoDecimalFormat(wb); // 数值显示两位小数(保留全精度);% / 日期等既有样式不改变
            int inlineFixed = normalizeStaleInlineStrings(wb); // 清掉「非 inlineStr 却残留 <is>」的非法格,否则 Excel 拒绝打开
            if (inlineFixed > 0) log.warn("汇总工作簿清掉残留 inlineStr 非法格 {} 个(母版由 openpyxl 写出时的典型问题)", inlineFixed);
            reportProgress(progress, 70, "重算整本公式(约 4 万格)");
            int[] recalc = recalcWorkbookFormulas(wb); // 公式重算并写回缓存值:全省行(Σ市州)/累计列/排名 SUM 才能被预览与二次处理读到
            log.info("汇总工作簿公式重算完成:成功 {} 格,失败 {} 格(失败格保留母版旧缓存值,打开时按 fullCalcOnLoad 重算)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */
    private static void reportProgress(java.util.function.BiConsumer<Integer, String> progress, int percent, String step) {
        if (progress == null) return;
        try {
            progress.accept(percent, step);
        } catch (Exception ignore) {
            // 进度回调失败不影响导出
        }
    }
@@ -3682,7 +3866,7 @@
        int n = 0;
        n += copyRankValues(wb, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode), new int[][]{{0, 0}, {0, 10}});
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}});
        return n;
    }
@@ -5381,6 +5565,7 @@
                hssfText(row, 0, "合计");
                InvestAgg prov = new InvestAgg();
                for (InvestmentMonthly m : monthlies) prov.add(m, pmap.get(m.getProjectId()));
                addProjectsWithoutMonthly(prov, projects, mByProject, null, null);
                writeInvestAggHssf(row, prov);
                continue;
            }
@@ -5393,6 +5578,7 @@
                    InvestmentProject p = pmap.get(m.getProjectId());
                    if (p != null && blockCategory.equals(p.getCategory())) agg.add(m, p);
                }
                addProjectsWithoutMonthly(agg, projects, mByProject, blockCategory, null);
                writeInvestAggHssf(row, agg);
                seq = 0;
                continue;
@@ -5421,6 +5607,7 @@
                    if (p != null && city.equals(p.getCity())
                        && (blockCategory == null || blockCategory.equals(p.getCategory()))) agg.add(m, p);
                }
                addProjectsWithoutMonthly(agg, projects, mByProject, blockCategory, city);
                writeInvestAggHssf(row, agg);
                continue;
            }
@@ -5468,6 +5655,24 @@
        if (agg.yearPlan > 0) hssfNum(row, 23, agg.yearCum / agg.yearPlan);
    }
    /**
     * 补计「本期没有月度记录、但项目主档有总投资」的项目(只计总投资这一列)。
     * 场景:某市州本月未报送单表(如武汉 8 月物流),主档里项目仍在,若不补计,该市州小计/合计会整块为空。
     *
     * @param category 块类别(客运站场/物流园区);null 表示不限
     * @param city     市州全称(如 武汉市);null 表示不限
     */
    private void addProjectsWithoutMonthly(InvestAgg agg, List<InvestmentProject> projects,
                                           Map<Long, InvestmentMonthly> mByProject,
                                           String category, String city) {
        for (InvestmentProject p : projects) {
            if (p == null || mByProject.containsKey(p.getId())) continue;
            if (category != null && !category.equals(p.getCategory())) continue;
            if (city != null && !city.equals(p.getCity())) continue;
            if (p.getTotalInvestment() != null) agg.total += p.getTotalInvestment();
        }
    }
    // ==================== 投资模块:客运站投资明细表 / 物流站场投资明细(模板底稿原位替换) ====================