| | |
| | | import com.trafficaudit.reportexport.calc.MidCalc; |
| | | import com.trafficaudit.reportexport.calc.PaxCalc; |
| | | import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller; |
| | | import com.trafficaudit.reportexport.calc.FreightReadinessCheck; |
| | | import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck; |
| | | import com.trafficaudit.reportexport.layout.SummaryLayoutExpander; |
| | | import com.trafficaudit.reportexport.calc.WycSplitCalc; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.apache.poi.ss.usermodel.Cell; |
| | | import org.apache.poi.ss.usermodel.CellStyle; |
| | | import org.apache.poi.ss.usermodel.CellType; |
| | | import org.apache.poi.ss.usermodel.Font; |
| | | import org.apache.poi.ss.usermodel.HorizontalAlignment; |
| | | import org.apache.poi.ss.usermodel.IndexedColors; |
| | | import org.apache.poi.ss.usermodel.Row; |
| | | import org.apache.poi.ss.usermodel.Sheet; |
| | | import org.apache.poi.ss.usermodel.VerticalAlignment; |
| | | import org.apache.poi.ss.usermodel.FormulaEvaluator; |
| | | import org.apache.poi.xssf.usermodel.XSSFSheet; |
| | | import org.apache.poi.xssf.usermodel.XSSFWorkbook; |
| | |
| | | private CityTaxiMonthlyMapper cityTaxiMapper; |
| | | @Resource |
| | | private SummaryWorkbookFiller summaryWorkbookFiller; |
| | | @Resource |
| | | private SummaryLayoutExpander summaryLayoutExpander; |
| | | @Resource |
| | | private FreightReadinessCheck freightReadinessCheck; |
| | | @Resource |
| | | private SummaryMissingDataCheck missingDataCheck; |
| | | @Resource |
| | | private WycSplitCalc wycSplitCalc; |
| | | @Resource |
| | |
| | | Map<String, ScaleSplitTransport> result = new HashMap<>(); |
| | | List<ScaleSplitTransport> list = scaleSplitMapper.selectList( |
| | | new LambdaQueryWrapper<ScaleSplitTransport>() |
| | | .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")); |
| | | .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE") |
| | | .eq(ScaleSplitTransport::getReportPeriod, period)); // 2026-10-12:只认目标期,缺数留空、不静默回退上一期 |
| | | for (ScaleSplitTransport record : list) { |
| | | if (record.getReportPeriod() == null) continue; |
| | | if (record.getReportPeriod().compareTo(period) > 0) continue; |
| | | if (!period.equals(record.getReportPeriod())) continue; |
| | | String city = RegionUtil.normalizeCityName(record.getRegionName()); |
| | | if (city == null) continue; |
| | | if ("湖北省".equals(city)) continue; |
| | |
| | | private ScaleSplitTransport getProvinceCumulative(String period) { |
| | | List<ScaleSplitTransport> list = scaleSplitMapper.selectList( |
| | | new LambdaQueryWrapper<ScaleSplitTransport>() |
| | | .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")); |
| | | .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE") |
| | | .eq(ScaleSplitTransport::getReportPeriod, period)); // 2026-10-12:同上(只认目标期) |
| | | ScaleSplitTransport best = null; |
| | | for (ScaleSplitTransport record : list) { |
| | | if (record.getReportPeriod() == null) continue; |
| | | if (record.getReportPeriod().compareTo(period) > 0) continue; |
| | | if (!period.equals(record.getReportPeriod())) continue; |
| | | String name = RegionUtil.normalizeCityName(record.getRegionName()); |
| | | if (!"湖北省".equals(name)) continue; |
| | | if (best == null || record.getReportPeriod().compareTo(best.getReportPeriod()) > 0) best = record; |
| | |
| | | * 生成_中口径排名.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); |
| | |
| | | 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)); |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | |
| | | /** 写数值或清空(公式单元格保留不动) */ |
| | |
| | | return exportSummaryWorkbook(period, mode, null, null); |
| | | } |
| | | |
| | | /** |
| | | * 汇总大表「按月扩列」(2026-10-09 Step 1):把 period 当月母版扩列成下一月版式。 |
| | | * 只做版式——当月数据列留空;不落盘,由调用方决定是否确认后另存为下期母版。 |
| | | */ |
| | | public byte[] expandSummaryWorkbook(String period) throws Exception { |
| | | if (period == null || !period.contains("-")) { |
| | | throw new RuntimeException("扩列仅支持按月,请指定 period=YYYY-MM"); |
| | | } |
| | | int year = Integer.parseInt(period.split("-")[0]); |
| | | int month = monthOf(period, "month"); |
| | | if (month >= 12) { |
| | | throw new RuntimeException("扩列:12 月之后需新开一本,跨年规则另行处理"); |
| | | } |
| | | // 2026-10-12:扩列以「当期产物」为底(产物含当期列的值与公式;母版的当期列是空的, |
| | | // 直接拿母版扩列会把当期数据丢成空洞,导致累计漏月)。产物取自导出落盘缓存;缺产物时回退母版并告警。 |
| | | File source = resolveLatestProduct(period); |
| | | boolean fromProduct = source != null; |
| | | if (source == null) { |
| | | source = resolveSummaryMother(period, "month"); |
| | | if (source != null) { |
| | | log.warn("汇总大表扩列:未找到 {} 的产物缓存,回退用母版扩列——历史列里缺少当期数据,累计可能漏月", period); |
| | | } |
| | | } |
| | | if (source == null) { |
| | | throw new RuntimeException("未找到汇总表母版或产物(docs/生成汇总大表):" + year + "年" + month + "月道路运输量汇总表.xlsx"); |
| | | } |
| | | try (InputStream in = new FileInputStream(source)) { |
| | | byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1); |
| | | log.info("汇总大表扩列完成:{}{} -> {}年{}月版式(只做版式、当期列留空)", |
| | | fromProduct ? "产物" : "母版", source.getName(), year, month + 1); |
| | | return out; |
| | | } |
| | | } |
| | | |
| | | /** 取该期「最新产物」:导出落盘缓存 _cache/summaryWorkbook_<period>_month_v*.xlsx(按修改时间取最新) */ |
| | | private File resolveLatestProduct(String period) { |
| | | try { |
| | | String rel = summaryTemplateDir; |
| | | while (rel != null && rel.startsWith("./")) rel = rel.substring(2); |
| | | String[] roots = { |
| | | summaryTemplateDir, |
| | | System.getProperty("user.dir") + "/" + rel, |
| | | System.getProperty("user.dir") + "/../" + rel |
| | | }; |
| | | String prefix = "summaryWorkbook_" + period + "_month_"; |
| | | for (String root : roots) { |
| | | if (root == null || root.trim().isEmpty()) continue; |
| | | File dir = new File(root, "_cache"); |
| | | if (!dir.isDirectory()) continue; |
| | | File[] files = dir.listFiles((d, name) -> name.startsWith(prefix) && name.endsWith(".xlsx")); |
| | | if (files == null || files.length == 0) continue; |
| | | File latest = files[0]; |
| | | for (File f : files) { |
| | | if (f.lastModified() > latest.lastModified()) latest = f; |
| | | } |
| | | return latest; |
| | | } |
| | | return null; |
| | | } catch (Exception e) { |
| | | log.warn("汇总大表扩列:查找产物缓存失败(忽略):{}", e.getMessage()); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** 只修正当期母版里 7 个标题页的标题文字(按母版期次重写,用于修复滞后标题)。 */ |
| | | public byte[] fixSummaryTitles(String period) throws Exception { |
| | | if (period == null || !period.contains("-")) { |
| | | throw new RuntimeException("标题修正仅支持按月,请指定 period=YYYY-MM"); |
| | | } |
| | | int year = Integer.parseInt(period.split("-")[0]); |
| | | int month = monthOf(period, "month"); |
| | | File mother = resolveSummaryMother(period, "month"); |
| | | if (mother == null) { |
| | | throw new RuntimeException("未找到汇总表母版(docs/生成汇总大表):" + year + "年" + month + "月道路运输量汇总表.xlsx"); |
| | | } |
| | | try (InputStream in = new FileInputStream(mother)) { |
| | | return summaryLayoutExpander.fixTitles(org.apache.poi.util.IOUtils.toByteArray(in), year, month); |
| | | } |
| | | } |
| | | |
| | | /** 生成_道路运输量汇总表.xlsx(P0-P1 骨架,支持 toMonth 裁剪): |
| | | * 以《YYYY年M月道路运输量汇总表.xlsx》(M 由 period 决定)为母版,按同期备份母版还原月度块; |
| | | * toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头, |
| | |
| | | int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month; |
| | | List<String> dbProblems = new ArrayList<>(); |
| | | String effPeriod = String.format("%04d-%02d", year, effMonth); |
| | | dbProblems.addAll(freightReadinessCheck.check(effPeriod)); // B4:货运口径导出前体检(6 条规则) |
| | | dbProblems.addAll(missingDataCheck.check(effPeriod)); // 缺数清单(该有的数没有) |
| | | 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); |
| | | // 2026-10-13:累计列 / 累计同比分母按表头重建(根治扩列器早期“累计漏月”的历史欠账) |
| | | int cumRebuilt = summaryWorkbookFiller.repairCumulativeFormulas(wb, year, effMonth); |
| | | reportProgress(progress, 45, "刷新排名页与跨页合成页"); |
| | | int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month"); |
| | | int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month", dbProblems); |
| | | int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新 |
| | | int widthApplied = applySummaryReferenceLayout(wb); |
| | | // 2026-10-12:停用「套用外部参照件《生成_道路运输量汇总表.xlsx》列宽」——版式一律以当期母版为准, |
| | | // 避免母版与参照件两处维护(母版已并入参照件的列宽/隐藏)。 |
| | | int widthApplied = 0; |
| | | log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={}," |
| | | + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={}," |
| | | + "修正累计同比分母格数={},重建累计/累计同比格数={},刷新排名页数值格数={}," |
| | | + "刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={}," |
| | | + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}", |
| | | dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems); |
| | | dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, cumRebuilt, 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 才能被预览与二次处理读到 |
| | | setSheetViewsToCurrentMonth(wb, year, effMonth); // 打开时定位到本期数据列(不影响数据与格式) |
| | | log.info("汇总工作簿公式重算完成:成功 {} 格,失败 {} 格(失败格保留母版旧缓存值,打开时按 fullCalcOnLoad 重算)", recalc[0], recalc[1]); |
| | | if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr(); |
| | | wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算 |
| | | appendMissingDataSheet(wb, effPeriod, dbProblems); // 2026-10-12:缺数/体检结果写进生成件(用户要求) |
| | | reportProgress(progress, 95, "写出文件"); |
| | | return toBytes(wb); |
| | | return normalizeSheetViews(toBytes(wb), wb, year, effMonth); |
| | | } finally { |
| | | ZipSecureFile.setMinInflateRatio(savedZipRatio); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 打开工作簿时的定位(2026-10-13):把每个页签的滚动/选中位置归一到「本期数据列」, |
| | | * 并清掉母版(被 Excel/WPS 存过)残留的旧 topLeftCell/activeCell —— 否则每个页签会停在 |
| | | * 上次编辑时的奇怪位置(实测母版里 城市客运 BQ38、中口径明细 CC103、货运排名 A40…)。 |
| | | * 冻结拆分(xSplit/ySplit/state)原样保留,只把锚点挪到本期月份列。 |
| | | */ |
| | | private byte[] normalizeSheetViews(byte[] xlsx, XSSFWorkbook wb, int year, int month) { |
| | | try { |
| | | java.util.Map<String, String> targets = sheetViewTargets(wb, year, month); |
| | | if (targets.isEmpty()) return xlsx; |
| | | java.util.Map<String, byte[]> entries = new java.util.LinkedHashMap<>(); |
| | | try (java.util.zip.ZipInputStream zin = new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(xlsx))) { |
| | | java.util.zip.ZipEntry e; |
| | | byte[] buf = new byte[8192]; |
| | | while ((e = zin.getNextEntry()) != null) { |
| | | java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream(); |
| | | int n; |
| | | while ((n = zin.read(buf)) > 0) b.write(buf, 0, n); |
| | | entries.put(e.getName(), b.toByteArray()); |
| | | } |
| | | } |
| | | byte[] wbx = entries.get("xl/workbook.xml"); |
| | | byte[] rlx = entries.get("xl/_rels/workbook.xml.rels"); |
| | | if (wbx == null || rlx == null) return xlsx; |
| | | String wbs = new String(wbx, java.nio.charset.StandardCharsets.UTF_8); |
| | | String rls = new String(rlx, java.nio.charset.StandardCharsets.UTF_8); |
| | | java.util.Map<String, String> rid2t = new java.util.HashMap<>(); |
| | | java.util.regex.Matcher rm = java.util.regex.Pattern.compile("Id=\"([^\"]+)\"[^>]*Target=\"([^\"]+)\"").matcher(rls); |
| | | while (rm.find()) rid2t.put(rm.group(1), rm.group(2)); |
| | | java.util.Map<String, String> name2part = new java.util.LinkedHashMap<>(); |
| | | java.util.regex.Matcher sm = java.util.regex.Pattern.compile("<sheet[^>]*name=\"([^\"]+)\"[^>]*r:id=\"([^\"]+)\"").matcher(wbs); |
| | | while (sm.find()) { |
| | | String t = rid2t.get(sm.group(2)); |
| | | if (t == null) continue; |
| | | if (t.startsWith("/")) t = t.substring(1); |
| | | else if (!t.startsWith("xl/")) t = "xl/" + t; |
| | | name2part.put(sm.group(1), t); |
| | | } |
| | | int patched = 0; |
| | | for (java.util.Map.Entry<String, String> en : name2part.entrySet()) { |
| | | String target = targets.get(en.getKey()); |
| | | byte[] part = entries.get(en.getValue()); |
| | | if (target == null || part == null) continue; |
| | | String xml = new String(part, java.nio.charset.StandardCharsets.UTF_8); |
| | | String np = patchSheetView(xml, target); |
| | | if (!np.equals(xml)) { entries.put(en.getValue(), np.getBytes(java.nio.charset.StandardCharsets.UTF_8)); patched++; } |
| | | } |
| | | try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(xlsx.length); |
| | | java.util.zip.ZipOutputStream zout = new java.util.zip.ZipOutputStream(bos)) { |
| | | for (java.util.Map.Entry<String, byte[]> en : entries.entrySet()) { |
| | | zout.putNextEntry(new java.util.zip.ZipEntry(en.getKey())); |
| | | zout.write(en.getValue()); |
| | | zout.closeEntry(); |
| | | } |
| | | zout.finish(); |
| | | log.info("汇总工作簿:页签打开位置已归一到本期({}年{}月),处理 {} 个页签", year, month, patched); |
| | | return bos.toByteArray(); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.warn("汇总工作簿:页签打开位置归一失败(忽略,保留原样):{}", ex.toString()); |
| | | return xlsx; |
| | | } |
| | | } |
| | | |
| | | /** 每页的定位目标:有本期月份列 → 该月表头格;没有(排名/合成页)→ A1(顺带清掉残留位置) */ |
| | | private java.util.Map<String, String> sheetViewTargets(XSSFWorkbook wb, int year, int month) { |
| | | java.util.Map<String, String> m = new java.util.LinkedHashMap<>(); |
| | | for (int i = 0; i < wb.getNumberOfSheets(); i++) { |
| | | XSSFSheet sh = wb.getSheetAt(i); |
| | | if (sh == null) continue; |
| | | int headerRow = -1, col = -1; |
| | | int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4); |
| | | for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) { |
| | | Integer ym = smYearMonthOf(row.getCell(c)); |
| | | if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; } |
| | | } |
| | | } |
| | | m.put(sh.getSheetName(), col >= 0 ? a1Ref(headerRow, col) : "A1"); |
| | | } |
| | | return m; |
| | | } |
| | | |
| | | /** 0 基行列 -> "S3" */ |
| | | private String a1Ref(int row0, int col0) { |
| | | StringBuilder sb = new StringBuilder(); |
| | | int n = col0 + 1; |
| | | while (n > 0) { int rem = (n - 1) % 26; sb.insert(0, (char) ('A' + rem)); n = (n - 1) / 26; } |
| | | return sb.toString() + (row0 + 1); |
| | | } |
| | | |
| | | /** 去掉属性 */ |
| | | private String stripAttr(String attrs, String name) { |
| | | int i = attrs.indexOf(name + "=\""); |
| | | if (i < 0) return attrs; |
| | | int e = attrs.indexOf('"', i + name.length() + 2); |
| | | if (e < 0) return attrs; |
| | | return attrs.substring(0, i) + attrs.substring(e + 1); |
| | | } |
| | | |
| | | /** 找标签起点:要求标签名后面紧跟 空格/>,避免 "<sheetView" 命中 "<sheetViews" */ |
| | | private int findTag(String xml, String tag) { |
| | | int from = 0; |
| | | while (true) { |
| | | int i = xml.indexOf(tag, from); |
| | | if (i < 0) return -1; |
| | | int nx = i + tag.length(); |
| | | char ch = nx < xml.length() ? xml.charAt(nx) : '>'; |
| | | if (ch == ' ' || ch == '>' || ch == '/') return i; |
| | | from = i + 1; |
| | | } |
| | | } |
| | | |
| | | /** 取属性值(不存在返回 null) */ |
| | | private String attrValue(String attrs, String name) { |
| | | int i = attrs.indexOf(name + "=\""); |
| | | if (i < 0) return null; |
| | | int e = attrs.indexOf('"', i + name.length() + 2); |
| | | return e < 0 ? null : attrs.substring(i + name.length() + 2, e); |
| | | } |
| | | |
| | | /** 追加属性(已存在则不重复加) */ |
| | | private String addAttr(String attrs, String name, String v) { |
| | | if (attrValue(attrs, name) != null) return attrs; |
| | | String sep = (attrs.isEmpty() || attrs.endsWith(" ")) ? "" : " "; |
| | | return attrs + sep + name + "=\"" + v + "\""; |
| | | } |
| | | |
| | | /** |
| | | * 单页 XML 归一:sheetView / pane 的 topLeftCell、活动 pane 的 selection 全部指向 target。 |
| | | * 冻结拆分(xSplit/ySplit/state)原样保留;破坏结构则返回原文(failed-safe)。 |
| | | */ |
| | | private String patchSheetView(String xml, String target) { |
| | | String orig = xml; |
| | | int i = findTag(xml, "<sheetView"); |
| | | if (i >= 0) { |
| | | int j = xml.indexOf('>', i); |
| | | if (j > 0) { |
| | | String attrs = addAttr(stripAttr(xml.substring(i + 10, j), "topLeftCell"), "topLeftCell", target); |
| | | xml = xml.substring(0, i) + "<sheetView" + attrs + ">" + xml.substring(j + 1); |
| | | } |
| | | } |
| | | String activePane = null; |
| | | int p = findTag(xml, "<pane"); |
| | | if (p >= 0) { |
| | | int q = xml.indexOf("/>", p); |
| | | if (q > 0) { |
| | | String raw = xml.substring(p + 5, q); |
| | | activePane = attrValue(raw, "activePane"); |
| | | String attrs = addAttr(stripAttr(raw, "topLeftCell"), "topLeftCell", target); |
| | | xml = xml.substring(0, p) + "<pane" + attrs + "/>" + xml.substring(q + 2); |
| | | } |
| | | } |
| | | StringBuilder sb = new StringBuilder(); |
| | | int last = 0, pos = 0; |
| | | boolean done = false, any = false; |
| | | while (true) { |
| | | int s0 = xml.indexOf("<selection", pos); |
| | | if (s0 < 0) break; |
| | | int s1 = xml.indexOf("/>", s0); |
| | | if (s1 < 0) break; |
| | | String raw = xml.substring(s0 + 10, s1); |
| | | String attrs = stripAttr(stripAttr(raw, "activeCell"), "sqref"); |
| | | String pane = attrValue(raw, "pane"); |
| | | boolean hit = (activePane != null) ? (pane != null && pane.equals(activePane)) : !done; |
| | | if (hit) { attrs = addAttr(addAttr(attrs, "activeCell", target), "sqref", target); done = true; } |
| | | sb.append(xml, last, s0).append("<selection").append(attrs).append("/>"); |
| | | last = s1 + 2; |
| | | pos = s1 + 2; |
| | | any = true; |
| | | } |
| | | if (!any) { |
| | | int close = xml.indexOf("</sheetView>"); |
| | | if (close > 0) xml = xml.substring(0, close) + "<selection activeCell=\"" + target + "\" sqref=\"" + target + "\"/>" + xml.substring(close); |
| | | } else { |
| | | sb.append(xml, last, xml.length()); |
| | | xml = sb.toString(); |
| | | } |
| | | if (!xml.contains("<sheetViews>") || !xml.contains("</sheetViews>")) return orig; |
| | | return xml; |
| | | } |
| | | |
| | | /** |
| | | * 生成件《数据缺失清单》页(2026-10-12,用户要求)。 |
| | | * |
| | | * <p>把本次导出的「缺数清单」与「货运基数体检」结果显式写进产物:用户拿到文件即可看到本报表期 |
| | | * 有哪些数没到、哪些数不合理,不必翻导出日志。只读提示页,不参与本表计算。 |
| | | */ |
| | | private void appendMissingDataSheet(XSSFWorkbook wb, String period, List<String> problems) { |
| | | String sheetName = "数据缺失清单"; |
| | | int old = wb.getSheetIndex(sheetName); |
| | | if (old >= 0) wb.removeSheetAt(old); // 母版由「当期产物」派生时可能已带本页,先移除避免重名 |
| | | XSSFSheet sh = wb.createSheet(sheetName); |
| | | |
| | | CellStyle titleStyle = wb.createCellStyle(); |
| | | Font titleFont = wb.createFont(); |
| | | titleFont.setBold(true); |
| | | titleFont.setFontHeightInPoints((short) 14); |
| | | titleStyle.setFont(titleFont); |
| | | |
| | | CellStyle metaStyle = wb.createCellStyle(); |
| | | Font metaFont = wb.createFont(); |
| | | metaFont.setFontHeightInPoints((short) 10); |
| | | metaFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex()); |
| | | metaStyle.setFont(metaFont); |
| | | |
| | | CellStyle sectionStyle = wb.createCellStyle(); |
| | | Font sectionFont = wb.createFont(); |
| | | sectionFont.setBold(true); |
| | | sectionStyle.setFont(sectionFont); |
| | | |
| | | CellStyle bodyStyle = wb.createCellStyle(); |
| | | bodyStyle.setWrapText(true); |
| | | bodyStyle.setVerticalAlignment(VerticalAlignment.TOP); |
| | | |
| | | List<String> missing = new ArrayList<>(); |
| | | List<String> check = new ArrayList<>(); |
| | | List<String> other = new ArrayList<>(); |
| | | if (problems != null) { |
| | | for (String p : problems) { |
| | | if (p == null || p.trim().isEmpty()) continue; |
| | | if (p.startsWith("缺数")) missing.add(p); |
| | | else if (p.startsWith("货运体检")) check.add(p); |
| | | else other.add(p); |
| | | } |
| | | } |
| | | |
| | | int r = 0; |
| | | Row tRow = sh.createRow(r++); |
| | | Cell tCell = tRow.createCell(0); |
| | | tCell.setCellValue("数据缺失清单"); |
| | | tCell.setCellStyle(titleStyle); |
| | | |
| | | Row mRow = sh.createRow(r++); |
| | | Cell mCell = mRow.createCell(0); |
| | | mCell.setCellValue("报表期 " + period + " 生成时间 " |
| | | + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(new java.util.Date()) |
| | | + " 共 " + (missing.size() + check.size() + other.size()) + " 条"); |
| | | mCell.setCellStyle(metaStyle); |
| | | |
| | | Row nRow = sh.createRow(r++); |
| | | Cell nCell = nRow.createCell(0); |
| | | nCell.setCellValue("本页由程序自动生成(只读提示):一、缺数清单=该有的数没有;二、数据体检=数值不合理(如规下为负)。不参与本表计算。"); |
| | | nCell.setCellStyle(metaStyle); |
| | | |
| | | r++; |
| | | r = writeProblemSection(sh, r, "一、缺数清单(该有的数没有)", missing, sectionStyle, bodyStyle); |
| | | r = writeProblemSection(sh, r, "二、数据体检(数值不合理)", check, sectionStyle, bodyStyle); |
| | | if (!other.isEmpty()) { |
| | | r = writeProblemSection(sh, r, "三、其它提示", other, sectionStyle, bodyStyle); |
| | | } |
| | | |
| | | sh.setColumnWidth(0, 6 * 256); |
| | | sh.setColumnWidth(1, 120 * 256); |
| | | sh.createFreezePane(0, 3); |
| | | } |
| | | |
| | | /** 写一个清单分区(标题 + 逐条),返回下一个可写行号 */ |
| | | private int writeProblemSection(XSSFSheet sh, int rowIdx, String title, List<String> lines, |
| | | CellStyle sectionStyle, CellStyle bodyStyle) { |
| | | Row hr = sh.createRow(rowIdx++); |
| | | Cell hc = hr.createCell(0); |
| | | hc.setCellValue(title); |
| | | hc.setCellStyle(sectionStyle); |
| | | sh.addMergedRegion(new CellRangeAddress(rowIdx - 1, rowIdx - 1, 0, 1)); |
| | | if (lines.isEmpty()) { |
| | | Row er = sh.createRow(rowIdx++); |
| | | er.createCell(0).setCellValue("—"); |
| | | er.createCell(1).setCellValue("无"); |
| | | return rowIdx; |
| | | } |
| | | int i = 1; |
| | | for (String line : lines) { |
| | | Row row = sh.createRow(rowIdx++); |
| | | row.createCell(0).setCellValue(i++); |
| | | Cell c = row.createCell(1); |
| | | c.setCellValue(line); |
| | | c.setCellStyle(bodyStyle); |
| | | } |
| | | return rowIdx; |
| | | } |
| | | |
| | | /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */ |
| | |
| | | * 的结果刷新数值,避免汇总表里的排名页停留在旧月份。 |
| | | * 数值格搬值、公式格按偏移平移公式后写入;目标格本身已是公式的(SUM/RANK/占比/补数)保留不动,打开时按新数据重算。 |
| | | */ |
| | | private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode) throws Exception { |
| | | private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode, List<String> problems) throws Exception { |
| | | 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, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems); |
| | | n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems); |
| | | n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}}, problems); |
| | | return n; |
| | | } |
| | | |
| | |
| | | * srcBlocks[i] = 源块 i 的起始 {行, 列}(0 基);目标块按标题顺序(累计块在前、当月块在后)对应。 |
| | | * 数值格直接搬值;源侧是公式的(排名/占比等)按目标块偏移整体平移后写入公式,打开时按汇总表自身数据重算。 |
| | | */ |
| | | private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks) throws Exception { |
| | | private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks, |
| | | List<String> problems) throws Exception { |
| | | XSSFSheet dst = dstWb.getSheet(dstSheetName); |
| | | if (dst == null) return 0; |
| | | List<XSSFCell> dstTitles = rankTitleCells(dst); |
| | | if (dstTitles.isEmpty()) return 0; |
| | | int copied = 0; |
| | | FormulaEvaluator dstEv = dstWb.getCreationHelper().createFormulaEvaluator(); |
| | | List<int[]> anchors = new ArrayList<>(); // {源全省行, 目标全省行, 块首列, 列偏移, 块宽} |
| | | try (InputStream sin = new ByteArrayInputStream(srcBytes); |
| | | XSSFWorkbook srcWb = new XSSFWorkbook(sin)) { |
| | | XSSFSheet src = srcWb.getSheetAt(0); |
| | |
| | | int sr0 = srcBlocks[b][0]; |
| | | int sc0 = srcBlocks[b][1]; |
| | | XSSFCell dstTitle = dstTitles.get(b); |
| | | int rowOff = dstTitle.getRowIndex() - sr0; |
| | | int colOff = dstTitle.getColumnIndex() - sc0; |
| | | int rowOff = dstTitle.getRowIndex() - sr0; |
| | | // 2026-10-13:《货运周转量排名》母版块标题曾占两行(合并),"标题行+固定偏移"会把源行 |
| | | // 整体写错位一行(全省值落到表头行、市州值与市州名错开)。改为用「全省」行做锚点对齐: |
| | | // rowOff = 目标全省行 - 源全省行;识别不到锚点再回退标题行偏移并告警。 |
| | | Integer srcAnchor = rankAnchorRow(src, sr0, sc0); |
| | | Integer dstAnchor = rankAnchorRow(dst, dstTitle.getRowIndex(), dstTitle.getColumnIndex()); |
| | | if (srcAnchor != null && dstAnchor != null) { |
| | | int anchored = dstAnchor - srcAnchor; |
| | | if (anchored != rowOff) { |
| | | log.info("排名页《{}》第 {} 块:标题行偏移 {} 与「全省」锚点偏移 {} 不一致,按锚点对齐(源全省行={},目标全省行={})", |
| | | dstSheetName, b + 1, rowOff, anchored, srcAnchor + 1, dstAnchor + 1); |
| | | } |
| | | rowOff = anchored; |
| | | } else { |
| | | log.warn("排名页《{}》第 {} 块:未识别到「全省」锚点(源={},目标={}),回退按标题行偏移 {} 对齐", |
| | | dstSheetName, b + 1, srcAnchor, dstAnchor, rowOff); |
| | | } |
| | | int h = rankBlockHeight(src, sr0, sc0); |
| | | int w = rankBlockWidth(src, sr0, sc0); |
| | | anchors.add(new int[]{srcAnchor == null ? -1 : srcAnchor, dstAnchor == null ? -1 : dstAnchor, sc0, colOff, w}); |
| | | // 2026-10-12:整块缺数(源侧一个数值都没有,如 9 月缺②③时的周转量排名)→ 目标块只留标题与标签, |
| | | // 数据/排名/占比一律留空;否则母版的 RANK/占比公式会在空块上算出「全 1 + #DIV/0!」。 |
| | | boolean blockHasData = false; |
| | | for (int r = 1; r < h && !blockHasData; r++) { |
| | | Row sr = src.getRow(sr0 + r); |
| | | if (sr == null) continue; |
| | | for (int c = 0; c < w; c++) { |
| | | Cell sc0c = sr.getCell(sc0 + c); |
| | | if (sc0c != null && rankNumeric(sc0c, ev) != null) { |
| | | blockHasData = true; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | for (int r = 0; r < h; r++) { |
| | | Row srcRow = src.getRow(sr0 + r); |
| | | if (srcRow == null) continue; |
| | | Row dstRow = dst.getRow(sr0 + r + rowOff); |
| | | if (dstRow == null) dstRow = dst.createRow(sr0 + r + rowOff); |
| | | for (int c = 0; c < w; c++) { |
| | | Cell sc = srcRow.getCell(sc0 + c); |
| | | if (sc == null) continue; |
| | | int dc = sc0 + c + colOff; |
| | | Cell sc = srcRow.getCell(sc0 + c); |
| | | if (!blockHasData && r > 0) { |
| | | // 整块缺数:只保留标签(市州名/指标名),数据与排名/占比重算结果一律留空 |
| | | if (sc == null || !isRankLabel(sc)) clearRankCell(dstRow, dc); |
| | | continue; |
| | | } |
| | | if (sc == null) { |
| | | // 2026-10-12:源侧整格缺失 = 缺数 → 目标格按「缺数留空」清空(不再沿用母版上一报表期的旧值) |
| | | if (r > 0) clearRankCell(dstRow, dc); |
| | | continue; |
| | | } |
| | | if (r == 0) { |
| | | // 标题行:同步块标题文案。母版标题是 inlineStr,POI 的 setCellValue 只写 <v>、 |
| | | // 不更新 <is>,Excel 会继续显示旧月份,必须先 setBlank 清掉再写。 |
| | |
| | | continue; |
| | | } |
| | | Double v = rankNumeric(sc, ev); |
| | | if (v == null) continue; |
| | | if (v == null) { |
| | | // 2026-10-12:源侧数值位为空(如缺②全口径时排名表的「规下」列)= 缺数 → 留空; |
| | | // 源侧是标签文本(市州名/指标名)时不是缺数,保留母版自带内容。 |
| | | if (!isRankLabel(sc)) clearRankCell(dstRow, dc); |
| | | continue; |
| | | } |
| | | if (dcCell == null) dcCell = rankCell(dstRow, dc); |
| | | dcCell.setCellValue(v); |
| | | copied++; |
| | | } |
| | | } |
| | | } |
| | | // 2026-10-13 自检:目标「全省」行应与源侧一致(偏移错位、汇总区间不对时报警并写进缺数清单) |
| | | for (int[] an : anchors) { |
| | | if (an[0] < 0 || an[1] < 0) continue; |
| | | Row srr = src.getRow(an[0]); |
| | | Row drr = dst.getRow(an[1]); |
| | | if (srr == null || drr == null) continue; |
| | | int badCols = 0; |
| | | StringBuilder ex = new StringBuilder(); |
| | | for (int c = 0; c < an[4]; c++) { |
| | | Cell sCell = srr.getCell(an[2] + c); |
| | | Cell dCell = drr.getCell(an[2] + c + an[3]); |
| | | Double sv = sCell == null ? null : rankNumeric(sCell, ev); |
| | | Double dv = dCell == null ? null : rankNumeric(dCell, dstEv); |
| | | if (sv == null && dv == null) continue; |
| | | if (sv == null || dv == null || Math.abs(sv - dv) > Math.max(1.0, Math.abs(sv) * 1e-4)) { |
| | | badCols++; |
| | | if (badCols <= 3) { |
| | | if (ex.length() > 0) ex.append(";"); |
| | | ex.append(colLetter(an[2] + c)).append(" 源=").append(sv).append(" 目标=").append(dv); |
| | | } |
| | | } |
| | | } |
| | | if (badCols >= 2) { |
| | | String msg = "排名页《" + dstSheetName + "》「全省」行与源侧不一致(" + badCols + " 列):" + ex; |
| | | log.warn(msg); |
| | | if (problems != null) problems.add("生成校验:" + msg); |
| | | } |
| | | } |
| | | } |
| | | return copied; |
| | | } |
| | |
| | | Cell left = col > 0 ? row.getCell(col - 1) : null; |
| | | if (left != null) c.setCellStyle(left.getCellStyle()); |
| | | return c; |
| | | } |
| | | |
| | | /** |
| | | * 目标格按「缺数留空」清空。 |
| | | * |
| | | * <p>2026-10-12:这里连母版自带的排名/占比公式格一起清(缺数期不显示"上一期残留值 + 全 1 排名 + #DIV/0!"), |
| | | * 与《生成_货运量排名/周转量排名/中口径排名》这些用户日常核对件的空态一致;下期源侧有数时按源侧重写回来。 |
| | | * 文字标签格不动。 |
| | | */ |
| | | private void clearRankCell(Row dstRow, int col) { |
| | | Cell c = dstRow.getCell(col); |
| | | if (c == null) return; |
| | | if (c.getCellType() != CellType.STRING) c.setBlank(); |
| | | } |
| | | |
| | | /** 源侧是否为文字标签(市州名/指标名)——标签不是缺数,不清空目标格 */ |
| | | private boolean isRankLabel(Cell c) { |
| | | if (c.getCellType() != CellType.STRING) return false; |
| | | String v = c.getStringCellValue(); |
| | | if (v == null) return false; |
| | | String x = v.replace(",", "").trim(); |
| | | if (x.isEmpty()) return false; |
| | | try { |
| | | Double.parseDouble(x); |
| | | return false; |
| | | } catch (NumberFormatException ignore) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** 公式内所有 A1 引用整体平移:列 +dCol、行 +dRow(SUM/RANK 等函数名后接括号不会被匹配) */ |
| | |
| | | } |
| | | |
| | | /** 排名页里的“块标题”单元格(含“全省分市州”的说明文字),按行、列顺序返回 */ |
| | | /** 在 [fromRow, fromRow+40) 范围内、块起始两列里找整格文本为「全省」的行号(0 基);找不到返回 null。 |
| | | * 2026-10-13:排名页块结构可能因母版版式不同而整体上/下移一行,用「全省」行做锚点可自动对齐。 */ |
| | | private Integer rankAnchorRow(Sheet sh, int fromRow, int col0) { |
| | | int last = Math.min(sh.getLastRowNum(), fromRow + 40); |
| | | for (int r = fromRow; r <= last; r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | for (int c = Math.max(0, col0); c <= col0 + 1; c++) { |
| | | Cell cell = row.getCell(c); |
| | | if (cell == null || cell.getCellType() != CellType.STRING) continue; |
| | | String v = cell.getStringCellValue(); |
| | | if (v != null && v.replace(" ", "").equals("全省")) return r; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private List<XSSFCell> rankTitleCells(Sheet sh) { |
| | | List<XSSFCell> out = new ArrayList<>(); |
| | | for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) { |
| | |
| | | private static final int SM_WYC_CX_PAX = 16, SM_WYC_CX_TURN = 17; |
| | | private static final int SM_FREIGHT_TURN = 18; |
| | | private static final int SM_DIM = 19; |
| | | /** 2026 年 1 月当月值列(0 基,C 列);2025 年 1 月当月值列(0 基,V 列) */ |
| | | private static final int SM_CUR_COL0 = 2; |
| | | private static final int SM_LAST_COL0 = 21; |
| | | // 2026-10-09:原先写死的 SM_CUR_COL0=2(2026年1月=C)/ SM_LAST_COL0=21(2025年1月=V)已废弃, |
| | | // 改为按表头动态定位(见 smMonthCols / smYearMonthOf),兼容「文本 / 日期序列号 / 日期格式」三种表头写法。 |
| | | |
| | | /** |
| | | * 刷新四张跨页合成页:当年累计(1..month 月)、当月(month 月)及对应同比; |
| | |
| | | int[] cumMonths = new int[month]; |
| | | for (int m = 1; m <= month; m++) cumMonths[m - 1] = m; |
| | | int[] oneMonth = new int[]{month}; |
| | | Map<String, double[]> curCum = smReadLedgerMetrics(wb, true, cumMonths); |
| | | Map<String, double[]> lastCum = smReadLedgerMetrics(wb, false, cumMonths); |
| | | Map<String, double[]> curMon = smReadLedgerMetrics(wb, true, oneMonth); |
| | | Map<String, double[]> lastMon = smReadLedgerMetrics(wb, false, oneMonth); |
| | | Map<String, double[]> curCum = smReadLedgerMetrics(wb, year, cumMonths); |
| | | Map<String, double[]> lastCum = smReadLedgerMetrics(wb, year - 1, cumMonths); |
| | | Map<String, double[]> curMon = smReadLedgerMetrics(wb, year, oneMonth); |
| | | Map<String, double[]> lastMon = smReadLedgerMetrics(wb, year - 1, oneMonth); |
| | | if (curCum.isEmpty()) { |
| | | log.warn("汇总大表四页刷新:台账页未读到市州数据,跳过(保持母版原值)"); |
| | | return 0; |
| | |
| | | } |
| | | |
| | | /** 台账页累计取数:市州规范名 -> 19 维指标 */ |
| | | private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, boolean curYear, int[] months) { |
| | | private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, int blockYear, int[] months) { |
| | | Map<String, double[]> out = new LinkedHashMap<>(); |
| | | smReadCityPaxPage(wb, "公交", curYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN); |
| | | smReadCityPaxPage(wb, "出租", curYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN); |
| | | smReadCityPaxPage(wb, "网约车", curYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN); |
| | | smReadRailFerry(wb, curYear, months, out); |
| | | smReadBanxian(wb, curYear, months, out); |
| | | smReadFreightTurnover(wb, curYear, months, out); |
| | | smReadCityPaxPage(wb, "公交", blockYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN); |
| | | smReadCityPaxPage(wb, "出租", blockYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN); |
| | | smReadCityPaxPage(wb, "网约车", blockYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN); |
| | | smReadRailFerry(wb, blockYear, months, out); |
| | | smReadBanxian(wb, blockYear, months, out); |
| | | smReadFreightTurnover(wb, blockYear, months, out); |
| | | return out; |
| | | } |
| | | |
| | | /** 公交/出租/网约车页:每市州 4 行(客运量/旅客周转量/其中城市内客运量/其中城市内旅客周转量) */ |
| | | private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, boolean curYear, int[] months, |
| | | private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, int blockYear, int[] months, |
| | | Map<String, double[]> out, int paxIdx, int turnIdx, int cxPaxIdx, int cxTurnIdx) { |
| | | Sheet sh = wb.getSheet(sheetName); |
| | | if (sh == null) return; |
| | | Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear); |
| | | int last = sh.getLastRowNum(); |
| | | for (int r = sh.getFirstRowNum(); r <= last; r++) { |
| | | Row row = sh.getRow(r); |
| | |
| | | else if (paxRow < 0) paxRow = k; |
| | | } |
| | | } |
| | | double totalPax = smSumMonths(sh, paxRow, curYear, months); |
| | | double totalTurn = smSumMonths(sh, turnRow, curYear, months); |
| | | double cityPax = smSumMonths(sh, cxPaxRow, curYear, months); |
| | | double cityTurn = smSumMonths(sh, cxTurnRow, curYear, months); |
| | | double totalPax = smSumMonths(sh, paxRow, monthCols, months); |
| | | double totalTurn = smSumMonths(sh, turnRow, monthCols, months); |
| | | double cityPax = smSumMonths(sh, cxPaxRow, monthCols, months); |
| | | double cityTurn = smSumMonths(sh, cxTurnRow, monthCols, months); |
| | | double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]); |
| | | arr[paxIdx] += cityPax; // 城市客运口径取“城市内” |
| | | arr[turnIdx] += cityTurn; |
| | |
| | | } |
| | | |
| | | /** 轨道、轮渡页:轨道块(武汉/黄石)+ 轮渡块(武汉) */ |
| | | private void smReadRailFerry(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) { |
| | | private void smReadRailFerry(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) { |
| | | Sheet sh = wb.getSheet("轨道、轮渡"); |
| | | if (sh == null) return; |
| | | Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear); |
| | | int last = sh.getLastRowNum(); |
| | | int ferryTitle = -1; |
| | | for (int r = sh.getFirstRowNum(); r <= last; r++) { |
| | |
| | | String a = smText(row.getCell(0)); |
| | | if (a != null && a.contains("轮渡客运量")) { ferryTitle = r; break; } |
| | | } |
| | | smReadRailFerryBlock(sh, curYear, months, out, sh.getFirstRowNum(), |
| | | smReadRailFerryBlock(sh, monthCols, months, out, sh.getFirstRowNum(), |
| | | ferryTitle < 0 ? last : ferryTitle - 1, SM_RAIL_PAX); |
| | | if (ferryTitle >= 0) smReadRailFerryBlock(sh, curYear, months, out, ferryTitle, last, SM_FERRY_PAX); |
| | | if (ferryTitle >= 0) smReadRailFerryBlock(sh, monthCols, months, out, ferryTitle, last, SM_FERRY_PAX); |
| | | } |
| | | |
| | | private void smReadRailFerryBlock(Sheet sh, boolean curYear, int[] months, Map<String, double[]> out, |
| | | private void smReadRailFerryBlock(Sheet sh, Map<Integer, Integer> monthCols, int[] months, Map<String, double[]> out, |
| | | int start, int end, int baseIdx) { |
| | | for (int r = start; r <= end; r++) { |
| | | Row row = sh.getRow(r); |
| | |
| | | else if (paxRow < 0) paxRow = k; |
| | | } |
| | | double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]); |
| | | arr[baseIdx] += smSumMonths(sh, paxRow, curYear, months); |
| | | arr[baseIdx + 1] += smSumMonths(sh, turnRow, curYear, months); |
| | | arr[baseIdx] += smSumMonths(sh, paxRow, monthCols, months); |
| | | arr[baseIdx + 1] += smSumMonths(sh, turnRow, monthCols, months); |
| | | } |
| | | } |
| | | |
| | | /** 班线包车页:每市州 客运量 / 旅客周转量(“其中个体”行不重复计入) */ |
| | | private void smReadBanxian(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) { |
| | | private void smReadBanxian(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) { |
| | | Sheet sh = wb.getSheet("班线包车"); |
| | | if (sh == null) return; |
| | | Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear); |
| | | int last = sh.getLastRowNum(); |
| | | for (int r = sh.getFirstRowNum(); r <= last; r++) { |
| | | Row row = sh.getRow(r); |
| | |
| | | else if (b.contains("客运量") && paxRow < 0) paxRow = k; |
| | | } |
| | | double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]); |
| | | arr[SM_BX_PAX] += smSumMonths(sh, paxRow, curYear, months); |
| | | arr[SM_BX_TURN] += smSumMonths(sh, turnRow, curYear, months); |
| | | arr[SM_BX_PAX] += smSumMonths(sh, paxRow, monthCols, months); |
| | | arr[SM_BX_TURN] += smSumMonths(sh, turnRow, monthCols, months); |
| | | } |
| | | } |
| | | |
| | | /** 货运页:每市州 规上货物周转量 + 规下货物周转量(“规上+规下”行本身是求和公式,不直接取) */ |
| | | private void smReadFreightTurnover(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) { |
| | | private void smReadFreightTurnover(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) { |
| | | Sheet sh = wb.getSheet(" 货运"); |
| | | if (sh == null) sh = wb.getSheet("货运"); |
| | | if (sh == null) return; |
| | | Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear); |
| | | int last = sh.getLastRowNum(); |
| | | for (int r = sh.getFirstRowNum(); r <= last; r++) { |
| | | Row row = sh.getRow(r); |
| | |
| | | else if (b.contains("规下货物周转量")) { if (below < 0) below = k; } |
| | | } |
| | | double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]); |
| | | arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, curYear, months) + smSumMonths(sh, below, curYear, months); |
| | | arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, monthCols, months) + smSumMonths(sh, below, monthCols, months); |
| | | } |
| | | } |
| | | |
| | | private double smSumMonths(Sheet sh, int rowIdx, boolean curYear, int[] months) { |
| | | if (sh == null || rowIdx < 0) return 0; |
| | | private double smSumMonths(Sheet sh, int rowIdx, Map<Integer, Integer> monthCols, int[] months) { |
| | | if (sh == null || rowIdx < 0 || monthCols == null) return 0; |
| | | Row row = sh.getRow(rowIdx); |
| | | if (row == null) return 0; |
| | | double s = 0; |
| | | for (int m : months) { |
| | | int col = (curYear ? SM_CUR_COL0 : SM_LAST_COL0) + 2 * (m - 1); |
| | | Integer col = monthCols.get(m); |
| | | if (col == null) continue; |
| | | s += smNum(row.getCell(col)); |
| | | } |
| | | return s; |
| | | } |
| | | |
| | | /** 台账页「某年 M 月」的 0 基列号:按表头动态定位(不再写死 C/V),兼容文本 / 日期序列号 / 日期格式三种写法 */ |
| | | private Map<Integer, Integer> smMonthCols(Sheet sh, int wantedYear) { |
| | | Map<Integer, Integer> cols = new LinkedHashMap<>(); |
| | | if (sh == null) return cols; |
| | | int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4); |
| | | for (int r = sh.getFirstRowNum(); r <= lastRow; r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | int lastCol = row.getLastCellNum(); |
| | | for (int c = row.getFirstCellNum(); c >= 0 && c < lastCol; c++) { |
| | | Integer ym = smYearMonthOf(row.getCell(c)); |
| | | if (ym == null || ym / 100 != wantedYear) continue; |
| | | int mo = ym % 100; |
| | | if (!cols.containsKey(mo)) cols.put(mo, c); |
| | | } |
| | | } |
| | | if (cols.isEmpty()) { |
| | | log.warn("汇总大表四页刷新:{} 页未识别到 {} 年的月份表头列(支持文本/序列号/日期三种形式)", sh.getSheetName(), wantedYear); |
| | | } |
| | | return cols; |
| | | } |
| | | |
| | | /** |
| | | * 打开工作簿时把每个页签定位到「本期数据」位置(2026-10-13 用户要求)。 |
| | | * 做法:凡能按表头识别出「当年当月值列」的页,把活动单元格设到该月表头格;其余页保持默认。 |
| | | * 只影响打开时的滚动/选中位置,不改任何数据与格式。 |
| | | */ |
| | | private int setSheetViewsToCurrentMonth(XSSFWorkbook wb, int year, int month) { |
| | | int n = 0; |
| | | for (int i = 0; i < wb.getNumberOfSheets(); i++) { |
| | | XSSFSheet sh = wb.getSheetAt(i); |
| | | if (sh == null) continue; |
| | | int headerRow = -1, col = -1; |
| | | int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4); |
| | | for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) { |
| | | Integer ym = smYearMonthOf(row.getCell(c)); |
| | | if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; } |
| | | } |
| | | } |
| | | if (col < 0) continue; |
| | | sh.setActiveCell(new org.apache.poi.ss.util.CellAddress(headerRow, col)); |
| | | n++; |
| | | } |
| | | if (n > 0) log.info("汇总工作簿:{} 个页签已定位到本期({}年{}月)数据列", n, year, month); |
| | | return n; |
| | | } |
| | | |
| | | /** 表头单元格 -> 年*100+月;只认「YYYY年M月」文本或「某月 1 号」的日期序列号,排除累计/同比/环比 */ |
| | | private Integer smYearMonthOf(Cell c) { |
| | | if (c == null) return null; |
| | | try { |
| | | CellType t = c.getCellType(); |
| | | if (t == CellType.STRING) { |
| | | String v = c.getStringCellValue(); |
| | | if (v == null) return null; |
| | | v = v.trim(); |
| | | if (v.isEmpty() || v.contains("累计") || v.contains("同比") || v.contains("环比")) return null; |
| | | java.util.regex.Matcher m = java.util.regex.Pattern |
| | | .compile("^(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月$").matcher(v); |
| | | if (!m.matches()) return null; |
| | | int y = Integer.parseInt(m.group(1)); |
| | | int mo = Integer.parseInt(m.group(2)); |
| | | return mo >= 1 && mo <= 12 ? y * 100 + mo : null; |
| | | } |
| | | if (t == CellType.NUMERIC) { |
| | | double v = c.getNumericCellValue(); |
| | | if (v < 36526 || v > 73415) return null; |
| | | java.util.Calendar cal = java.util.Calendar.getInstance(); |
| | | cal.setTime(org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v)); |
| | | if (cal.get(java.util.Calendar.DAY_OF_MONTH) != 1) return null; |
| | | return cal.get(java.util.Calendar.YEAR) * 100 + (cal.get(java.util.Calendar.MONTH) + 1); |
| | | } |
| | | } catch (Exception ignore) { |
| | | return null; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** 台账页单元格取数(数字 / 数字文本 / 公式缓存) */ |
| | |
| | | if (sh == null) return 0; |
| | | smSetTitle(sh, 0, 0, "城市客运客运量分市州1-" + month + "月累计情况"); |
| | | smSetTitle(sh, 22, 0, "城市客运客运周转量分市州1-" + month + "月累计情况"); |
| | | smSetTitle(sh, 45, 0, "城市客运客运量分市州" + month + "月情况"); |
| | | smSetTitle(sh, 67, 0, "城市客运客运周转量分市州" + month + "月情况"); |
| | | smSetTitle(sh, 44, 0, "城市客运客运量分市州" + month + "月情况"); // 0基44 = Excel 第45行(母版里标题在 A45,A46 是"单位"行) |
| | | smSetTitle(sh, 66, 0, "城市客运客运周转量分市州" + month + "月情况"); // 0基66 = Excel 第67行(同上) |
| | | int n = 0; |
| | | n += smWriteCityByRegionBlock(sh, 4, true, curCum, lastCum); |
| | | n += smWriteCityByRegionBlock(sh, 26, false, curCum, lastCum); |
| | |
| | | String curCity = null; |
| | | Set<Long> used = new HashSet<>(); |
| | | int filled = 0, kept = 0, dup = 0; |
| | | List<String> skipped = new ArrayList<>(); |
| | | Map<String, Integer> skippedByCity = new LinkedHashMap<>(); |
| | | for (int r = 8; r <= sheet.getLastRowNum(); r++) { |
| | | Row row = sheet.getRow(r); |
| | | if (row == null) continue; |
| | |
| | | if (!a.matches("\\d+(\\.\\d+)?")) continue; |
| | | if (b.isEmpty() || curCity == null) continue; |
| | | InvestmentProject p = matchInvest(projects, b, curCity, "客运站场", used); |
| | | p = preferMonthlyCandidate(projects, p, mByProject, "客运站场", used); |
| | | InvestmentMonthly m = p == null ? null : mByProject.get(p.getId()); |
| | | if (p == null || m == null) { |
| | | kept++; |
| | | // 有库内项目但当月无数据 → 记为待核对;无匹配项目 → 结转上月 |
| | | if (p != null) dup++; |
| | | // 2026-09-22:不再静默结转上月,未覆盖的行必须留痕(行号+市州+项目名+原因) |
| | | skipped.add("第" + (r + 1) + "行 " + curCity + " " + b |
| | | + (p == null ? " → 库内无匹配项目" : " → 库内无 " + period + " 月度数据")); |
| | | skippedByCity.merge(curCity, 1, Integer::sum); |
| | | continue; |
| | | } |
| | | used.add(p.getId()); |
| | |
| | | } |
| | | log.info("客运站投资明细表 {}:写入 {} 行,结转母版原值 {} 行(其中库内有项目但当月无数据 {} 行)", |
| | | period, filled, kept, dup); |
| | | logSkippedRows("客运站投资明细表", period, skipped, skippedByCity); |
| | | return toBytes(wb); |
| | | } |
| | | } |
| | |
| | | Map<String, double[]> cityStats = new LinkedHashMap<>(); // 全名 -> {总数, 累计, 规划内数, 规划内累计, 规划外数, 规划外累计} |
| | | Set<Long> used = new HashSet<>(); |
| | | int filled = 0, kept = 0; |
| | | List<String> skipped = new ArrayList<>(); |
| | | Map<String, Integer> skippedByCity = new LinkedHashMap<>(); |
| | | for (int i = 0; i < cityRows.size(); i++) { |
| | | int cr = cityRows.get(i); |
| | | String cityLabel = nvl(cellText(sheet.getRow(cr).getCell(0))); |
| | |
| | | if (!a.matches("\\d+(\\.\\d+)?")) continue; |
| | | if (b.isEmpty()) continue; |
| | | InvestmentProject p = matchInvest(projects, b, city, "物流园区", used); |
| | | p = preferMonthlyCandidate(projects, p, mByProject, "物流园区", used); |
| | | InvestmentMonthly m = p == null ? null : mByProject.get(p.getId()); |
| | | if (p == null || m == null) { kept++; continue; } |
| | | if (p == null || m == null) { |
| | | kept++; |
| | | // 2026-09-22:不再静默结转上月,未覆盖的行必须留痕 |
| | | skipped.add("第" + (r + 1) + "行 " + city + " " + b |
| | | + (p == null ? " → 库内无匹配项目" : " → 库内无 " + period + " 月度数据")); |
| | | skippedByCity.merge(city, 1, Integer::sum); |
| | | continue; |
| | | } |
| | | used.add(p.getId()); |
| | | // 口径同客运站明细表:只覆盖 H 自开始建设累计 / J 自年初累计 / K 本月完成 / M 形象进度 |
| | | if (m.getStartCum() != null) putNum(row, 7, m.getStartCum()); |
| | |
| | | log.warn("物流站场投资明细公式求值失败: {}", e.getMessage()); |
| | | } |
| | | log.info("物流站场投资明细 {}:写入 {} 行,结转母版原值 {} 行", period, filled, kept); |
| | | logSkippedRows("物流站场投资明细", period, skipped, skippedByCity); |
| | | return toBytesHssf(wb); |
| | | } |
| | | |
| | |
| | | return full != null && RegionUtil.cityList().contains(full); |
| | | } |
| | | |
| | | /** |
| | | * 同名(归一化后一致)多档时,优先选「当月有数据」的那一档。 |
| | | * 背景(2026-09-22):十堰「快递物流产业园二期」在库内有两档 id=154(竣工 2026,月度只到 7 月)与 |
| | | * id=181(竣工 2029,有 8 月),模板行名前缀与 154 一致 → 导出取到 154 的 7 月值。 |
| | | */ |
| | | private InvestmentProject preferMonthlyCandidate(List<InvestmentProject> pool, InvestmentProject p, |
| | | Map<Long, InvestmentMonthly> mByProject, |
| | | String category, Set<Long> excludeIds) { |
| | | if (p == null || mByProject.containsKey(p.getId())) return p; |
| | | String norm = normInvestName(p.getProjectName()); |
| | | if (norm.isEmpty()) return p; |
| | | InvestmentProject better = null; |
| | | for (InvestmentProject c : pool) { |
| | | if (c == null || c.getId().equals(p.getId())) continue; |
| | | if (excludeIds != null && excludeIds.contains(c.getId())) continue; |
| | | if (category != null && !category.equals(c.getCategory())) continue; |
| | | if (!mByProject.containsKey(c.getId())) continue; |
| | | if (!norm.equals(normInvestName(c.getProjectName()))) continue; |
| | | if (better == null || c.getId() > better.getId()) better = c; |
| | | } |
| | | return better != null ? better : p; |
| | | } |
| | | |
| | | /** 未按当月数据覆盖的行必须留痕:逐行列出行号+市州+项目名,避免「静默结转上期值」再次发生 */ |
| | | private void logSkippedRows(String reportName, String period, List<String> skipped, Map<String, Integer> byCity) { |
| | | if (skipped == null || skipped.isEmpty()) return; |
| | | log.warn("{} {}:{} 行未按当月数据覆盖(结转母版原值),需人工核对源文件/库内数据:", |
| | | reportName, period, skipped.size()); |
| | | int limit = Math.min(skipped.size(), 50); |
| | | for (int i = 0; i < limit; i++) log.warn(" [未覆盖] {}", skipped.get(i)); |
| | | if (skipped.size() > limit) log.warn(" …另有 {} 行未逐行列出", skipped.size() - limit); |
| | | if (byCity != null && !byCity.isEmpty()) log.warn(" 未覆盖行按市州统计:{}", byCity); |
| | | } |
| | | |
| | | /** 在候选项目池中按名称匹配 DB 项目(可选 市州/类别 过滤 + 县区前缀消歧),未命中返回 null */ |
| | | private InvestmentProject matchInvest(List<InvestmentProject> pool, String name, String cityFull, String category) { |
| | | return matchInvest(pool, name, cityFull, category, null); |
| | |
| | | if (name == null) return ""; |
| | | String s = name.trim(); |
| | | s = s.replaceAll("[((]", "").replaceAll("[))]", ""); |
| | | s = s.replaceAll("[·•—-_\\-]", ""); |
| | | // 2026-09-22:补充半/全角句点、顿号、分号等(库内「中国供销.公安商贸物流园」因半角句点匹配不上模板行) |
| | | s = s.replaceAll("[·•—-_\\-..。、,,;;::]", ""); |
| | | s = s.replaceAll("[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ]", "I"); |
| | | s = s.replaceAll("^(湖北省|武汉市|黄石市|十堰市|宜昌市|襄阳市|鄂州市|荆门市|孝感市|荆州市|黄冈市|咸宁市|随州市|恩施州|仙桃市|潜江市|天门市|神农架林区)", ""); |
| | | s = s.replaceAll("^(?:[\\u4e00-\\u9fa5]{2,4}(?:县|市|区))", ""); |