| | |
| | | import org.apache.poi.hssf.usermodel.HSSFSheet; |
| | | import org.apache.poi.hssf.usermodel.HSSFWorkbook; |
| | | import org.apache.poi.ss.util.CellRangeAddress; |
| | | import org.apache.poi.openxml4j.util.ZipSecureFile; |
| | | import org.openxmlformats.schemas.spreadsheetml.x2006.main.STCellType; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.stereotype.Service; |
| | |
| | | Map<String, Double> h2032Cum = loadH2032FreightCumulative(period); |
| | | ScaleSplitTransport provCum = getProvinceCumulative(period); |
| | | Double total = freightCum(ft.get("湖北省"), month); |
| | | Double totalWan = total == null ? null : round(total / 10000.0, 4); |
| | | Double totalWan = total == null ? null : round(total, 4); // 模板列已是万吨,不再 ÷10000 |
| | | double above = 0.0; |
| | | for (Double v : h2032Cum.values()) if (v != null) above += v; |
| | | double aboveWan = round(above / 10000.0, 4); |
| | |
| | | * 保留全部页签/版式/公式/2025 年缓存值,仅把表头年份动态化为目标年;数据填充按 P2 起逐步接入。 |
| | | */ |
| | | public byte[] exportSummaryWorkbook(String period, String mode) throws Exception { |
| | | return exportSummaryWorkbook(period, mode, null); |
| | | } |
| | | |
| | | /** 生成_道路运输量汇总表.xlsx(P0-P1 骨架,支持 toMonth 裁剪): |
| | | * 以《YYYY年M月道路运输量汇总表.xlsx》(M 由 period 决定)为母版,按同期备份母版还原月度块; |
| | | * toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头, |
| | | * 累计与累计同比公式改为按 toMonth 月重算),用于“先生成 1-6 月、1-7 月扩列”验证。 */ |
| | | public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth) throws Exception { |
| | | int year = Integer.parseInt(period.contains("-") ? period.split("-")[0] : period); |
| | | int month = period.contains("-") ? monthOf(period, mode) : 12; |
| | | File template = resolveSummaryMother(period, mode); |
| | | if (template == null) { |
| | | throw new RuntimeException("未找到汇总表母版《" + year + "年" + month + "月道路运输量汇总表.xlsx》(docs/生成汇总大表)"); |
| | | } |
| | | double savedZipRatio = ZipSecureFile.getMinInflateRatio(); |
| | | 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)) { |
| | | 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); |
| | | } |
| | | if (toMonth != null && toMonth > 0 && toMonth < month) { |
| | | int trimmed = trimSummaryMonthlySheets(wb, toMonth); |
| | | log.info("汇总工作簿裁剪至 {} 月完成,清空/改写单元格数={}", toMonth, trimmed); |
| | | } |
| | | dynamicSummaryYear(wb, year); |
| | | cleanSummarySheetPresentation(wb); // 取消各页筛选、还原隐藏行(否则“班线包车”等页看起来像缺数据) |
| | | applyTwoDecimalFormat(wb); // 数值显示两位小数(保留全精度);% / 日期等既有样式不改变 |
| | | autoFitContentColumns(wb); // 按数据自动加宽列宽(只加宽不缩窄,跳过合并标题格,防长文本把单列撑爆) |
| | | if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr(); |
| | | wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算 |
| | | return toBytes(wb); |
| | | } finally { |
| | | ZipSecureFile.setMinInflateRatio(savedZipRatio); |
| | | } |
| | | } |
| | | |
| | |
| | | if (a[5] <= 0 && a[2] > 0) a[5] = round(a[3] / a[2], 2); |
| | | } |
| | | 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; |
| | | // 中口径排名页 A..H 陈旧左块已于 2026-09-09 拍板整块删除:该区域永不从备份母版回填,避免“生成又复活” |
| | | boolean skipMidRankLeftBlock = "中口径排名".equals(ds.getSheetName()); |
| | | 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; |
| | | if (skipMidRankLeftBlock && dc.getColumnIndex() < 8) 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(); |
| | | applyDonorNumberFormat(wb, tc, dc); |
| | | 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; |
| | | } |
| | | |
| | | /** 还原回填时同步 donor(已做版式统一)的数字格式,保证同比/数值/排名格显示格式与母版一致 */ |
| | | private void applyDonorNumberFormat(XSSFWorkbook wb, Cell dst, Cell src) { |
| | | if (wb == null || dst == null || src == null) return; |
| | | try { |
| | | String fmt = src.getCellStyle().getDataFormatString(); |
| | | if (fmt == null || fmt.isEmpty() || "General".equalsIgnoreCase(fmt)) return; |
| | | org.apache.poi.ss.usermodel.DataFormat df = wb.createDataFormat(); |
| | | short idx = df.getFormat(fmt); |
| | | CellStyle cur = dst.getCellStyle(); |
| | | if (cur != null && idx == cur.getDataFormat()) return; |
| | | CellStyle ns = wb.createCellStyle(); |
| | | if (cur != null) ns.cloneStyleFrom(cur); |
| | | ns.setDataFormat(idx); |
| | | dst.setCellStyle(ns); |
| | | } catch (Exception ignore) { |
| | | // 个别格式异常不阻塞还原 |
| | | } |
| | | } |
| | | /** 单元格内容文本化(公式带 = 前缀),用于跨簿比对 */ |
| | | 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 ""; |
| | | } |
| | | } |
| | | |
| | | /** 把 8 个台账/合成页裁剪到 keepMonths 月: |
| | | * 月度值列 2026 年 m 月 = 第 3+2*(m-1) 列(C/E/G/I/K/M/O),同比列为其后一列; |
| | | * 累计 Q 列 = C+E+...+O,累计同比 R 列分母 = 2025 年同月列 T/V/X/Z/AB/AD/AF。 |
| | | * 裁剪 = 数据行(含累计公式且任一 2026 月度列有内容)中 keepMonths+1..7 月的当月值/同比单元格清空(表头保留), |
| | | * 并把该行累计公式去掉对应 +列,累计同比分母去掉对应 2025 列。 */ |
| | | private int trimSummaryMonthlySheets(XSSFWorkbook wb, int keepMonths) { |
| | | String[] scope = {" 货运", "班线包车", "城市客运", "公交", "出租", "网约车", "轨道、轮渡", "中口径明细"}; |
| | | java.util.Set<String> scopeSet = new java.util.HashSet<>(); |
| | | for (String s : scope) scopeSet.add(s); |
| | | int touched = 0; |
| | | for (int i = 0; i < wb.getNumberOfSheets(); i++) { |
| | | Sheet sh = wb.getSheetAt(i); |
| | | if (sh == null || !scopeSet.contains(sh.getSheetName())) continue; |
| | | for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | Cell q = row.getCell(16); // Q 列 = 累计 |
| | | if (q == null || q.getCellType() != CellType.FORMULA) continue; |
| | | boolean hasMonthly = false; |
| | | for (int m = 1; m <= 7 && !hasMonthly; m++) { |
| | | Cell v = row.getCell(2 + 2 * (m - 1)); // C,E,G,I,K,M,O |
| | | if (v != null && v.getCellType() != CellType.BLANK) hasMonthly = true; |
| | | } |
| | | if (!hasMonthly) continue; // 排除页首/页中跨页签校验公式行(无月度值) |
| | | int excelRow = r + 1; // 公式内引用为 Excel 1-based 行号 |
| | | for (int m = keepMonths + 1; m <= 7; m++) { |
| | | int valueCol = 2 + 2 * (m - 1); // 2026 当月值列 |
| | | int yoyCol = valueCol + 1; // 当月同比列 |
| | | String vLetter = colLetter(valueCol); |
| | | Cell vc = row.getCell(valueCol); |
| | | if (vc != null) { |
| | | vc.setBlank(); |
| | | touched++; |
| | | } |
| | | Cell yc = row.getCell(yoyCol); |
| | | if (yc != null) { |
| | | yc.setBlank(); |
| | | touched++; |
| | | } |
| | | String fq = q.getCellFormula(); |
| | | if (fq != null && fq.contains("+" + vLetter + excelRow)) { |
| | | q.setCellFormula(fq.replace("+" + vLetter + excelRow, "")); |
| | | touched++; |
| | | } |
| | | Cell rc = row.getCell(17); // R 列 = 累计同比 |
| | | if (rc != null && rc.getCellType() == CellType.FORMULA) { |
| | | String fr = rc.getCellFormula(); |
| | | int lastCol = 19 + 2 * (m - 1); // 2025 同月列 T..AF(0-based:T=19,AF=31) |
| | | String lLetter = colLetter(lastCol); |
| | | String txt = fr; |
| | | if (txt != null && txt.contains("," + lLetter + excelRow)) { |
| | | txt = txt.replace("," + lLetter + excelRow, ""); |
| | | touched++; |
| | | } |
| | | if (txt != null && txt.contains("+" + lLetter + excelRow)) { |
| | | txt = txt.replace("+" + lLetter + excelRow, ""); |
| | | touched++; |
| | | } |
| | | if (txt != null && !txt.equals(fr)) rc.setCellFormula(txt); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | return touched; |
| | | } |
| | | |
| | | /** 汇总工作簿年份动态化:仅把 2026年 前缀替换为目标年(2025/2024 参考列不动) */ |
| | |
| | | CellType ct = cell.getCellType(); |
| | | if (ct != CellType.NUMERIC && ct != CellType.FORMULA) continue; |
| | | if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) continue; |
| | | if (ct == CellType.NUMERIC && Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue()) continue; |
| | | boolean integral = false; |
| | | if (ct == CellType.NUMERIC) { |
| | | integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue(); |
| | | } else if (cell.getCachedFormulaResultType() == CellType.NUMERIC) { |
| | | integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue(); |
| | | } |
| | | if (integral) continue; // 整数不显示 .00(含公式结果为整数的格) |
| | | org.apache.poi.ss.usermodel.CellStyle cs = cell.getCellStyle(); |
| | | if (cs == null) continue; |
| | | String f = cs.getDataFormatString(); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 汇总表导出后按内容自动加宽列宽(只加宽不缩窄;跳过合并单元格标题,防长标题把单列撑爆) */ |
| | | /** 汇总大表输出前清理母版残留下来的筛选与行隐藏:筛选箭头/隐藏行会让整页看起来“缺数据” */ |
| | | private void cleanSummarySheetPresentation(org.apache.poi.ss.usermodel.Workbook wb) { |
| | | if (wb == null) return; |
| | | for (int s = 0; s < wb.getNumberOfSheets(); s++) { |
| | | org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(s); |
| | | if (sh == null) continue; |
| | | if (sh instanceof org.apache.poi.xssf.usermodel.XSSFSheet) { |
| | | org.apache.poi.xssf.usermodel.XSSFSheet xs = (org.apache.poi.xssf.usermodel.XSSFSheet) sh; |
| | | if (xs.getCTWorksheet().isSetAutoFilter()) xs.getCTWorksheet().unsetAutoFilter(); |
| | | for (org.apache.poi.ss.usermodel.Row row : xs) { |
| | | if (row == null) continue; |
| | | org.apache.poi.xssf.usermodel.XSSFRow xr = (org.apache.poi.xssf.usermodel.XSSFRow) row; |
| | | if (xr.getCTRow().isSetHidden()) xr.getCTRow().unsetHidden(); |
| | | } |
| | | } else { |
| | | sh.setAutoFilter(null); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private void autoFitContentColumns(org.apache.poi.ss.usermodel.Workbook wb) { |
| | | if (wb == null) return; |
| | | org.apache.poi.ss.usermodel.DataFormatter dfmt = new org.apache.poi.ss.usermodel.DataFormatter(); |
| | | for (int s = 0; s < wb.getNumberOfSheets(); s++) { |
| | | org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(s); |
| | | if (sh == null) continue; |
| | | java.util.Set<String> merged = new java.util.HashSet<>(); |
| | | for (org.apache.poi.ss.util.CellRangeAddress ra : sh.getMergedRegions()) { |
| | | for (int r = ra.getFirstRow(); r <= ra.getLastRow(); r++) { |
| | | for (int c = ra.getFirstColumn(); c <= ra.getLastColumn(); c++) { |
| | | merged.add(r + ":" + c); |
| | | } |
| | | } |
| | | } |
| | | int maxCol = -1; |
| | | for (org.apache.poi.ss.usermodel.Row row : sh) { |
| | | if (row == null) continue; |
| | | maxCol = Math.max(maxCol, (int) row.getLastCellNum() - 1); |
| | | } |
| | | if (maxCol < 0) continue; |
| | | int[] need = new int[maxCol + 1]; |
| | | for (org.apache.poi.ss.usermodel.Row row : sh) { |
| | | if (row == null) continue; |
| | | for (org.apache.poi.ss.usermodel.Cell cell : row) { |
| | | int c = cell.getColumnIndex(); |
| | | if (c > maxCol || merged.contains(row.getRowNum() + ":" + c)) continue; |
| | | String txt; |
| | | try { |
| | | txt = dfmt.formatCellValue(cell); |
| | | } catch (Exception ignore) { |
| | | continue; |
| | | } |
| | | if (txt == null || txt.isEmpty()) continue; |
| | | int units = 0; |
| | | for (int i = 0; i < txt.length(); i++) { |
| | | units += isWideChar(txt.charAt(i)) ? 2 : 1; |
| | | } |
| | | if (units > need[c]) need[c] = Math.min(units, 34); // 长文本不把单列撑爆 |
| | | } |
| | | } |
| | | for (int c = 0; c <= maxCol; c++) { |
| | | if (need[c] <= 0) continue; |
| | | int target = Math.min(need[c] + 2, 46) * 256; |
| | | if (target > sh.getColumnWidth(c)) sh.setColumnWidth(c, target); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** CJK / 全角字符按两倍宽度计(近似 Excel 显示列宽) */ |
| | | private boolean isWideChar(char ch) { |
| | | return (ch >= 0x2E80 && ch <= 0x9FFF) || (ch >= 0xF900 && ch <= 0xFAFF) |
| | | || (ch >= 0xFF00 && ch <= 0xFFEF); |
| | | } |
| | | |
| | | private byte[] toBytes(XSSFWorkbook wb) throws Exception { |
| | | applyTwoDecimalFormat(wb); |
| | | try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { |