traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java
@@ -18,6 +18,8 @@
import com.trafficaudit.dataimport.entity.CityBusMonthly;
import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
import com.trafficaudit.dataimport.entity.CityTaxiAuth;
import com.trafficaudit.dataimport.entity.WycOrderMonthly;
import com.trafficaudit.dataimport.entity.WycTotalMonthly;
import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
import com.trafficaudit.dataimport.entity.TransportAuthVehicle;
import com.trafficaudit.dataimport.entity.VehicleTrackMileage;
@@ -36,6 +38,8 @@
import com.trafficaudit.dataimport.mapper.CityBusMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityTaxiAuthMapper;
import com.trafficaudit.dataimport.mapper.WycOrderMonthlyMapper;
import com.trafficaudit.dataimport.mapper.WycTotalMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import com.trafficaudit.dataimport.mapper.TransportAuthVehicleMapper;
import com.trafficaudit.dataimport.mapper.VehicleTrackMileageMapper;
@@ -67,6 +71,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.trafficaudit.dataimport.dto.ImportResult;
import org.apache.poi.ss.usermodel.DateUtil;
import java.util.Comparator;
@@ -94,6 +99,9 @@
    /** H204 燃料1计量单位码 -> 中文单位 */
    private static final Map<String, String> ENERGY_FUEL_UNIT_NAMES = new HashMap<>();
    /** 能耗导入按报表期互斥,防止同报表期并发重导互相覆盖 */
    private final ConcurrentHashMap<String, Object> energyImportLocks = new ConcurrentHashMap<>();
    static {
        ENERGY_VEHICLE_TYPE_NAMES.put("01", "普通货车");
@@ -159,6 +167,10 @@
    @Resource
    private CityTaxiAuthMapper cityTaxiAuthMapper;
    @Resource
    private WycTotalMonthlyMapper wycTotalMapper;
    @Resource
    private WycOrderMonthlyMapper wycOrderMapper;
    @Resource
    private SqlSessionFactory sqlSessionFactory;
    // ========== H2032 月报 ==========
@@ -168,6 +180,7 @@
        Map<String, Integer> colMap;
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderCols(sheet, "道路货物运输月度生产情况", "企业名称", "货运量_总计");
            colMap = buildHeaderMap(sheet.getRow(0));
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
@@ -284,6 +297,7 @@
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            // sheet0: 公路旅客6月报
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderCols(sheet, "公路旅客运输月度生产情况", "企业名称", "客运量_总计");
            Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
@@ -401,41 +415,20 @@
    /** 公路旅客个体客运量/周转量(企业线下汇报数据,按所属地区导入,供分市州表个体行填充) */
    /** 公路旅客个体客运量/周转量(导入后按市州汇总填充分市州表个体行),支持两种模板:
     *  ① 企业线下汇报明细(表头含「企业名称」):逐企业入库;
     *  ② 省厅市州汇总表《湖北省公路旅客运输个体户月度生产情况(YYYY.M)》:
     *     sheet 内为「全省N月个体客运量汇总表」标题块 + 「市州/个体车辆数/…/个体客运量/个体旅客周转量」表头,
     *     按市州逐行入库(同一市州多行如 县内班线+跨县班线 均入库,报表侧按市州自动汇总)。 */
    public ImportResult importPassengerIndividual(MultipartFile file, String period) throws Exception {
        List<PassengerIndividualMonthly> rows = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
            Integer codeCol = colMap.get("所属地区");
            Integer entCol = colMap.get("企业代码");
            Integer nameCol = colMap.get("企业名称");
            Integer passCol = null;
            Integer turnCol = null;
            for (Map.Entry<String, Integer> e : colMap.entrySet()) {
                String k = e.getKey();
                if (k != null && k.contains("个体") && k.contains("客运量")) passCol = e.getValue();
                if (k != null && k.contains("个体") && (k.contains("周转量") || k.contains("周转"))) turnCol = e.getValue();
            }
            if (passCol == null && turnCol == null) {
                throw new RuntimeException("未找到「个体客运量/个体旅客周转量」列,请检查模板(列名需包含“个体客运量”“个体周转量”)");
            }
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String name = nameCol != null ? getString(row, nameCol) : null;
                if (name == null || name.trim().isEmpty()) continue;
                Double pass = passCol != null ? getDecimalOrNull(row, passCol) : null;
                Double turn = turnCol != null ? getDecimalOrNull(row, turnCol) : null;
                if ((pass == null || pass == 0.0) && (turn == null || turn == 0.0)) continue;
                PassengerIndividualMonthly entity = new PassengerIndividualMonthly();
                entity.setReportPeriod(period);
                entity.setRegionCode(codeCol != null ? getString(row, codeCol) : null);
                entity.setEnterpriseCode(entCol != null ? getString(row, entCol) : null);
                entity.setEnterpriseName(name.trim());
                entity.setPassengerCount(pass);
                entity.setTurnover(turn);
                rows.add(entity);
            if (colMap.containsKey("企业名称")) {
                parsePassengerIndividualEnterprise(sheet, colMap, period, rows);
            } else {
                parsePassengerIndividualCitySheet(sheet, period, rows);
            }
        } catch (Exception e) {
            log.error("Passenger individual parse error", e);
@@ -470,12 +463,108 @@
        return new ImportResult(success, failDetails.size(), failDetails);
    }
    /** 旧版个体客运量模板(企业线下汇报明细:所属地区/企业代码/企业名称/…个体客运量/…个体旅客周转量) */
    private void parsePassengerIndividualEnterprise(Sheet sheet, Map<String, Integer> colMap, String period,
                                                    List<PassengerIndividualMonthly> rows) {
        Integer codeCol = colMap.get("所属地区");
        Integer entCol = colMap.get("企业代码");
        Integer nameCol = colMap.get("企业名称");
        Integer passCol = null;
        Integer turnCol = null;
        for (Map.Entry<String, Integer> e : colMap.entrySet()) {
            String k = e.getKey();
            if (k != null && k.contains("个体") && k.contains("客运量")) passCol = e.getValue();
            if (k != null && k.contains("个体") && (k.contains("周转量") || k.contains("周转"))) turnCol = e.getValue();
        }
        if (passCol == null && turnCol == null) {
            throw new RuntimeException("未找到「个体客运量/个体旅客周转量」列,请检查模板(列名需包含“个体客运量”“个体周转量”)");
        }
        for (int r = 1; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            String name = nameCol != null ? getString(row, nameCol) : null;
            if (name == null || name.trim().isEmpty()) continue;
            Double pass = passCol != null ? getDecimalOrNull(row, passCol) : null;
            Double turn = turnCol != null ? getDecimalOrNull(row, turnCol) : null;
            if ((pass == null || pass == 0.0) && (turn == null || turn == 0.0)) continue;
            PassengerIndividualMonthly entity = new PassengerIndividualMonthly();
            entity.setReportPeriod(period);
            entity.setRegionCode(codeCol != null ? getString(row, codeCol) : null);
            entity.setEnterpriseCode(entCol != null ? getString(row, entCol) : null);
            entity.setEnterpriseName(name.trim());
            entity.setPassengerCount(pass);
            entity.setTurnover(turn);
            rows.add(entity);
        }
    }
    /** 新版市州汇总模板:《全省N月个体客运量汇总表》标题行 → 表头行(市州/…/个体客运量/个体旅客周转量)→ 市州数据行 */
    private void parsePassengerIndividualCitySheet(Sheet sheet, String period, List<PassengerIndividualMonthly> rows) {
        int targetMonth;
        try {
            targetMonth = Integer.parseInt(period.split("-")[1]);
        } catch (Exception e) {
            throw new RuntimeException("报表期格式应为 yyyy-MM:" + period);
        }
        int titleRow = -1;
        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            String t = getString(row, 0);
            if (t == null || !t.contains("全省") || !t.contains("月")) continue;
            java.util.regex.Matcher m = java.util.regex.Pattern.compile("全省(\\d+)月").matcher(t);
            if (!m.find()) continue;
            if (Integer.parseInt(m.group(1)) == targetMonth) { titleRow = r; break; }
            if (titleRow < 0) titleRow = r; // 标题不含目标月份时暂记第一个标题块兜底
        }
        if (titleRow < 0) {
            throw new RuntimeException("未找到「全省N月个体客运量汇总表」标题块,请使用《模板_湖北省公路旅客运输个体户月度生产情况(YYYY.M)》整理后导入");
        }
        Row header = sheet.getRow(titleRow + 1);
        if (header == null) {
            throw new RuntimeException("「全省" + targetMonth + "月…汇总表」标题后缺少表头行(市州/个体客运量/个体旅客周转量)");
        }
        int cityCol = -1, passCol = -1, turnCol = -1;
        for (Cell c : header) {
            String h = FORMATTER.formatCellValue(c).trim();
            if (h.isEmpty()) continue;
            if (h.contains("市州")) cityCol = c.getColumnIndex();
            else if (h.contains("个体") && h.contains("客运量")) passCol = c.getColumnIndex();
            else if (h.contains("个体") && (h.contains("周转量") || h.contains("周转"))) turnCol = c.getColumnIndex();
        }
        if (cityCol < 0 || (passCol < 0 && turnCol < 0)) {
            throw new RuntimeException("未找到「市州 / 个体客运量 / 个体旅客周转量」列,请使用系统模板整理后导入");
        }
        int lastRow = Math.min(sheet.getLastRowNum(), titleRow + 40);
        for (int r = titleRow + 2; r <= lastRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            String first = getString(row, cityCol);
            if (first == null) break;
            String t = first.trim();
            if (t.isEmpty() || t.contains("合计") || t.contains("环比") || t.contains("全省")) break;
            String city = RegionUtil.normalizeCityName(t);
            if (!RegionUtil.cityList().contains(city)) continue;
            Double pass = passCol >= 0 ? getDecimalOrNull(row, passCol) : null;
            Double turn = turnCol >= 0 ? getDecimalOrNull(row, turnCol) : null;
            if ((pass == null || pass == 0.0) && (turn == null || turn == 0.0)) continue;
            PassengerIndividualMonthly entity = new PassengerIndividualMonthly();
            entity.setReportPeriod(period);
            entity.setRegionCode(RegionUtil.codeOfCity(city));
            entity.setEnterpriseName(city);
            entity.setPassengerCount(pass);
            entity.setTurnover(turn);
            rows.add(entity);
        }
    }
    /** 公路旅客运政车辆数(独立模板:企业代码/企业名称/车辆数/载客位数) */
    public ImportResult importPassengerAuth(MultipartFile file, String period) throws Exception {
        List<PassengerAuthVehicle> authList = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
            requireHeaderCols(sheet, "旅客运政车辆数", "企业名称", "车辆数", "载客位数");
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -524,12 +613,23 @@
    /** 能耗明细每组固定18列,车牌号起始列(0-based):3,21,39,57,75 */
    private static final int[] ENERGY_GROUP_COLS = {3, 21, 39, 57, 75};
    /** 能耗明细模板表头关键列(0-based):程序按固定列位解析,解析前校验表头,错位直接拒绝 */
    private static final int[] ENERGY_HEADER_COLS = {3, 4, 5, 21, 39, 57, 75};
    private static final String[] ENERGY_HEADER_KEYWORDS = {"车牌号", "车辆类型", "燃料类型", "车牌号", "车牌号", "车牌号", "车牌号"};
    /** 能耗导入按报表期互斥锁(H204 明细与运政信息共用,避免同报表期并发重导互相覆盖) */
    private Object energyLock(String period) {
        return energyImportLocks.computeIfAbsent(period == null ? "" : period, k -> new Object());
    }
    public ImportResult importEnergyMonthly(MultipartFile file, String period) throws Exception {
        List<EnergyRow> rows = new ArrayList<>();
        List<EnergyAuthVehicle> authList = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            // sheet0: 第二季度货车能耗明细(每行左右各5组车辆,每组固定18列)
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderKeyword(sheet, "道路货运车辆能源消耗情况", "企业名称", "车牌号");
            requireEnergyHeaderLayout(sheet);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -537,7 +637,7 @@
                if (enterpriseName == null || enterpriseName.trim().isEmpty()) continue;
                for (int group : ENERGY_GROUP_COLS) {
                    String plate = getString(row, group);
                    if (plate == null || plate.trim().isEmpty()) continue;
                    if (plate == null || plate.trim().isEmpty() || "0".equals(plate.trim())) continue;
                    EnergyVehicleQuarterly e = new EnergyVehicleQuarterly();
                    e.setReportPeriod(period);
                    e.setRegionCode(getString(row, 0));
@@ -576,7 +676,7 @@
                    Row row = authSheet.getRow(r);
                    if (row == null) continue;
                    String plate = getString(row, 0);
                    if (plate == null || plate.trim().isEmpty()) continue;
                    if (plate == null || plate.trim().isEmpty() || "0".equals(plate.trim())) continue;
                    EnergyAuthVehicle auth = new EnergyAuthVehicle();
                    auth.setReportPeriod(period);
                    auth.setPlateNo(plate.trim());
@@ -593,54 +693,57 @@
        }
        // 幂等:同报表期重新导入,先删除旧数据(两表 + H204 规则审核结果)
        energyMapper.delete(new LambdaQueryWrapper<EnergyVehicleQuarterly>()
            .eq(EnergyVehicleQuarterly::getReportPeriod, period));
        energyAuthMapper.delete(new LambdaQueryWrapper<EnergyAuthVehicle>()
            .eq(EnergyAuthVehicle::getReportPeriod, period));
        List<Long> h204RuleIds = new ArrayList<>();
        for (com.trafficaudit.rulemanage.entity.AuditRule rule : ruleMapper.selectList(null)) {
            if ("H204".equals(rule.getReportType())) h204RuleIds.add(rule.getId());
        }
        if (!h204RuleIds.isEmpty()) {
            auditResultMapper.delete(new LambdaQueryWrapper<AuditResult>()
                .eq(AuditResult::getReportPeriod, period)
                .in(AuditResult::getRuleId, h204RuleIds));
        }
        int success = 0;
        List<String> failDetails = new ArrayList<>();
        for (EnergyRow item : rows) {
            try {
                energyMapper.insert(item.entity);
                success++;
            } catch (Exception e) {
                Throwable cause = e;
                while (cause.getCause() != null) cause = cause.getCause();
                String reason = cause.getMessage();
                if (reason == null || reason.trim().isEmpty()) reason = e.getMessage();
                failDetails.add("第 " + item.excelRow + " 行(" + item.entity.getEnterpriseName()
                    + " " + item.entity.getPlateNo() + "):" + reason);
                log.error("Energy insert error: row {}, {}", item.excelRow, item.entity.getPlateNo(), e);
        // 加锁:同报表期并发重导互斥,防止 delete/insert 交错导致数据残留或翻倍
        synchronized (energyLock(period)) {
            energyMapper.delete(new LambdaQueryWrapper<EnergyVehicleQuarterly>()
                .eq(EnergyVehicleQuarterly::getReportPeriod, period));
            energyAuthMapper.delete(new LambdaQueryWrapper<EnergyAuthVehicle>()
                .eq(EnergyAuthVehicle::getReportPeriod, period));
            List<Long> h204RuleIds = new ArrayList<>();
            for (com.trafficaudit.rulemanage.entity.AuditRule rule : ruleMapper.selectList(null)) {
                if ("H204".equals(rule.getReportType())) h204RuleIds.add(rule.getId());
            }
        }
        String errorDetail = String.join("\n", failDetails);
        if (errorDetail.length() > 4000) {
            errorDetail = errorDetail.substring(0, 4000) + "\n……";
        }
        recordBatch(file.getOriginalFilename(), "H204", period, rows.size(), success, failDetails.size(), errorDetail);
        int authSuccess = 0;
        for (EnergyAuthVehicle auth : authList) {
            try {
                energyAuthMapper.insert(auth);
                authSuccess++;
            } catch (Exception e) {
                log.error("energy auth insert error: {}", auth.getPlateNo(), e);
            if (!h204RuleIds.isEmpty()) {
                auditResultMapper.delete(new LambdaQueryWrapper<AuditResult>()
                    .eq(AuditResult::getReportPeriod, period)
                    .in(AuditResult::getRuleId, h204RuleIds));
            }
            int success = 0;
            List<String> failDetails = new ArrayList<>();
            for (EnergyRow item : rows) {
                try {
                    energyMapper.insert(item.entity);
                    success++;
                } catch (Exception e) {
                    Throwable cause = e;
                    while (cause.getCause() != null) cause = cause.getCause();
                    String reason = cause.getMessage();
                    if (reason == null || reason.trim().isEmpty()) reason = e.getMessage();
                    failDetails.add("第 " + item.excelRow + " 行(" + item.entity.getEnterpriseName()
                        + " " + item.entity.getPlateNo() + "):" + reason);
                    log.error("Energy insert error: row {}, {}", item.excelRow, item.entity.getPlateNo(), e);
                }
            }
            String errorDetail = String.join("\n", failDetails);
            if (errorDetail.length() > 4000) {
                errorDetail = errorDetail.substring(0, 4000) + "\n……";
            }
            recordBatch(file.getOriginalFilename(), "H204", period, rows.size(), success, failDetails.size(), errorDetail);
            int authSuccess = 0;
            for (EnergyAuthVehicle auth : authList) {
                try {
                    energyAuthMapper.insert(auth);
                    authSuccess++;
                } catch (Exception e) {
                    log.error("energy auth insert error: {}", auth.getPlateNo(), e);
                }
            }
            recordBatch(file.getOriginalFilename(), "ENERGY_AUTH", period, authList.size(), authSuccess, 0, null);
            log.info("Energy monthly imported: {} vehicles, auth {} rows for {}", success, authSuccess, period);
            return new ImportResult(success, failDetails.size(), failDetails);
        }
        recordBatch(file.getOriginalFilename(), "ENERGY_AUTH", period, authList.size(), authSuccess, 0, null);
        log.info("Energy monthly imported: {} vehicles, auth {} rows for {}", success, authSuccess, period);
        return new ImportResult(success, failDetails.size(), failDetails);
    }
    /** 能耗车辆运政信息(独立模板:车牌号/车辆类型/燃料类型/标记吨位/准牵引质量) */
@@ -648,11 +751,18 @@
        List<EnergyAuthVehicle> authList = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderKeyword(sheet, "能耗车辆运政信息", "车牌号");
            Row authHeader = sheet.getRow(0);
            String authH0 = authHeader == null ? null : FORMATTER.formatCellValue(authHeader.getCell(0)).trim();
            if (authH0 == null || authH0.isEmpty() || !authH0.contains("车牌号")) {
                throw new RuntimeException("能耗车辆运政信息表头与系统模板不一致:第 1 列应为「车牌号」,实际为「"
                    + (authH0 == null || authH0.isEmpty() ? "空" : authH0) + "」。请使用系统模板《模板_能耗车辆运政信息.xlsx》整理后再导入");
            }
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String plate = getString(row, 0);
                if (plate == null || plate.trim().isEmpty()) continue;
                if (plate == null || plate.trim().isEmpty() || "0".equals(plate.trim())) continue;
                EnergyAuthVehicle auth = new EnergyAuthVehicle();
                auth.setReportPeriod(period);
                auth.setPlateNo(plate.trim());
@@ -666,30 +776,32 @@
            log.error("Energy auth parse error", e);
            throw e;
        }
        // 幂等:同报表期重新导入,先删除旧数据
        energyAuthMapper.delete(new LambdaQueryWrapper<EnergyAuthVehicle>()
            .eq(EnergyAuthVehicle::getReportPeriod, period));
        deleteRuleResults("H204", period);
        int success = 0;
        List<String> failDetails = new ArrayList<>();
        for (EnergyAuthVehicle auth : authList) {
            try {
                energyAuthMapper.insert(auth);
                success++;
            } catch (Exception e) {
                Throwable cause = e;
                while (cause.getCause() != null) cause = cause.getCause();
                String reason = cause.getMessage();
                if (reason == null || reason.trim().isEmpty()) reason = e.getMessage();
                failDetails.add("第 " + (authList.indexOf(auth) + 2) + " 行(" + auth.getPlateNo() + "):" + reason);
                log.error("Energy auth insert error: {}", auth.getPlateNo(), e);
        // 幂等:同报表期重新导入,先删除旧数据;与 H204 明细共用报表期锁,防并发互相覆盖
        synchronized (energyLock(period)) {
            energyAuthMapper.delete(new LambdaQueryWrapper<EnergyAuthVehicle>()
                .eq(EnergyAuthVehicle::getReportPeriod, period));
            deleteRuleResults("H204", period);
            int success = 0;
            List<String> failDetails = new ArrayList<>();
            for (EnergyAuthVehicle auth : authList) {
                try {
                    energyAuthMapper.insert(auth);
                    success++;
                } catch (Exception e) {
                    Throwable cause = e;
                    while (cause.getCause() != null) cause = cause.getCause();
                    String reason = cause.getMessage();
                    if (reason == null || reason.trim().isEmpty()) reason = e.getMessage();
                    failDetails.add("第 " + (authList.indexOf(auth) + 2) + " 行(" + auth.getPlateNo() + "):" + reason);
                    log.error("Energy auth insert error: {}", auth.getPlateNo(), e);
                }
            }
            String errorDetail = String.join("\n", failDetails);
            if (errorDetail.length() > 4000) errorDetail = errorDetail.substring(0, 4000) + "\n……";
            recordBatch(file.getOriginalFilename(), "ENERGY_AUTH", period, authList.size(), success, failDetails.size(), errorDetail);
            log.info("Energy auth imported: {} rows for {}", success, period);
            return new ImportResult(success, failDetails.size(), failDetails);
        }
        String errorDetail = String.join("\n", failDetails);
        if (errorDetail.length() > 4000) errorDetail = errorDetail.substring(0, 4000) + "\n……";
        recordBatch(file.getOriginalFilename(), "ENERGY_AUTH", period, authList.size(), success, failDetails.size(), errorDetail);
        log.info("Energy auth imported: {} rows for {}", success, period);
        return new ImportResult(success, failDetails.size(), failDetails);
    }
    /** H204 行记录:保留 Excel 行号用于失败定位 */
@@ -711,24 +823,27 @@
    // ========== 运政车辆 ==========
    /** 运政车辆导入:与 H203-2 月报一致,按表头名称定位列,列顺序变化也能正确导入 */
    public int importTransportAuth(MultipartFile file, String period) throws Exception {
        List<TransportAuthVehicle> list = new ArrayList<>();
        int failRows = 0;
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderCols(sheet, "运政车辆", "企业名称", "总牵引车数", "总挂车数", "其它车辆数", "挂车总吨位(吨)", "其它车辆总吨位(吨)");
            Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String name = getString(row, 0);
                String name = gs(row, colMap, "企业名称");
                if (name == null || name.trim().isEmpty()) continue;
                TransportAuthVehicle entity = new TransportAuthVehicle();
                entity.setReportPeriod(period);
                entity.setEnterpriseName(name);
                entity.setTractorCount(getInt(row, 1));
                entity.setTrailerCount(getInt(row, 2));
                entity.setOtherCount(getInt(row, 3));
                entity.setTrailerTons(getDecimal(row, 4));
                entity.setOtherTons(getDecimal(row, 5));
                entity.setTractorCount(gi(row, colMap, "总牵引车数"));
                entity.setTrailerCount(gi(row, colMap, "总挂车数"));
                entity.setOtherCount(gi(row, colMap, "其它车辆数"));
                entity.setTrailerTons(gd(row, colMap, "挂车总吨位(吨)"));
                entity.setOtherTons(gd(row, colMap, "其它车辆总吨位(吨)"));
                list.add(entity);
            }
        } catch (Exception e) {
@@ -758,20 +873,23 @@
    // ========== 轨迹里程 ==========
    /** 轨迹里程导入:与 H203-2 月报一致,按表头名称定位列,列顺序变化也能正确导入 */
    public int importTrackMileage(MultipartFile file, String period) throws Exception {
        List<VehicleTrackMileage> list = new ArrayList<>();
        int failRows = 0;
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderCols(sheet, "轨迹里程", "企业名称", "月度总里程(公里)", "有里程的车辆数");
            Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String name = getString(row, 1);
                String name = gs(row, colMap, "企业名称");
                if (name == null || name.trim().isEmpty()) continue;
                VehicleTrackMileage entity = new VehicleTrackMileage();
                entity.setEnterpriseName(name);
                entity.setMonthlyMileage(getDecimal(row, 2));
                entity.setTrackedVehicles(getInt(row, 3));
                entity.setMonthlyMileage(gd(row, colMap, "月度总里程(公里)"));
                entity.setTrackedVehicles(gi(row, colMap, "有里程的车辆数"));
                entity.setReportPeriod(period);
                list.add(entity);
            }
@@ -806,13 +924,44 @@
        List<ScaleSplitTransport> list = new ArrayList<>();
        int failRows = 0;
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            int targetMonth = Integer.parseInt(period.split("-")[1]); // 仅导入目标期所在月的页签(1-N月文件按目标期取当月/累计)
            boolean hasPeriodSheet = false;
            for (int i = 0; i < wb.getNumberOfSheets(); i++) {
                String sn = wb.getSheetAt(i).getSheetName();
                if (sn != null && (sn.contains("当月") || sn.contains("月份") || sn.contains("累计"))) { hasPeriodSheet = true; break; }
            }
            if (!hasPeriodSheet) {
                throw new RuntimeException("所选数据类型【规上规下拆分】与文件内容不符:未找到含 当月/累计 的 sheet,请确认是否选错了数据类型");
            }
            for (int i = 0; i < wb.getNumberOfSheets(); i++) {
                Sheet sheet = wb.getSheetAt(i);
                String sheetName = sheet.getSheetName();
                String periodType = null;
                if (sheetName.contains("当月")) periodType = "MONTH";
                else if (sheetName.contains("累计")) periodType = "CUMULATIVE";
                java.util.regex.Matcher mSheetMonth = java.util.regex.Pattern.compile("(\\d{1,2})\\u6708(\\u4efd|\\u7d2f\\u8ba1)").matcher(sheetName);
                if (mSheetMonth.find()) {
                    int sheetMonth = Integer.parseInt(mSheetMonth.group(1));
                    if (sheetMonth == targetMonth) {
                        periodType = "份".equals(mSheetMonth.group(2)) ? "MONTH" : "CUMULATIVE";
                    }
                } else {
                    if (sheetName.contains("当月")) periodType = "MONTH";
                    else if (sheetName.contains("累计")) periodType = "CUMULATIVE";
                }
                if (periodType == null) continue;
                // 校验 sheet 布局:第2行应含「市州/规上企业运输量」,第3行子表头应含「周转量」或「货运量」(扩展布局)
                Row headRow = sheet.getRow(1);
                Row subHeadRow = sheet.getRow(2);
                boolean layoutOk = headRow != null
                    && getString(headRow, 0) != null && getString(headRow, 0).contains("市州")
                    && getString(headRow, 1) != null && getString(headRow, 1).contains("规上")
                    && subHeadRow != null
                    && ("货运量".equals(getString(subHeadRow, 0))
                        || (getString(subHeadRow, 1) != null && getString(subHeadRow, 1).contains("周转量")));
                if (!layoutOk) {
                    throw new RuntimeException("所选数据类型【规上规下拆分】与文件内容不符:sheet「" + sheetName
                        + "」未找到「市州/规上企业运输量/周转量」表头,请确认是否选错了数据类型");
                }
                // 检测子表头:是否包含货运量列(扩展布局)
                Row subHeader = sheet.getRow(2);
@@ -896,6 +1045,24 @@
        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);
            String h1 = header == null ? null : getString(header, 1);
            if (header == null || h0 == null || !h0.contains("货运量") || h1 == null || !h1.contains("月")) {
                throw new RuntimeException("所选数据类型【货运量周转量】与文件内容不符:表头应为「货运量(万吨)/1月…12月」布局,请确认是否选错了数据类型");
            }
            boolean cityRowFound = false;
            for (int r = 1; r <= Math.min(sheet.getLastRowNum(), 30); r++) {
                Row rr = sheet.getRow(r);
                if (rr == null) continue;
                String nm = getString(rr, 0);
                if (nm == null || nm.trim().isEmpty()) continue;
                String region = RegionUtil.normalizeCityName(nm);
                if (RegionUtil.cityList().contains(region) || "湖北省".equals(region)) { cityRowFound = true; break; }
            }
            if (!cityRowFound) {
                throw new RuntimeException("所选数据类型【货运量周转量】与文件内容不符:未识别到市州行(首列应为市州名称),请确认是否选错了数据类型");
            }
            Map<String, FreightTurnoverImport> freightMap = new HashMap<>();
            Map<String, FreightTurnoverImport> turnoverMap = new HashMap<>();
            Set<String> seen = new HashSet<>();
@@ -1152,6 +1319,70 @@
        }
    }
    /** 类型-表头校验:按必需列名(解析器实际依赖的列),缺失即抛"类型与文件不符" */
    private void requireHeaderCols(Sheet sheet, String typeLabel, String... cols) {
        Map<String, Integer> colMap = buildHeaderMap(sheet.getRow(0));
        List<String> missing = new ArrayList<>();
        for (String c : cols) {
            if (!colMap.containsKey(c)) missing.add(c);
        }
        if (!missing.isEmpty()) {
            throw new RuntimeException("所选数据类型【" + typeLabel + "】与文件内容不符:表头缺少必需列【" + String.join("】【", missing) + "】,请确认是否选错了数据类型");
        }
    }
    /** 类型-表头校验:扫描表头行,命中任一关键字即通过 */
    private void requireHeaderKeyword(Sheet sheet, String typeLabel, String... keywords) {
        Row header = sheet == null ? null : sheet.getRow(0);
        if (header != null) {
            for (Cell cell : header) {
                String v = FORMATTER.formatCellValue(cell);
                if (v == null || v.isEmpty()) continue;
                for (String k : keywords) {
                    if (v.contains(k)) return;
                }
            }
        }
        throw new RuntimeException("所选数据类型【" + typeLabel + "】与文件内容不符:表头未包含【" + String.join("】【", keywords) + "】等列,请确认是否选错了数据类型");
    }
    /** 能耗明细表头列位校验:系统模板为“前3列 + 5组×18列”,程序按固定列位解析,表头错位会导致整表错乱,错位时直接拒绝 */
    private void requireEnergyHeaderLayout(Sheet sheet) {
        Row header = sheet == null ? null : sheet.getRow(0);
        if (header == null) {
            throw new RuntimeException("能耗明细表头缺失,请使用系统模板《模板_道路货运车辆能源消耗情况(2026).xlsx》整理后再导入");
        }
        for (int i = 0; i < ENERGY_HEADER_COLS.length; i++) {
            int col = ENERGY_HEADER_COLS[i];
            String keyword = ENERGY_HEADER_KEYWORDS[i];
            Cell cell = header.getCell(col);
            String hv = cell == null ? null : FORMATTER.formatCellValue(cell).trim();
            if (hv == null || hv.isEmpty() || !hv.contains(keyword)) {
                throw new RuntimeException("表头与系统模板不一致:第 " + (col + 1) + " 列应为「" + keyword + "」,实际为「"
                    + (hv == null || hv.isEmpty() ? "空" : hv) + "」。请使用系统模板《模板_道路货运车辆能源消耗情况(2026).xlsx》整理后再导入");
            }
        }
    }
    /** 通用表头列位校验:程序按固定列位解析,表头错位会导致整表错乱,错位时直接拒绝导入 */
    private void requireHeaderLayout(Sheet sheet, String typeLabel, String templateHint,
                                     int[] cols, String[] keywords) {
        Row header = sheet == null ? null : sheet.getRow(0);
        if (header == null) {
            throw new RuntimeException("所选数据类型【" + typeLabel + "】文件表头缺失,请使用系统模板《" + templateHint + "》整理后再导入");
        }
        for (int i = 0; i < cols.length; i++) {
            int col = cols[i];
            String keyword = keywords[i];
            Cell cell = header.getCell(col);
            String hv = cell == null ? null : FORMATTER.formatCellValue(cell).trim();
            if (hv == null || hv.isEmpty() || !hv.contains(keyword)) {
                throw new RuntimeException("表头与系统模板不一致:第 " + (col + 1) + " 列应为「" + keyword + "」,实际为「"
                    + (hv == null || hv.isEmpty() ? "空" : hv) + "」。所选数据类型【" + typeLabel + "】请使用系统模板《" + templateHint + "》整理后再导入");
            }
        }
    }
    /** 按表头名称建立列号映射,兼容新旧模板列位差异 */
    private Map<String, Integer> buildHeaderMap(Row header) {
        Map<String, Integer> colMap = new HashMap<>();
@@ -1398,7 +1629,7 @@
                }
            }
            if (headerRow < 0) {
                throw new RuntimeException("未找到表头行(需包含 序号/项目名称 列)");
                throw new RuntimeException("所选数据类型【" + (passenger ? "投资月报(客运站场)" : "投资月报(物流园区)") + "】与文件内容不符:未找到表头行(需包含 序号/项目名称 列),请确认是否选错了数据类型");
            }
            for (int r = headerRow + 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
@@ -1553,11 +1784,19 @@
            }
            fileResults.add(fr);
        }
        // 幂等清理:同类别同报表期月度数据 + INVEST 审核结果(项目主档跨月保留,按名 upsert)
        cleanInvestMonthly(category, period);
        // 幂等清理:按本次文件涉及市州清理月度数据 + INVEST 审核结果(项目主档跨月保留,按名 upsert)
        cleanInvestMonthlyByCity(category, period, rows);
        Map<String, Set<String>> knownCities = loadInvestKnownCities(category);
        Map<String, String> fileFirstMismatch = new LinkedHashMap<>();
        int success = 0;
        List<String> failDetails = new ArrayList<>();
        for (InvestCityRow item : rows) {
            String mismatch = investCityMismatchReason(item, knownCities);
            if (mismatch != null) {
                failDetails.add(mismatch);
                fileFirstMismatch.putIfAbsent(item.fileName, mismatch);
                continue;
            }
            try {
                saveInvestProjectRow(item.project, item.monthly);
                success++;
@@ -1569,6 +1808,11 @@
                failDetails.add(item.fileName + " 第 " + item.excelRow + " 行(" + item.project.getProjectName() + "):" + reason);
                log.error("Invest city insert error: file {}, row {}", item.fileName, item.excelRow, e);
            }
        }
        // 把首个市州错位提示挂到对应文件结果上,便于页面弹窗直接看到
        for (Map<String, Object> fr2 : fileResults) {
            String msg = fileFirstMismatch.get(fr2.get("fileName"));
            if (msg != null && fr2.get("error") == null) fr2.put("error", msg);
        }
        String errorDetail = String.join("\n", failDetails);
        if (errorDetail.length() > 4000) errorDetail = errorDetail.substring(0, 4000) + "\n……";
@@ -1591,6 +1835,119 @@
        return result;
    }
    /**
     * 多文件批量导入:前端一次选择多个 Excel 上传。
     * 投资类型(investment/investmentLogistics)按市州单表解析(文件名/填报单位识别市州,十五五自动标记);
     * 其他类型逐个走现有解析逻辑(importDirFile)。
     */
    public Map<String, Object> importBatchFiles(String type, String period, List<MultipartFile> files) throws Exception {
        if (files == null || files.isEmpty()) {
            throw new RuntimeException("未接收到任何文件");
        }
        if ("investment".equals(type) || "investmentLogistics".equals(type)) {
            String category = "investment".equals(type) ? "客运站场" : "物流园区";
            return importInvestCityFiles(period, category, files);
        }
        List<Map<String, Object>> fileResults = new ArrayList<>();
        int totalSuccess = 0, totalFail = 0;
        for (MultipartFile f : files) {
            Map<String, Object> fr = new LinkedHashMap<>();
            fr.put("fileName", f.getOriginalFilename());
            try {
                ImportResult r = importDirFile(type, f, period);
                fr.put("success", r.getSuccess());
                fr.put("fail", r.getFail());
                if (r.getFail() > 0 && r.getFailDetails() != null && !r.getFailDetails().isEmpty()) {
                    fr.put("error", r.getFailDetails().get(0));
                }
                totalSuccess += r.getSuccess();
                totalFail += r.getFail();
            } catch (Exception e) {
                fr.put("success", 0);
                fr.put("fail", 0);
                fr.put("error", e.getMessage() == null ? e.toString() : e.getMessage());
                totalFail++;
            }
            fileResults.add(fr);
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("type", type);
        result.put("total", files.size());
        result.put("success", totalSuccess);
        result.put("fail", totalFail);
        result.put("files", fileResults);
        return result;
    }
    /** 投资多文件批量:逐文件按市州单表解析,统一幂等清理落库,批次汇总 */
    public Map<String, Object> importInvestCityFiles(String period, String category, List<MultipartFile> files) throws Exception {
        boolean passenger = "客运站场".equals(category);
        Set<String> fiveYearNames = loadFiveYearProjectNames();
        List<InvestCityRow> rows = new ArrayList<>();
        List<Map<String, Object>> fileResults = new ArrayList<>();
        for (MultipartFile f : files) {
            Map<String, Object> fr = new LinkedHashMap<>();
            fr.put("fileName", f.getOriginalFilename());
            try (Workbook wb = WorkbookFactory.create(f.getInputStream())) {
                parseInvestCityWorkbook(wb, f.getOriginalFilename(), category, period, fiveYearNames, rows, fr);
            } catch (Exception e) {
                fr.put("success", 0);
                fr.put("error", e.getMessage() == null ? e.toString() : e.getMessage());
                log.error("invest city file parse error: {}", f.getOriginalFilename(), e);
            }
            fileResults.add(fr);
        }
        String catDir = passenger ? "客运(客运站)" : "物流(物流园区)";
        cleanInvestMonthlyByCity(category, period, rows);
        Map<String, Set<String>> knownCities = loadInvestKnownCities(category);
        Map<String, String> fileFirstMismatch = new LinkedHashMap<>();
        int success = 0;
        List<String> failDetails = new ArrayList<>();
        for (InvestCityRow item : rows) {
            String mismatch = investCityMismatchReason(item, knownCities);
            if (mismatch != null) {
                failDetails.add(mismatch);
                fileFirstMismatch.putIfAbsent(item.fileName, mismatch);
                continue;
            }
            try {
                saveInvestProjectRow(item.project, item.monthly);
                success++;
            } catch (Exception e) {
                Throwable cause = e;
                while (cause.getCause() != null) cause = cause.getCause();
                String reason = cause.getMessage();
                if (reason == null || reason.trim().isEmpty()) reason = e.getMessage();
                failDetails.add(item.fileName + " 第 " + item.excelRow + " 行(" + item.project.getProjectName() + "):" + reason);
                log.error("Invest city insert error: file {}, row {}", item.fileName, item.excelRow, e);
            }
        }
        // 把首个市州错位提示挂到对应文件结果上,便于页面弹窗直接看到
        for (Map<String, Object> fr2 : fileResults) {
            String msg = fileFirstMismatch.get(fr2.get("fileName"));
            if (msg != null && fr2.get("error") == null) fr2.put("error", msg);
        }
        String errorDetail = String.join("\n", failDetails);
        if (errorDetail.length() > 4000) errorDetail = errorDetail.substring(0, 4000) + "\n……";
        recordBatch("多文件批量导入(" + catDir + "/" + period + ")", "INVEST_" + category + "_CITY",
            period, rows.size(), success, failDetails.size(), errorDetail);
        int fiveYearMatched = 0;
        for (InvestCityRow item : rows) {
            if (Integer.valueOf(1).equals(item.project.getIsFiveYear())) fiveYearMatched++;
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("total", rows.size());
        result.put("success", success);
        result.put("fail", failDetails.size());
        result.put("failDetails", failDetails);
        result.put("files", fileResults);
        result.put("fiveYearTemplateProjects", fiveYearNames.size());
        result.put("fiveYearMatched", fiveYearMatched);
        log.info("Invest city multi-file {} {} imported: {} rows (fiveYear matched {}/{})",
            category, period, success, fiveYearMatched, fiveYearNames.size());
        return result;
    }
    /** 多 sheet 历史文件(襄阳 .et 等):sheet 名含报表期优先,其次选数据行最多 */
    private boolean investSheetMatchesPeriod(Sheet sh, String period) {
        if (period == null || !period.matches("\\d{4}-\\d{2}")) return false;
@@ -1598,8 +1955,10 @@
        if (name == null || name.isEmpty()) return false;
        String norm = name.replace(" ", "");
        int month = Integer.parseInt(period.substring(5, 7));
        String normDash = norm.replace('.', '-'); // "2026.8"/"2026.08" -> "2026-8"/"2026-08"
        return norm.contains(month + "月") || norm.contains(String.format("%02d", month) + "月")
            || name.contains(period) || name.contains(period.replace("-0", "-"));
            || name.contains(period) || name.contains(period.replace("-0", "-"))
            || normDash.contains(period) || normDash.contains(period.replace("-0", "-"));
    }
    /** 解析单个市州报表:选 sheet(报表期名优先 + 数据行数),按列名映射提取项目行 */
@@ -1614,6 +1973,11 @@
            if (col == null) continue;
            int cnt = countInvestRows(sh, col);
            int score = cnt + (cnt > 0 && investSheetMatchesPeriod(sh, period) ? 1000000 : 0);
            if (cnt > 0 && investSheetMatchesPeriod(sh, period)) {
                // 当期真实报表 sheet 名通常带年份(如 2026.8/2026年8月),历史sheet多为纯月份名(8月):带年份的优先
                String norm = sh.getSheetName() == null ? "" : sh.getSheetName().replace(" ", "");
                if (norm.contains(period.substring(0, 4))) score += 100000;
            }
            if (score > bestScore) {
                best = sh;
                bestCol = col;
@@ -1627,6 +1991,7 @@
        }
        String city = cityFromFileName(fileName);
        if (city == null) city = cityFromSheetHeader(best);
        fr.put("city", city == null ? "" : city);
        String currentCity = city;
        Map<String, Integer> col = bestCol;
        int nameCol = col.get("name");
@@ -1637,6 +2002,15 @@
            Row row = best.getRow(r);
            if (row == null) continue;
            String name = cellStr(row, nameCol);
            String seq = hasSeq ? cellStr(row, seqCol) : null;
            // 市州小节行:部分汇总表把市州名写在序号列(如十堰汇总表 A 列写"武汉"),需同样识别
            if (seq != null && !seq.trim().isEmpty()) {
                String seqCity = investmentCity(seq.trim());
                if (seqCity != null) {
                    currentCity = seqCity;
                    continue;
                }
            }
            if (name == null || name.trim().isEmpty()) continue;
            String nt = name.trim().replaceAll("[\r\n]+", "");
            if (nt.contains("合计") || nt.contains("总计")) continue;
@@ -1645,14 +2019,15 @@
            if (nt.replace(" ", "").matches(".*小\\s*计.*")) continue;
            if (nt.startsWith("填报") || nt.startsWith("单位负责人") || nt.matches("^\\d+、.*")) continue;
            if (hasSeq) {
                String seq = cellStr(row, seqCol);
                if (seq == null || seq.trim().isEmpty()) {
                    // 市州小节行(如 "武汉" "天 门"),更新当前市州
                    String c = investmentCity(nt);
                    if (c != null) currentCity = c;
                    if (c != null) { currentCity = c; continue; }
                    // 序号为空的真实项目行(部分市州月报不填序号,如神农架物流园)也按项目处理
                    if (!isLikelyInvestProjectName(nt)) continue;
                } else if (!seq.trim().matches("\\d+(\\.\\d+)?")) {
                    continue;
                }
                if (!seq.trim().matches("\\d+(\\.\\d+)?")) continue;
            } else {
                // 无序号列(随州等):项目名非空即项目行,市州小节行用 investmentCity 识别
                String c = investmentCity(nt);
@@ -1777,7 +2152,11 @@
                if (nt.length() > 4) count++;
            } else {
                String seq = cellStr(row, seqCol);
                if (seq != null && seq.trim().matches("\\d+(\\.\\d+)?")) count++;
                if (seq != null && seq.trim().matches("\\d+(\\.\\d+)?")) {
                    count++;
                } else if ((seq == null || seq.trim().isEmpty()) && isLikelyInvestProjectName(nt)) {
                    count++;
                }
            }
        }
        return count;
@@ -1849,6 +2228,15 @@
        return names;
    }
    /** 序号为空的非市州行是否像项目行:长度>4 且非备注/说明等杂项 */
    private boolean isLikelyInvestProjectName(String nt) {
        if (nt == null || nt.length() <= 4) return false;
        if (investmentCity(nt) != null) return false;
        return !nt.startsWith("备注") && !nt.startsWith("说明") && !nt.startsWith("注:")
            && !nt.startsWith("填表") && !nt.startsWith("审核") && !nt.startsWith("统计")
            && !nt.startsWith("负责");
    }
    /** 项目名规范化:去空白/换行/括号内容/标点/「项目」后缀,用于十五五清单匹配 */
    private String normalizeProjectName(String name) {
        String s = name.replaceAll("\\s+", "").replace("\n", "");
@@ -1884,7 +2272,7 @@
        return null;
    }
    /** 同类别同报表期月度数据 + INVEST 审核结果清理(幂等) */
    /** 同类别同报表期月度数据 + INVEST 审核结果清理(幂等,全省汇总大表全量清理) */
    private void cleanInvestMonthly(String category, String period) {
        List<InvestmentProject> sameCat = investProjectMapper.selectList(
            new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, category));
@@ -1895,15 +2283,60 @@
                .eq(InvestmentMonthly::getReportPeriod, period)
                .in(InvestmentMonthly::getProjectId, catProjectIds));
        }
        deleteInvestAuditResults(period, null);
    }
    /** 按市州清理:只删除本次文件涉及市州(含未识别市州的 null 项目)的月度数据 + INVEST 审核结果 */
    private void cleanInvestMonthlyByCity(String category, String period, List<InvestCityRow> rows) {
        Set<String> citySet = new HashSet<>();
        boolean hasNullCity = false;
        for (InvestCityRow item : rows) {
            String c = item.project.getCity();
            if (c == null || c.trim().isEmpty()) hasNullCity = true;
            else citySet.add(c.trim());
        }
        if (citySet.isEmpty() && !hasNullCity) return;
        boolean cleanNullCity = hasNullCity;
        LambdaQueryWrapper<InvestmentProject> pw = new LambdaQueryWrapper<InvestmentProject>()
            .eq(InvestmentProject::getCategory, category);
        pw.and(w -> {
            if (!citySet.isEmpty()) w.in(InvestmentProject::getCity, citySet);
            if (cleanNullCity) {
                if (!citySet.isEmpty()) w.or();
                w.isNull(InvestmentProject::getCity);
            }
        });
        List<InvestmentProject> cityProjects = investProjectMapper.selectList(pw);
        java.util.Set<Long> catProjectIds = new HashSet<>();
        for (InvestmentProject pc : cityProjects) catProjectIds.add(pc.getId());
        if (catProjectIds.isEmpty()) return;
        List<InvestmentMonthly> oldMonthlies = investMonthlyMapper.selectList(
            new LambdaQueryWrapper<InvestmentMonthly>()
                .eq(InvestmentMonthly::getReportPeriod, period)
                .in(InvestmentMonthly::getProjectId, catProjectIds));
        java.util.Set<Long> oldMonthlyIds = new HashSet<>();
        for (InvestmentMonthly m : oldMonthlies) oldMonthlyIds.add(m.getId());
        investMonthlyMapper.delete(new LambdaQueryWrapper<InvestmentMonthly>()
            .eq(InvestmentMonthly::getReportPeriod, period)
            .in(InvestmentMonthly::getProjectId, catProjectIds));
        deleteInvestAuditResults(period, oldMonthlyIds);
    }
    /** 清理 INVEST 审核结果(monthlyIds 为 null 表示该期间全量清理) */
    private void deleteInvestAuditResults(String period, java.util.Set<Long> monthlyIds) {
        List<Long> investRuleIds = new ArrayList<>();
        for (com.trafficaudit.rulemanage.entity.AuditRule rule : ruleMapper.selectList(null)) {
            if ("INVEST".equals(rule.getReportType())) investRuleIds.add(rule.getId());
        }
        if (!investRuleIds.isEmpty()) {
            auditResultMapper.delete(new LambdaQueryWrapper<AuditResult>()
                .eq(AuditResult::getReportPeriod, period)
                .in(AuditResult::getRuleId, investRuleIds));
        if (investRuleIds.isEmpty()) return;
        LambdaQueryWrapper<AuditResult> w = new LambdaQueryWrapper<AuditResult>()
            .eq(AuditResult::getReportPeriod, period)
            .in(AuditResult::getRuleId, investRuleIds);
        if (monthlyIds != null) {
            if (monthlyIds.isEmpty()) return;
            w.in(AuditResult::getReportId, monthlyIds);
        }
        auditResultMapper.delete(w);
    }
    /** 项目主档 upsert(按 项目名+类别)+ 月度写入 */
@@ -1922,6 +2355,32 @@
            monthly.setProjectId(exist.getId());
        }
        investMonthlyMapper.insert(monthly);
    }
    /** 载入某类别「规范化项目名 -> 已入库市州集合」,用于市州一致性校验(名称在多个市州重复时放弃校验) */
    private Map<String, Set<String>> loadInvestKnownCities(String category) {
        Map<String, Set<String>> map = new HashMap<>();
        List<InvestmentProject> list = investProjectMapper.selectList(
            new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, category));
        for (InvestmentProject p : list) {
            if (p.getCity() == null || p.getCity().trim().isEmpty()) continue;
            map.computeIfAbsent(normalizeProjectName(p.getProjectName()), k -> new HashSet<>())
               .add(p.getCity().trim());
        }
        return map;
    }
    /** 市州一致性校验:库中唯一归属市州与本次解析不一致时返回错误说明,否则返回 null */
    private String investCityMismatchReason(InvestCityRow item, Map<String, Set<String>> knownCities) {
        String parsed = item.project.getCity();
        if (parsed == null || parsed.trim().isEmpty()) return null;
        Set<String> known = knownCities.get(normalizeProjectName(item.project.getProjectName()));
        if (known == null || known.size() != 1) return null;
        String knownCity = known.iterator().next();
        if (knownCity.equals(parsed.trim())) return null;
        return item.fileName + " 第 " + item.excelRow + " 行(" + item.project.getProjectName()
            + "):市州一致性校验未通过——库中该项目归属「" + knownCity + "」,本次文件解析为「" + parsed.trim()
            + "」,疑似市州错位,已跳过导入,请核对源文件";
    }
    private String cellStr(Row row, int idx) {
@@ -1954,11 +2413,18 @@
        }
    }
    /** 投资系统导出导入(审核比对基准),固定8列:行号/单位/时期/计划总投资/自开始累计/本年计划/自年初累计/当月完成 */
    private static final int[] INVEST_SYSTEM_HEADER_COLS = {0, 1, 2, 3, 4, 5, 6, 7};
    private static final String[] INVEST_SYSTEM_HEADER_KEYWORDS =
        {"行号", "单位", "时期", "计划总投资", "自开始建设", "本年计划投资", "自年初", "当月完成投资"};
    public int importInvestmentSystem(MultipartFile file, String period) throws Exception {
        List<InvestmentSystem> list = new ArrayList<>();
        int failRows = 0;
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderKeyword(sheet, "投资系统导出", "项目名称", "计划总投资", "单位");
            requireHeaderLayout(sheet, "投资系统导出", "模板_查询结果(投资系统-客运+物流 2026.7).xls",
                INVEST_SYSTEM_HEADER_COLS, INVEST_SYSTEM_HEADER_KEYWORDS);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -1996,15 +2462,18 @@
    }
    /** 城市公交月报导入(城市公共交通月度运营情况,企业级明细,取公共汽电车字段) */
    /** 城市公交(U203)模板关键列位(0-based):所属地区/企业代码/企业名称/运营车数/客运量/轨道客运量/轮渡客运量/平均运距/填表人/联系电话/报出日期 */
    private static final int[] CITY_BUS_HEADER_COLS = {0, 1, 2, 5, 8, 13, 17, 19, 24, 25, 26};
    private static final String[] CITY_BUS_HEADER_KEYWORDS =
        {"所属地区", "企业代码", "企业名称", "公共汽电车_运营车数", "公共汽电车_客运量", "轨道交通_客运量",
         "城市客运轮渡_客运量", "公共汽电车平均运距", "填表人", "联系电话", "报出日期"};
    public ImportResult importCityBus(MultipartFile file, String period) throws Exception {
        List<CityBusRow> rows = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            Row header = sheet.getRow(0);
            if (header == null || getString(header, 2) == null
                    || !getString(header, 2).contains("企业名称")) {
                throw new RuntimeException("未找到表头行(需包含 企业名称 列)");
            }
            requireHeaderLayout(sheet, "城市公交", "模板_交企统U203表_城市公共交通月度运营情况(2026年7月).xls",
                CITY_BUS_HEADER_COLS, CITY_BUS_HEADER_KEYWORDS);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -2108,15 +2577,18 @@
    }
    /** 巡游出租月报导入(市州级,17 行;含核实说明/上报说明/修改记录) */
    /** 巡游出租(U402)模板关键列位(0-based):所属地区/企业代码/企业名称/载客车次总数/客运量/旅客周转量/运营车辆数/平均运距/填表人/联系电话/报出日期 */
    private static final int[] CITY_TAXI_HEADER_COLS = {0, 1, 2, 4, 6, 8, 10, 11, 15, 16, 17};
    private static final String[] CITY_TAXI_HEADER_KEYWORDS =
        {"所属地区", "企业代码", "企业名称", "载客车次总数", "客运量", "旅客周转量", "运营车辆数",
         "巡游出租汽车平均运距", "填表人", "联系电话", "报出日期"};
    public ImportResult importCityTaxi(MultipartFile file, String period) throws Exception {
        List<TaxiRow> rows = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            Row header = sheet.getRow(0);
            if (header == null || getString(header, 2) == null
                    || !getString(header, 2).contains("企业名称")) {
                throw new RuntimeException("未找到表头行(需包含 企业名称 列)");
            }
            requireHeaderLayout(sheet, "巡游出租", "模板_交行统U402表-巡游出租汽车运营服务情况月报(2026).xls",
                CITY_TAXI_HEADER_COLS, CITY_TAXI_HEADER_KEYWORDS);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -2198,6 +2670,7 @@
        List<CityTaxiAuth> list = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderKeyword(sheet, "出租车运政车辆", "车牌");
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -2246,6 +2719,155 @@
        recordBatch(file.getOriginalFilename(), "CITY_TAXI_AUTH", period, list.size(), success, fail, null);
        log.info("CityTaxiAuth imported: {} rows for {}", success, period);
        return success;
    }
    // ========== 网约车月度输入导入(全省 pin / 订单) ==========
    /** 网约车总数月度输入:模板_网约车总数.xlsx(sheet1;A 列=月份日期;B=全省客运量 C=其中城市内 D=周转量 E=其中城市内周转量)。整表按报表期覆盖。 */
    public synchronized int importWycTotal(MultipartFile file, String period) throws Exception {
        Map<String, WycTotalMonthly> rows = new LinkedHashMap<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            for (int r = 2; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String rp = periodOfCell(row.getCell(0));
                if (rp == null) continue;
                double pk = dbl(row, 1), pck = dbl(row, 2), pz = dbl(row, 3), pcz = dbl(row, 4);
                if (pk <= 0 && pck <= 0 && pz <= 0 && pcz <= 0) continue;
                WycTotalMonthly item = new WycTotalMonthly();
                item.setReportPeriod(rp);
                item.setPinK(pk > 0 ? pk : null);
                item.setPinCK(pck > 0 ? pck : null);
                item.setPinZ(pz > 0 ? pz : null);
                item.setPinCZ(pcz > 0 ? pcz : null);
                rows.put(rp, item);
            }
        }
        if (rows.isEmpty()) throw new RuntimeException("未识别到有效月份行:A 列应为日期(如 2026-07),B-E 为全省客运量/城市内/周转量/城市内周转量");
        int ok = upsertWycTotal(rows);
        log.info("WycTotal imported: {} periods ok={}", rows.size(), ok);
        return ok;
    }
    /** 网约车订单及全省总量:模板_网约车订单及全省总量.xlsx 首个数据 sheet(A=月份;B-E=全省 pin;F=订单合计;G-W=17 市州订单) */
    public synchronized int importWycOrder(MultipartFile file, String period) throws Exception {
        Map<String, WycOrderMonthly> rows = new LinkedHashMap<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            int head = -1;
            for (int r = 0; r <= Math.min(sheet.getLastRowNum(), 4); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String a = getString(row, 0);
                if (a != null && a.contains("月份")) { head = r; break; }
            }
            if (head < 0) throw new RuntimeException("未找到表头:A 列需含“月份”");
            for (int r = head + 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String rp = periodOfCell(row.getCell(0));
                if (rp == null) continue;
                double pk = dbl(row, 1), pck = dbl(row, 2), pz = dbl(row, 3), pcz = dbl(row, 4), sum = dbl(row, 5);
                double citySum = 0;
                for (int k = 0; k < 17; k++) citySum += dbl(row, 6 + k);
                if (pk <= 0 && pck <= 0 && pz <= 0 && pcz <= 0 && sum <= 0 && citySum <= 0) continue;
                WycOrderMonthly it = new WycOrderMonthly();
                it.setReportPeriod(rp);
                it.setPinK(pk > 0 ? pk : null);
                it.setPinCK(pck > 0 ? pck : null);
                it.setPinZ(pz > 0 ? pz : null);
                it.setPinCZ(pcz > 0 ? pcz : null);
                if (sum <= 0 && citySum > 0) sum = citySum;
                it.setOrderSum(sum > 0 ? sum : null);
                it.setOrderWuhan(dblN(row, 6));
                it.setOrderHuangshi(dblN(row, 7));
                it.setOrderShiyan(dblN(row, 8));
                it.setOrderYichang(dblN(row, 9));
                it.setOrderXiangyang(dblN(row, 10));
                it.setOrderEzhou(dblN(row, 11));
                it.setOrderJingmen(dblN(row, 12));
                it.setOrderXiaogan(dblN(row, 13));
                it.setOrderJingzhou(dblN(row, 14));
                it.setOrderHuanggang(dblN(row, 15));
                it.setOrderXianning(dblN(row, 16));
                it.setOrderSuizhou(dblN(row, 17));
                it.setOrderEnshi(dblN(row, 18));
                it.setOrderXiantao(dblN(row, 19));
                it.setOrderQianjiang(dblN(row, 20));
                it.setOrderTianmen(dblN(row, 21));
                it.setOrderShennong(dblN(row, 22));
                rows.put(rp, it);
            }
        }
        if (rows.isEmpty()) throw new RuntimeException("未识别到有效月份行:A 列应为 yyyy-MM,B-E 全省 pin / F 合计 / G-W 17 市州订单");
        int ok = upsertWyc(rows);
        log.info("WycOrder imported: {} periods ok={}", rows.size(), ok);
        return ok;
    }
    private int upsertWyc(Map<String, WycOrderMonthly> rows) {
        int ok = 0, fail = 0;
        for (WycOrderMonthly item : rows.values()) {
            try {
                wycOrderMapper.delete(new LambdaQueryWrapper<WycOrderMonthly>().eq(WycOrderMonthly::getReportPeriod, item.getReportPeriod()));
                wycOrderMapper.insert(item);
                recordBatch("网约车订单及全省总量", "WYC_ORDER", item.getReportPeriod(), 1, 1, 0, null);
                ok++;
            } catch (Exception e) {
                fail++;
                log.error("WycOrder insert error period={}", item.getReportPeriod(), e);
            }
        }
        return ok;
    }
    private int upsertWycTotal(Map<String, WycTotalMonthly> rows) {
        int ok = 0, fail = 0;
        for (WycTotalMonthly item : rows.values()) {
            try {
                wycTotalMapper.delete(new LambdaQueryWrapper<WycTotalMonthly>().eq(WycTotalMonthly::getReportPeriod, item.getReportPeriod()));
                wycTotalMapper.insert(item);
                recordBatch("网约车总数", "WYC_TOTAL", item.getReportPeriod(), 1, 1, 0, null);
                ok++;
            } catch (Exception e) {
                fail++;
                log.error("WycTotal insert error period={}", item.getReportPeriod(), e);
            }
        }
        return ok;
    }
    private String periodOfCell(Cell c) {
        if (c == null) return null;
        if (c.getCellType() == CellType.NUMERIC) {
            try {
                double v = c.getNumericCellValue();
                if (v >= 40000 && v <= 60000) {
                    java.util.Date d = org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v);
                    java.text.SimpleDateFormat f = new java.text.SimpleDateFormat("yyyy-MM");
                    return f.format(d);
                }
            } catch (Exception ignore) { }
        }
        String s = null;
        try { s = c.getStringCellValue(); } catch (Exception ignore) { }
        if (s != null) {
            java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d{4})[-/.年](\\d{1,2})").matcher(s.trim());
            if (m.find()) return String.format("%04d-%02d", Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)));
        }
        return null;
    }
    private double dbl(Row row, int idx) {
        Double v = getDecimal(row, idx);
        return v == null ? 0.0 : v;
    }
    private Double dblN(Row row, int idx) {
        Double v = getDecimal(row, idx);
        return (v == null || v <= 0) ? null : v;
    }
    /** 运政出租车市州提取:道路运输证字号→档案号→经营权号→业户地址,取 6 位区划代码 */
@@ -2369,10 +2991,13 @@
        result.put("parent", cur.getParent() == null ? "" : cur.getParent());
        List<Map<String, Object>> dirs = new ArrayList<>();
        File[] subs = cur.listFiles(File::isDirectory);
        int dirTotal = 0;
        if (subs != null) {
            Arrays.sort(subs, Comparator.comparing(File::getName));
            for (File d : subs) {
                if (d.isHidden()) continue;
                dirTotal++;
                if (dirs.size() >= 300) continue;
                Map<String, Object> m = new LinkedHashMap<>();
                m.put("name", d.getName());
                m.put("path", d.getAbsolutePath());
@@ -2380,17 +3005,21 @@
            }
        }
        result.put("dirs", dirs);
        result.put("dirsTruncated", dirTotal > dirs.size());
        List<String> excelFiles = new ArrayList<>();
        File[] files = cur.listFiles((d, n) -> {
            String lower = n.toLowerCase();
            return lower.endsWith(".xlsx") || lower.endsWith(".xls");
        });
        int excelTotal = files == null ? 0 : files.length;
        if (files != null) {
            Arrays.sort(files, Comparator.comparing(File::getName));
            for (File f : files) excelFiles.add(f.getName());
            int take = Math.min(files.length, 300);
            for (int i = 0; i < take; i++) excelFiles.add(files[i].getName());
        }
        result.put("excelCount", excelFiles.size());
        result.put("excelCount", excelTotal);
        result.put("files", excelFiles);
        result.put("filesTruncated", excelTotal > excelFiles.size());
        return result;
    }
@@ -2577,6 +3206,8 @@
            case "trackMileage": return new ImportResult(importTrackMileage(file, period), 0, new ArrayList<>());
            case "freightTurnover": return new ImportResult(importFreightTurnover(file, period), 0, new ArrayList<>());
            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<>());
            default: throw new RuntimeException("不支持的导入类型:" + type);
        }
    }