zhizhijie
2026-09-18 b14fec1599277629e10b2bc686f02fd0c2e0bec1
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java
@@ -60,6 +60,10 @@
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;
@@ -85,6 +89,7 @@
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;
@@ -318,6 +323,9 @@
                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, "车辆数_客运班车(含定线旅游客运客车)"));
@@ -1054,11 +1062,22 @@
    // ========== 货运量周转量(模板_货运量周转量.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);
@@ -1123,27 +1142,149 @@
                }
                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 月 */
@@ -3182,10 +3323,36 @@
        Map<String, Double> orders = new LinkedHashMap<>();
        double[] sumHolder = new double[]{0.0};
        String docPeriod;
        try (java.io.InputStream in = file.getInputStream();
             XWPFDocument doc = new XWPFDocument(in)) {
            docPeriod = periodOfDocx(doc);
            parseWycOrderReportTables(doc, orders, sumHolder);
        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() + ")");
@@ -3251,13 +3418,12 @@
    }
    /** 报告标题中的报表期:网约车行业2026年7月 -> 2026-07;取不到返回 null */
    private String periodOfDocx(XWPFDocument doc) {
    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 (XWPFParagraph para : doc.getParagraphs()) {
            String text = para.getText();
        for (String text : paragraphs) {
            if (text == null) continue;
            String flat = text.replaceAll("\\s+", "");
            if (flat.isEmpty()) continue;
@@ -3272,6 +3438,92 @@
        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);
@@ -3284,15 +3536,47 @@
    /** 解析报告中的「地市名称 | … | 订单数总数」表,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<XWPFTableRow> rows = table.getRows();
            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<org.apache.poi.xwpf.usermodel.XWPFTableCell> cells = rows.get(r).getTableCells();
                List<String> cells = rows.get(r);
                for (int c = 0; c < cells.size(); c++) {
                    String norm = flat(cells.get(c).getText());
                    String norm = cells.get(c);
                    if (norm.contains("订单数总数") || "订单数".equals(norm)) {
                        headerRow = r;
                        orderCol = c;
@@ -3307,11 +3591,11 @@
            double citySum = 0.0;
            boolean hasTotal = false;
            for (int r = headerRow + 1; r < rows.size(); r++) {
                List<org.apache.poi.xwpf.usermodel.XWPFTableCell> cells = rows.get(r).getTableCells();
                if (orderCol >= cells.size()) continue;
                String name = flat(cells.get(0).getText());
                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).getText());
                Double val = parsePlainNumber(cells.get(orderCol));
                if (val == null) continue;
                String city = RegionUtil.normalizeCityName(name);
                if (city != null && RegionUtil.cityList().contains(city)) {