| | |
| | | import org.apache.poi.ss.usermodel.Sheet; |
| | | import org.apache.poi.ss.usermodel.Workbook; |
| | | import org.apache.poi.ss.usermodel.WorkbookFactory; |
| | | import org.apache.poi.xwpf.usermodel.XWPFDocument; |
| | | import org.apache.poi.xwpf.usermodel.XWPFParagraph; |
| | | import org.apache.poi.xwpf.usermodel.XWPFTable; |
| | | import org.apache.poi.xwpf.usermodel.XWPFTableRow; |
| | | import org.w3c.dom.Document; |
| | | import org.w3c.dom.Element; |
| | | import org.w3c.dom.Node; |
| | | import org.w3c.dom.NodeList; |
| | | import cn.hutool.core.io.IoUtil; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | |
| | | import java.util.Arrays; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.TreeMap; |
| | | import java.util.stream.Collectors; |
| | | |
| | | import java.text.SimpleDateFormat; |
| | |
| | | entity.setRegionCode(gs(row, colMap, "所属地区")); |
| | | entity.setEnterpriseCode(gs(row, colMap, "企业代码")); |
| | | entity.setEnterpriseName(name.trim()); |
| | | // 新版模板在「企业名称」后新增了两列(统一社会信用代码/填报单位),按表头取值,老模板无该列时为 null |
| | | entity.setUnifiedCreditCode(gs(row, colMap, "统一社会信用代码")); |
| | | entity.setReportUnit(gs(row, colMap, "填报单位")); |
| | | entity.setVehicleTotal(gi(row, colMap, "客运车辆_总计")); |
| | | entity.setSeatTotal(gi(row, colMap, "核定载客位_总计")); |
| | | entity.setVehicleSchedule(gi(row, colMap, "车辆数_客运班车(含定线旅游客运客车)")); |
| | |
| | | // ========== 货运量周转量(模板_货运量周转量.xlsx,每月导入) ========== |
| | | |
| | | public int importFreightTurnover(MultipartFile file, String period) throws Exception { |
| | | try (Workbook wb = WorkbookFactory.create(file.getInputStream())) { |
| | | // 多年度工作簿(一年一个 Sheet:整年 1-12 月,如 21-26 年定稿文件)→ 全部年份入库 |
| | | List<String> yearSheets = detectYearSheets(wb); |
| | | if (!yearSheets.isEmpty()) { |
| | | return importFreightTurnoverByYear(wb, file, yearSheets); |
| | | } |
| | | return importFreightTurnoverByMonth(wb, file, period); |
| | | } |
| | | } |
| | | |
| | | /** 每月导入(模板_货运量周转量.xlsx):左半今年 1-N 月,右半去年 1-12 月 */ |
| | | private int importFreightTurnoverByMonth(Workbook wb, MultipartFile file, String period) throws Exception { |
| | | List<FreightTurnoverImport> list = new ArrayList<>(); |
| | | int failRows = 0; |
| | | // 报表期 N 月:左半为今年 1-N 月(列数不固定),右半固定为去年 1-12 月(算同比用) |
| | | int monthCount = Integer.parseInt(period.split("-")[1]); |
| | | try (Workbook wb = WorkbookFactory.create(file.getInputStream())) { |
| | | { |
| | | Sheet sheet = wb.getSheetAt(0); |
| | | Row header = sheet.getRow(0); |
| | | String h0 = header == null ? null : getString(header, 0); |
| | |
| | | } |
| | | list.add(entity); |
| | | } |
| | | } catch (Exception e) { |
| | | throw e; |
| | | } |
| | | Map<String, List<FreightTurnoverImport>> byPeriod = new LinkedHashMap<>(); |
| | | byPeriod.put(period, list); |
| | | return persistFreightTurnover(byPeriod, file.getOriginalFilename()); |
| | | } |
| | | |
| | | // 幂等:同报表期先删除 |
| | | freightTurnoverMapper.delete(new LambdaQueryWrapper<FreightTurnoverImport>() |
| | | .eq(FreightTurnoverImport::getReportPeriod, period)); |
| | | /** 识别「多年度工作簿」:每个 Sheet 一年(Sheet 名 2021年/2022年…),且表头为「货运量/1月…12月」;否则返回空列表 */ |
| | | private List<String> detectYearSheets(Workbook wb) { |
| | | List<String> names = new ArrayList<>(); |
| | | if (wb.getNumberOfSheets() < 2) return names; |
| | | for (int i = 0; i < wb.getNumberOfSheets(); i++) { |
| | | Sheet sheet = wb.getSheetAt(i); |
| | | String nm = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); |
| | | if (!nm.matches("\\d{4}\\s*年?")) { names.clear(); return names; } |
| | | Row header = sheet.getRow(0); |
| | | String h0 = header == null ? null : getString(header, 0); |
| | | String h1 = header == null ? null : getString(header, 1); |
| | | if (h0 == null || !h0.contains("货运量") || h1 == null || !h1.contains("月")) { |
| | | names.clear(); |
| | | return names; |
| | | } |
| | | names.add(nm); |
| | | } |
| | | return names; |
| | | } |
| | | |
| | | int success = 0; |
| | | for (FreightTurnoverImport entity : list) { |
| | | try { |
| | | freightTurnoverMapper.insert(entity); |
| | | success++; |
| | | } catch (Exception e) { |
| | | log.error("freight turnover insert error: {}", entity.getRegionName(), e); |
| | | failRows++; |
| | | /** 多年度工作簿导入:逐年一条报表期(报表期=该年最后有数据的月份),上年同期取上一年度 Sheet */ |
| | | private int importFreightTurnoverByYear(Workbook wb, MultipartFile file, List<String> yearSheets) { |
| | | Map<Integer, String> sheetByYear = new TreeMap<>(); |
| | | for (String sheetName : yearSheets) { |
| | | sheetByYear.put(Integer.parseInt(sheetName.replace("年", "").trim()), sheetName); |
| | | } |
| | | Map<Integer, Map<String, FreightTurnoverImport>> byYear = new TreeMap<>(); |
| | | Map<Integer, Integer> maxMonthByYear = new HashMap<>(); |
| | | for (Map.Entry<Integer, String> entry : sheetByYear.entrySet()) { |
| | | Map<String, FreightTurnoverImport> parsed = new LinkedHashMap<>(); |
| | | int maxMonth = parseWholeYearSheet(wb.getSheet(entry.getValue()), parsed); |
| | | if (parsed.isEmpty()) continue; |
| | | byYear.put(entry.getKey(), parsed); |
| | | maxMonthByYear.put(entry.getKey(), Math.max(1, Math.min(12, maxMonth))); |
| | | } |
| | | Map<String, List<FreightTurnoverImport>> byPeriod = new LinkedHashMap<>(); |
| | | for (Map.Entry<Integer, Map<String, FreightTurnoverImport>> entry : byYear.entrySet()) { |
| | | int year = entry.getKey(); |
| | | String yearPeriod = year + "-" + String.format("%02d", maxMonthByYear.get(year)); |
| | | Map<String, FreightTurnoverImport> lastYear = byYear.get(year - 1); |
| | | List<FreightTurnoverImport> rows = new ArrayList<>(); |
| | | for (Map.Entry<String, FreightTurnoverImport> regionEntry : entry.getValue().entrySet()) { |
| | | FreightTurnoverImport entity = regionEntry.getValue(); |
| | | entity.setReportPeriod(yearPeriod); |
| | | // 上年同期 = 上一年度 Sheet(多年度文件没有右半「去年1-12月」区块) |
| | | FreightTurnoverImport last = lastYear == null ? null : lastYear.get(regionEntry.getKey()); |
| | | if (last != null) { |
| | | for (int m = 1; m <= 12; m++) { |
| | | setLastFreight(entity, m, getFreight(last, m)); |
| | | setLastTurnover(entity, m, getTurnover(last, m)); |
| | | } |
| | | } |
| | | rows.add(entity); |
| | | } |
| | | byPeriod.put(yearPeriod, rows); |
| | | } |
| | | return persistFreightTurnover(byPeriod, file.getOriginalFilename()); |
| | | } |
| | | |
| | | /** 解析整年 Sheet(1-12 月列):货运量块 + 周转量块;返回最后一个有货运量数据的月份 */ |
| | | private int parseWholeYearSheet(Sheet sheet, Map<String, FreightTurnoverImport> out) { |
| | | Map<String, FreightTurnoverImport> freightMap = new LinkedHashMap<>(); |
| | | Map<String, FreightTurnoverImport> turnoverMap = new LinkedHashMap<>(); |
| | | Set<String> seen = new HashSet<>(); |
| | | int maxMonth = 0; |
| | | for (int r = 1; r <= sheet.getLastRowNum(); r++) { |
| | | Row row = sheet.getRow(r); |
| | | if (row == null) continue; |
| | | String name = getString(row, 0); |
| | | if (name == null || name.trim().isEmpty()) continue; |
| | | String region = RegionUtil.normalizeCityName(name); |
| | | boolean isCity = RegionUtil.cityList().contains(region); |
| | | if (!isCity && !"湖北省".equals(region)) continue; // 表头/说明行 |
| | | if (seen.contains(region)) { |
| | | // 该名称已出现过 -> 周转量区块 |
| | | FreightTurnoverImport entity = turnoverMap.get(region); |
| | | if (entity == null) { |
| | | entity = new FreightTurnoverImport(); |
| | | entity.setRegionName(name.trim()); |
| | | turnoverMap.put(region, entity); |
| | | } |
| | | for (int m = 1; m <= 12; m++) { |
| | | setTurnover(entity, m, getDecimalOrNull(row, m)); |
| | | } |
| | | } else { |
| | | seen.add(region); |
| | | FreightTurnoverImport entity = freightMap.get(region); |
| | | if (entity == null) { |
| | | entity = new FreightTurnoverImport(); |
| | | entity.setRegionName(name.trim()); |
| | | freightMap.put(region, entity); |
| | | } |
| | | for (int m = 1; m <= 12; m++) { |
| | | Double v = getDecimalOrNull(row, m); |
| | | setFreight(entity, m, v); |
| | | if (v != null && m > maxMonth) maxMonth = m; |
| | | } |
| | | } |
| | | } |
| | | recordBatch(file.getOriginalFilename(), "FREIGHT_TURNOVER", period, list.size(), success, failRows, null); |
| | | log.info("Freight turnover imported: {} rows for {}", success, period); |
| | | return success; |
| | | // 合并:一个市州一行(货运量 + 周转量) |
| | | for (Map.Entry<String, FreightTurnoverImport> entry : freightMap.entrySet()) { |
| | | FreightTurnoverImport entity = entry.getValue(); |
| | | FreightTurnoverImport turnover = turnoverMap.get(entry.getKey()); |
| | | if (turnover != null) { |
| | | for (int m = 1; m <= 12; m++) { |
| | | setTurnover(entity, m, getTurnover(turnover, m)); |
| | | } |
| | | } |
| | | out.put(entry.getKey(), entity); |
| | | } |
| | | return maxMonth; |
| | | } |
| | | |
| | | /** 落库:按报表期先删后插(幂等),每个报表期一条导入批次记录,返回成功条数 */ |
| | | private int persistFreightTurnover(Map<String, List<FreightTurnoverImport>> byPeriod, String fileName) { |
| | | int total = 0; |
| | | for (Map.Entry<String, List<FreightTurnoverImport>> entry : byPeriod.entrySet()) { |
| | | String p = entry.getKey(); |
| | | List<FreightTurnoverImport> rows = entry.getValue(); |
| | | freightTurnoverMapper.delete(new LambdaQueryWrapper<FreightTurnoverImport>() |
| | | .eq(FreightTurnoverImport::getReportPeriod, p)); |
| | | int failRows = 0; |
| | | int success = 0; |
| | | for (FreightTurnoverImport entity : rows) { |
| | | try { |
| | | freightTurnoverMapper.insert(entity); |
| | | success++; |
| | | } catch (Exception e) { |
| | | log.error("freight turnover insert error: {}", entity.getRegionName(), e); |
| | | failRows++; |
| | | } |
| | | } |
| | | recordBatch(fileName, "FREIGHT_TURNOVER", p, rows.size(), success, failRows, null); |
| | | log.info("Freight turnover imported: {} rows for {}", success, p); |
| | | total += success; |
| | | } |
| | | return total; |
| | | } |
| | | |
| | | /** 解析货运量区块行:左半 B 起 N 列为今年 1-N 月,右半为去年 1-12 月 */ |
| | |
| | | CLEAR_TYPE_TO_OP.put("cityTaxiAuth", "CITY_TAXI_AUTH"); |
| | | CLEAR_TYPE_TO_OP.put("wycTotal", "WYC_TOTAL"); |
| | | CLEAR_TYPE_TO_OP.put("wycOrder", "WYC_ORDER"); |
| | | CLEAR_TYPE_TO_OP.put("wycOrderReport", "WYC_ORDER"); |
| | | |
| | | CLEAR_TYPE_TO_AUDIT.put("h2032", new String[]{"H2032"}); |
| | | CLEAR_TYPE_TO_AUDIT.put("transportAuth", new String[]{"H2032"}); |
| | |
| | | CLEAR_TYPE_TO_BATCH.put("cityTaxi", new String[]{"CITY_TAXI"}); |
| | | CLEAR_TYPE_TO_BATCH.put("cityTaxiAuth", new String[]{"CITY_TAXI_AUTH"}); |
| | | CLEAR_TYPE_TO_BATCH.put("wycTotal", new String[]{"WYC_TOTAL"}); |
| | | CLEAR_TYPE_TO_BATCH.put("wycOrder", new String[]{"WYC_ORDER"}); |
| | | CLEAR_TYPE_TO_BATCH.put("wycOrder", new String[]{"WYC_ORDER", "WYC_ORDER_REPORT"}); |
| | | CLEAR_TYPE_TO_BATCH.put("wycOrderReport", new String[]{"WYC_ORDER_REPORT", "WYC_ORDER"}); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | if (name == null || name.trim().isEmpty()) continue; |
| | | String nt = name.trim().replaceAll("[\r\n]+", ""); |
| | | if (nt.contains("合计") || nt.contains("总计")) continue; |
| | | String ntFlat = nt.replaceAll("\\s+", ""); |
| | | if (ntFlat.contains("合计") || ntFlat.contains("总计") || ntFlat.contains("小计")) continue; |
| | | if (nt.matches("^[一二三四五六七八九十]+、.*")) continue; |
| | | if (nt.matches("^([一二三四五六七八九十]+).*")) continue; |
| | | if (nt.replace(" ", "").matches(".*小\\s*计.*")) continue; |
| | | if (nt.startsWith("填报") || nt.startsWith("单位负责人") || nt.matches("^\\d+、.*")) continue; |
| | | if (hasSeq) { |
| | | if (seq == null || seq.trim().isEmpty()) { |
| | |
| | | } |
| | | if (!hasName) continue; |
| | | Map<Integer, String> colTexts = new HashMap<>(); |
| | | for (int rr = r; rr <= Math.min(r + 3, sh.getLastRowNum()); rr++) { |
| | | int scanEnd = investHeaderScanEnd(sh, r, row); |
| | | for (int rr = r; rr <= scanEnd; rr++) { |
| | | Row rr2 = sh.getRow(rr); |
| | | if (rr2 == null) continue; |
| | | for (int c = 0; c <= rr2.getLastCellNum(); c++) { |
| | |
| | | else if (v.contains("自年初累计")) col.putIfAbsent("yearcum", c); |
| | | else if (v.contains("本月完成")) col.putIfAbsent("monthdone", c); |
| | | else if (v.contains("建设阶段")) col.putIfAbsent("stage", c); |
| | | else if (v.contains("形象进度")) col.putIfAbsent("desc", c); |
| | | else if (v.contains("形象进度") || v.contains("建设进展")) col.putIfAbsent("desc", c); |
| | | else if (v.contains("工可") && !v.contains("项目名称")) col.putIfAbsent("gk", c); |
| | | else if (v.contains("初设") && !v.contains("项目名称")) col.putIfAbsent("cs", c); |
| | | else if (v.contains("建筑面积") && !v.contains("新增")) col.putIfAbsent("area", c); |
| | |
| | | int md = col.get("yearcum") + 1; |
| | | if (md <= row.getLastCellNum()) col.put("monthdone", md); |
| | | } |
| | | // desc 缺失时按 stage+1 兜底:省标「项目建设阶段、进度情况」是 L/M 合并表头, |
| | | // 孝感/恩施/林区/随州等市州只在 L 写「建设阶段」、M 直接写进度文字而不写标题 |
| | | if (!col.containsKey("desc") && col.containsKey("stage") && col.get("stage") >= 0) { |
| | | int dc = col.get("stage") + 1; |
| | | if (dc <= row.getLastCellNum()) col.put("desc", dc); |
| | | } |
| | | for (String k : new String[]{"builder", "nature", "start", "end", "total", "startcum", |
| | | "yearplan", "yearcum", "monthdone", "stage", "desc", "gk", "cs", "area"}) { |
| | | col.putIfAbsent(k, -1); |
| | |
| | | return col; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** 表头文本扫描下界:默认表头行 +3;若「项目名称」列首个数据行更靠下,则扩展到数据行前一行(上限 +12)。 |
| | | * 背景:咸宁/十堰等市州把「建设阶段/形象进度」标签写在表头下第 4~8 行,固定 +3 会漏列(形象进度读不到)。 */ |
| | | private int investHeaderScanEnd(Sheet sh, int headerRow, Row headerRowObj) { |
| | | int hardEnd = Math.min(headerRow + 12, sh.getLastRowNum()); |
| | | int nameCol = -1; |
| | | if (headerRowObj != null) { |
| | | for (int c = 0; c <= headerRowObj.getLastCellNum(); c++) { |
| | | String v = cellStr(headerRowObj, c); |
| | | if (v != null && v.contains("项目名称")) { nameCol = c; break; } |
| | | } |
| | | } |
| | | int firstData = findFirstInvestDataRow(sh, headerRow, nameCol); |
| | | int scanEnd = (firstData > headerRow) |
| | | ? Math.min(hardEnd, firstData - 1) |
| | | : Math.min(headerRow + 3, sh.getLastRowNum()); |
| | | if (scanEnd < headerRow) scanEnd = headerRow; |
| | | return scanEnd; |
| | | } |
| | | |
| | | /** 「项目名称」列首个数据行下标(跳过表头续行与空行);找不到返回 -1 */ |
| | | private int findFirstInvestDataRow(Sheet sh, int headerRow, int nameCol) { |
| | | if (nameCol < 0) return -1; |
| | | for (int r = headerRow + 1; r <= sh.getLastRowNum(); r++) { |
| | | Row row = sh.getRow(r); |
| | | if (row == null) continue; |
| | | String name = cellStr(row, nameCol); |
| | | if (name == null || name.trim().isEmpty()) continue; |
| | | String nt = name.trim().replaceAll("[\r\n]+", ""); |
| | | if (nt.contains("项目名称")) continue; |
| | | String flat = nt.replaceAll("\\s+", ""); |
| | | if (flat.contains("合计") || flat.contains("总计") || flat.contains("小计")) continue; |
| | | if (nt.matches("^[一二三四五六七八九十]+、.*")) continue; |
| | | if (nt.matches("^([一二三四五六七八九十]+).*")) continue; |
| | | if (nt.startsWith("填报") || nt.startsWith("单位负责人") || nt.matches("^\\d+、.*")) continue; |
| | | if (investmentCity(nt) != null) continue; |
| | | return r; |
| | | } |
| | | return -1; |
| | | } |
| | | |
| | | /** 统计 sheet 内项目数据行数(用于多 sheet 历史文件选当月 sheet) */ |
| | |
| | | String name = cellStr(row, nameCol); |
| | | if (name == null || name.trim().isEmpty()) continue; |
| | | String nt = name.trim().replaceAll("[\r\n]+", ""); |
| | | if (nt.contains("合计") || nt.contains("总计")) continue; |
| | | String ntFlat = nt.replaceAll("\\s+", ""); |
| | | if (ntFlat.contains("合计") || ntFlat.contains("总计") || ntFlat.contains("小计")) continue; |
| | | if (nt.matches("^[一二三四五六七八九十]+、.*")) continue; |
| | | if (nt.replace(" ", "").matches(".*小\\s*计.*")) continue; |
| | | if (nt.startsWith("填报") || nt.startsWith("单位负责人") || nt.matches("^\\d+、.*")) continue; |
| | | if (!hasSeq) { |
| | | if (nt.length() > 4) count++; |
| | |
| | | return ok; |
| | | } |
| | | |
| | | // ========== 网约车订单运行监测分析报告(docx)导入 ========== |
| | | |
| | | /** 网约车订单报告导入结果(报表期/订单合计/覆盖市州数/缺失市州) */ |
| | | public static class WycOrderReportResult { |
| | | private final String period; |
| | | private final double orderSum; |
| | | private final int cityCount; |
| | | private final List<String> missingCities; |
| | | |
| | | public WycOrderReportResult(String period, double orderSum, int cityCount, List<String> missingCities) { |
| | | this.period = period; |
| | | this.orderSum = orderSum; |
| | | this.cityCount = cityCount; |
| | | this.missingCities = missingCities; |
| | | } |
| | | |
| | | public String getPeriod() { return period; } |
| | | public double getOrderSum() { return orderSum; } |
| | | public int getCityCount() { return cityCount; } |
| | | public List<String> getMissingCities() { return missingCities; } |
| | | } |
| | | |
| | | /** |
| | | * 《网约车行业YYYY年M月运行监测情况分析报告》(docx)导入: |
| | | * 解析报告附件中「地市名称 | … | 订单数总数」表的 17 市州订单数,写入 wyc_order_monthly 的 |
| | | * F 订单合计与 G-W 17 市州订单列;全省 pin(B-E)沿用该期已有行,若无该期行则从 |
| | | * wyc_total_monthly 同报表期复制。报表期优先取报告标题年月,取不到时用页面所选报表期。 |
| | | */ |
| | | public synchronized WycOrderReportResult importWycOrderReport(MultipartFile file, String period) throws Exception { |
| | | Map<String, Double> orders = new LinkedHashMap<>(); |
| | | double[] sumHolder = new double[]{0.0}; |
| | | String docPeriod; |
| | | byte[] bytes; |
| | | try (java.io.InputStream in = file.getInputStream()) { |
| | | bytes = IoUtil.readBytes(in); |
| | | } |
| | | boolean isZip = bytes.length > 3 && (bytes[0] & 0xFF) == 0x50 && (bytes[1] & 0xFF) == 0x4B; |
| | | boolean isWordMl = !isZip && looksLikeWordMl(bytes); |
| | | try { |
| | | if (isZip && !isWordPackage(bytes)) { |
| | | throw new RuntimeException("这不是 Word 文档(压缩包里没有 word/document.xml)," |
| | | + "多半是 Excel 或其它格式。请上传网约车订单分析报告的 .docx(或 Word 2003 XML)"); |
| | | } |
| | | if (isZip) { |
| | | try (XWPFDocument doc = new XWPFDocument(new java.io.ByteArrayInputStream(bytes))) { |
| | | docPeriod = periodOfParagraphs(paragraphsOfDocx(doc)); |
| | | parseWycOrderReportTables(doc, orders, sumHolder); |
| | | } |
| | | } else if (isWordMl) { |
| | | // 网约车监管平台下发的报告常被 WPS/Word 存成「Word 2003 XML(平铺 XML)」后再改名 .docx: |
| | | // 这种文件不是 zip 包,XWPFDocument 打不开(报 "File is not a zip file"),需按 XML 直接解析。 |
| | | String xml = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); |
| | | Document dom = parseXmlSafely(xml); |
| | | docPeriod = periodOfParagraphs(paragraphsOfDocxXml(dom)); |
| | | parseWycOrderReportTablesXml(dom, orders, sumHolder); |
| | | } else if (isOle2(bytes)) { |
| | | throw new RuntimeException("这是 Word 97-2003 老格式(.doc),请用 WPS/Word 打开后「另存为」docx 格式再导入"); |
| | | } else { |
| | | throw new RuntimeException("无法识别的文件格式:既不是 docx(zip 包),也不是 Word 2003 XML 或 .doc"); |
| | | } |
| | | } catch (RuntimeException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new RuntimeException("无法读取 Word 文档,请上传 .docx 格式的《网约车行业YYYY年M月运行监测情况分析报告》;" |
| | | + "若上传的其实是 Excel/PDF,请改用对应模板导入。(原始错误:" + e.getMessage() + ")"); |
| | | } |
| | | if (orders.isEmpty()) { |
| | | throw new RuntimeException("未解析到任何市州订单数,请确认上传的是《网约车行业YYYY年M月运行监测情况分析报告》"); |
| | | } |
| | | String rp = null; |
| | | if (docPeriod != null && docPeriod.matches("\\d{4}-\\d{2}")) rp = docPeriod; |
| | | else if (period != null && period.matches("\\d{4}-\\d{2}")) rp = period; |
| | | if (rp == null) { |
| | | throw new RuntimeException("无法确定报表期:报告标题未含「YYYY年M月」,且页面未选择报表期"); |
| | | } |
| | | |
| | | List<String> missing = new ArrayList<>(); |
| | | double citySum = 0.0; |
| | | for (String city : RegionUtil.cityList()) { |
| | | Double v = orders.get(city); |
| | | if (v == null) missing.add(city); |
| | | else citySum += v; |
| | | } |
| | | double sum = sumHolder[0] > 0 ? sumHolder[0] : citySum; |
| | | |
| | | WycOrderMonthly it = wycOrderMapper.selectOne(new LambdaQueryWrapper<WycOrderMonthly>() |
| | | .eq(WycOrderMonthly::getReportPeriod, rp).last("LIMIT 1")); |
| | | if (it == null) it = new WycOrderMonthly(); |
| | | it.setReportPeriod(rp); |
| | | it.setOrderSum(sum > 0 ? sum : null); |
| | | it.setOrderWuhan(orderOf(orders, "武汉市")); |
| | | it.setOrderHuangshi(orderOf(orders, "黄石市")); |
| | | it.setOrderShiyan(orderOf(orders, "十堰市")); |
| | | it.setOrderYichang(orderOf(orders, "宜昌市")); |
| | | it.setOrderXiangyang(orderOf(orders, "襄阳市")); |
| | | it.setOrderEzhou(orderOf(orders, "鄂州市")); |
| | | it.setOrderJingmen(orderOf(orders, "荆门市")); |
| | | it.setOrderXiaogan(orderOf(orders, "孝感市")); |
| | | it.setOrderJingzhou(orderOf(orders, "荆州市")); |
| | | it.setOrderHuanggang(orderOf(orders, "黄冈市")); |
| | | it.setOrderXianning(orderOf(orders, "咸宁市")); |
| | | it.setOrderSuizhou(orderOf(orders, "随州市")); |
| | | it.setOrderEnshi(orderOf(orders, "恩施州")); |
| | | it.setOrderXiantao(orderOf(orders, "仙桃市")); |
| | | it.setOrderQianjiang(orderOf(orders, "潜江市")); |
| | | it.setOrderTianmen(orderOf(orders, "天门市")); |
| | | it.setOrderShennong(orderOf(orders, "神农架林区")); |
| | | |
| | | if (it.getPinK() == null || it.getPinCK() == null || it.getPinZ() == null || it.getPinCZ() == null) { |
| | | WycTotalMonthly tot = wycTotalMapper.selectOne(new LambdaQueryWrapper<WycTotalMonthly>() |
| | | .eq(WycTotalMonthly::getReportPeriod, rp).last("LIMIT 1")); |
| | | if (tot != null) { |
| | | if (it.getPinK() == null) it.setPinK(tot.getPinK()); |
| | | if (it.getPinCK() == null) it.setPinCK(tot.getPinCK()); |
| | | if (it.getPinZ() == null) it.setPinZ(tot.getPinZ()); |
| | | if (it.getPinCZ() == null) it.setPinCZ(tot.getPinCZ()); |
| | | } |
| | | } |
| | | |
| | | wycOrderMapper.delete(new LambdaQueryWrapper<WycOrderMonthly>().eq(WycOrderMonthly::getReportPeriod, rp)); |
| | | wycOrderMapper.insert(it); |
| | | recordBatch(file.getOriginalFilename(), "WYC_ORDER_REPORT", rp, 1, 1, 0, null); |
| | | log.info("WycOrderReport imported: period={} sum={} cities={} missing={}", rp, sum, orders.size(), missing); |
| | | return new WycOrderReportResult(rp, sum, orders.size(), missing); |
| | | } |
| | | |
| | | /** 报告标题中的报表期:网约车行业2026年7月 -> 2026-07;取不到返回 null */ |
| | | private String periodOfParagraphs(List<String> paragraphs) { |
| | | java.util.regex.Pattern titlePat = java.util.regex.Pattern.compile("网约车行业\\s*(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月"); |
| | | java.util.regex.Pattern anyPat = java.util.regex.Pattern.compile("(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月"); |
| | | String fallback = null; |
| | | int scanned = 0; |
| | | for (String text : paragraphs) { |
| | | if (text == null) continue; |
| | | String flat = text.replaceAll("\\s+", ""); |
| | | if (flat.isEmpty()) continue; |
| | | java.util.regex.Matcher m = titlePat.matcher(flat); |
| | | if (m.find()) return ymd(m.group(1), m.group(2)); |
| | | if (fallback == null && scanned < 10) { |
| | | java.util.regex.Matcher m2 = anyPat.matcher(flat); |
| | | if (m2.find()) fallback = ymd(m2.group(1), m2.group(2)); |
| | | } |
| | | scanned++; |
| | | } |
| | | return fallback; |
| | | } |
| | | |
| | | /** docx 段落文本(保持原始顺序) */ |
| | | private List<String> paragraphsOfDocx(XWPFDocument doc) { |
| | | List<String> out = new ArrayList<>(); |
| | | for (XWPFParagraph para : doc.getParagraphs()) out.add(para.getText()); |
| | | return out; |
| | | } |
| | | |
| | | /** Word 2003 XML(WordML)段落文本:<w:p> 下所有 <w:t> 拼接后压掉空白 */ |
| | | private List<String> paragraphsOfDocxXml(Document dom) { |
| | | List<String> out = new ArrayList<>(); |
| | | for (Element p : elementsByLocalName(dom, "p")) out.add(textOfXmlElement(p)); |
| | | return out; |
| | | } |
| | | |
| | | /** 是否为 Word 2003 XML(WordML 平铺 XML)——这种文件常被改名成 .docx */ |
| | | private boolean looksLikeWordMl(byte[] bytes) { |
| | | int n = Math.min(bytes.length, 4096); |
| | | String head = new String(bytes, 0, n, java.nio.charset.StandardCharsets.UTF_8); |
| | | return head.contains("wordml") && head.contains("<w:wordDocument"); |
| | | } |
| | | |
| | | /** zip 包内是否含 word/document.xml(用于区分 docx 与 xlsx/其它 OPC 包) */ |
| | | private boolean isWordPackage(byte[] bytes) { |
| | | try (java.util.zip.ZipInputStream zin = |
| | | new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(bytes))) { |
| | | java.util.zip.ZipEntry e; |
| | | while ((e = zin.getNextEntry()) != null) { |
| | | String n = e.getName(); |
| | | if ("word/document.xml".equals(n)) return true; |
| | | } |
| | | } catch (Exception ignore) { |
| | | return false; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** 是否为 OLE2 复合文档(Word 97-2003 .doc) */ |
| | | private boolean isOle2(byte[] b) { |
| | | return b.length > 8 && (b[0] & 0xFF) == 0xD0 && (b[1] & 0xFF) == 0xCF |
| | | && (b[2] & 0xFF) == 0x11 && (b[3] & 0xFF) == 0xE0; |
| | | } |
| | | |
| | | /** 安全解析 XML:禁用外部实体/DTD(报告来自外部平台,防 XXE) */ |
| | | private Document parseXmlSafely(String xml) throws Exception { |
| | | javax.xml.parsers.DocumentBuilderFactory f = javax.xml.parsers.DocumentBuilderFactory.newInstance(); |
| | | f.setNamespaceAware(true); |
| | | try { f.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); } catch (Exception ignore) { } |
| | | try { f.setFeature("http://xml.org/sax/features/external-general-entities", false); } catch (Exception ignore) { } |
| | | try { f.setFeature("http://xml.org/sax/features/external-parameter-entities", false); } catch (Exception ignore) { } |
| | | javax.xml.parsers.DocumentBuilder b = f.newDocumentBuilder(); |
| | | b.setEntityResolver(new org.xml.sax.EntityResolver() { |
| | | @Override |
| | | public org.xml.sax.InputSource resolveEntity(String publicId, String systemId) { |
| | | return new org.xml.sax.InputSource(new java.io.StringReader("")); |
| | | } |
| | | }); |
| | | return b.parse(new org.xml.sax.InputSource(new java.io.StringReader(xml))); |
| | | } |
| | | |
| | | /** 按 localName 取全部后代元素(命名空间无关,兼容 WordML 与 docx 的 w: 前缀) */ |
| | | private List<Element> elementsByLocalName(Document dom, String localName) { |
| | | List<Element> out = new ArrayList<>(); |
| | | NodeList list = dom.getElementsByTagNameNS("*", localName); |
| | | for (int i = 0; i < list.getLength(); i++) out.add((Element) list.item(i)); |
| | | return out; |
| | | } |
| | | |
| | | /** 只取直接子元素(避免嵌套表格/嵌套属性元素被算进来) */ |
| | | private List<Element> childElementsByLocalName(Element parent, String localName) { |
| | | List<Element> out = new ArrayList<>(); |
| | | NodeList kids = parent.getChildNodes(); |
| | | for (int i = 0; i < kids.getLength(); i++) { |
| | | Node n = kids.item(i); |
| | | if (n.getNodeType() == Node.ELEMENT_NODE && localName.equals(n.getLocalName())) out.add((Element) n); |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | /** 元素内所有 <w:t> 文本拼接并压掉空白 */ |
| | | private String textOfXmlElement(Element el) { |
| | | StringBuilder sb = new StringBuilder(); |
| | | NodeList ts = el.getElementsByTagNameNS("*", "t"); |
| | | for (int i = 0; i < ts.getLength(); i++) sb.append(ts.item(i).getTextContent()); |
| | | return flat(sb.toString()); |
| | | } |
| | | |
| | | private String ymd(String y, String mo) { |
| | | try { |
| | | int month = Integer.parseInt(mo); |
| | | if (month < 1 || month > 12) return null; |
| | | return String.format("%04d-%02d", Integer.parseInt(y), month); |
| | | } catch (NumberFormatException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** 解析报告中的「地市名称 | … | 订单数总数」表,orders=市州订单,sumHolder[0]=合计行订单数 */ |
| | | private void parseWycOrderReportTables(XWPFDocument doc, Map<String, Double> orders, double[] sumHolder) { |
| | | List<List<List<String>>> tables = new ArrayList<>(); |
| | | for (XWPFTable table : doc.getTables()) { |
| | | List<List<String>> rows = new ArrayList<>(); |
| | | for (XWPFTableRow row : table.getRows()) { |
| | | List<String> cells = new ArrayList<>(); |
| | | for (org.apache.poi.xwpf.usermodel.XWPFTableCell c : row.getTableCells()) cells.add(flat(c.getText())); |
| | | rows.add(cells); |
| | | } |
| | | tables.add(rows); |
| | | } |
| | | matchWycOrderTable(tables, orders, sumHolder); |
| | | } |
| | | |
| | | /** Word 2003 XML(WordML)分支:<w:tbl>/<w:tr>/<w:tc> 抽成文本表后走同一套匹配逻辑 */ |
| | | private void parseWycOrderReportTablesXml(Document dom, Map<String, Double> orders, double[] sumHolder) { |
| | | List<List<List<String>>> tables = new ArrayList<>(); |
| | | for (Element tbl : elementsByLocalName(dom, "tbl")) { |
| | | List<List<String>> rows = new ArrayList<>(); |
| | | for (Element tr : childElementsByLocalName(tbl, "tr")) { |
| | | List<String> cells = new ArrayList<>(); |
| | | for (Element tc : childElementsByLocalName(tr, "tc")) cells.add(textOfXmlElement(tc)); |
| | | rows.add(cells); |
| | | } |
| | | tables.add(rows); |
| | | } |
| | | matchWycOrderTable(tables, orders, sumHolder); |
| | | } |
| | | |
| | | /** |
| | | * 在「文本化表格」里找附件1(表头含「订单数总数」/「订单数」)并取 17 市州订单数与合计。 |
| | | * 入参 tables = 表 -> 行 -> 单元格文本(已压空白),与来源格式无关。 |
| | | */ |
| | | private void matchWycOrderTable(List<List<List<String>>> tables, Map<String, Double> orders, double[] sumHolder) { |
| | | for (List<List<String>> rows : tables) { |
| | | if (rows.size() < 3) continue; |
| | | int headerRow = -1; |
| | | int orderCol = -1; |
| | | for (int r = 0; r < Math.min(rows.size(), 3); r++) { |
| | | List<String> cells = rows.get(r); |
| | | for (int c = 0; c < cells.size(); c++) { |
| | | String norm = cells.get(c); |
| | | if (norm.contains("订单数总数") || "订单数".equals(norm)) { |
| | | headerRow = r; |
| | | orderCol = c; |
| | | break; |
| | | } |
| | | } |
| | | if (headerRow >= 0) break; |
| | | } |
| | | if (headerRow < 0 || orderCol < 0) continue; |
| | | |
| | | Map<String, Double> found = new LinkedHashMap<>(); |
| | | double citySum = 0.0; |
| | | boolean hasTotal = false; |
| | | for (int r = headerRow + 1; r < rows.size(); r++) { |
| | | List<String> cells = rows.get(r); |
| | | if (cells.isEmpty() || orderCol >= cells.size()) continue; |
| | | String name = cells.get(0); |
| | | if (name.isEmpty()) continue; |
| | | Double val = parsePlainNumber(cells.get(orderCol)); |
| | | if (val == null) continue; |
| | | String city = RegionUtil.normalizeCityName(name); |
| | | if (city != null && RegionUtil.cityList().contains(city)) { |
| | | found.put(city, val); |
| | | citySum += val; |
| | | } else if (name.contains("合计") || name.contains("总计") || name.contains("全省") || name.contains("湖北")) { |
| | | hasTotal = true; |
| | | sumHolder[0] = val; |
| | | } |
| | | } |
| | | if (!found.isEmpty()) { |
| | | orders.clear(); |
| | | orders.putAll(found); |
| | | if (!hasTotal) sumHolder[0] = citySum; |
| | | return; |
| | | } |
| | | } |
| | | } |
| | | |
| | | private String flat(String s) { |
| | | return s == null ? "" : s.replaceAll("\\s+", "").trim(); |
| | | } |
| | | |
| | | /** 「11,183,267 单」这类文本取数值 */ |
| | | private Double parsePlainNumber(String s) { |
| | | if (s == null) return null; |
| | | String t = s.replaceAll("[,\\s]", "").trim(); |
| | | if (t.isEmpty()) return null; |
| | | java.util.regex.Matcher m = java.util.regex.Pattern.compile("-?\\d+(?:\\.\\d+)?").matcher(t); |
| | | if (!m.find()) return null; |
| | | try { |
| | | return Double.parseDouble(m.group()); |
| | | } catch (NumberFormatException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private Double orderOf(Map<String, Double> orders, String city) { |
| | | Double v = orders.get(city); |
| | | return (v == null || v <= 0) ? null : v; |
| | | } |
| | | private String periodOfCell(Cell c) { |
| | | if (c == null) return null; |
| | | if (c.getCellType() == CellType.NUMERIC) { |
| | |
| | | case "scaleSplit": return new ImportResult(importScaleSplit(file, period), 0, new ArrayList<>()); |
| | | case "wycTotal": return new ImportResult(importWycTotal(file, period), 0, new ArrayList<>()); |
| | | case "wycOrder": return new ImportResult(importWycOrder(file, period), 0, new ArrayList<>()); |
| | | case "wycOrderReport": return new ImportResult(importWycOrderReport(file, period).getCityCount(), 0, new ArrayList<>()); |
| | | default: throw new RuntimeException("不支持的导入类型:" + type); |
| | | } |
| | | } |