feat(数据查看): 按市州汇总(任意起止月/自动上年同期/时点列不参与/排名可选) + 导出 Excel,新增 CityRollupService 与 3 个接口
2个文件已修改
1个文件已添加
768 ■■■■■ 已修改文件
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java 626 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java
@@ -24,6 +24,8 @@
import com.trafficaudit.dataimport.entity.WycTotalMonthly;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import com.trafficaudit.dataimport.service.CityRollupService;
import javax.servlet.http.HttpServletResponse;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerAuthVehicleMapper;
@@ -100,6 +102,8 @@
    private WycTotalMonthlyMapper wycTotalMapper;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
    private CityRollupService cityRollupService;
    @GetMapping("/list")
    public Result<Map<String, Object>> list(@RequestParam("type") String type,
@@ -451,6 +455,39 @@
        return rows;
    }
    /** 支持按市州汇总的数据类型 */
    @GetMapping("/rollupTypes")
    public Result<List<Map<String, Object>>> rollupTypes() {
        return Result.ok(cityRollupService.supportedTypes());
    }
    /** 按市州汇总(任意起止月) */
    @GetMapping("/summary")
    public Result<Map<String, Object>> summary(@RequestParam("type") String type,
                                               @RequestParam("from") String from,
                                               @RequestParam("to") String to,
                                               @RequestParam(value = "rankMode", defaultValue = "blank") String rankMode) {
        return Result.ok(cityRollupService.summary(type, from, to, "recompute".equalsIgnoreCase(rankMode)));
    }
    /** 按市州汇总导出 Excel(数值全精度,显示 2 位小数 / 百分比) */
    @GetMapping("/export")
    public void export(@RequestParam("type") String type,
                       @RequestParam("from") String from,
                       @RequestParam("to") String to,
                       @RequestParam(value = "rankMode", defaultValue = "blank") String rankMode,
                       HttpServletResponse response) throws Exception {
        byte[] bytes = cityRollupService.exportExcel(type, from, to, "recompute".equalsIgnoreCase(rankMode));
        String fileName = "按市州汇总_" + type + "_" + from + "_" + to + ".xlsx";
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-Disposition",
                "attachment; filename=\"" + java.net.URLEncoder.encode(fileName, "UTF-8") + "\"");
        response.setContentLength(bytes.length);
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();
    }
    private boolean notEmpty(String s) {
        return s != null && !s.trim().isEmpty();
    }
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java
New file
@@ -0,0 +1,626 @@
package com.trafficaudit.dataimport.service;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.CityBusMonthly;
import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
import com.trafficaudit.dataimport.entity.PassengerEnterpriseMonthly;
import com.trafficaudit.dataimport.entity.PassengerIndividualMonthly;
import com.trafficaudit.dataimport.entity.WycOrderMonthly;
import com.trafficaudit.dataimport.mapper.CityBusMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerEnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerIndividualMonthlyMapper;
import com.trafficaudit.dataimport.mapper.WycOrderMonthlyMapper;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * 数据查看 - 按市州汇总(任意起止月)。
 *
 * 口径(2026-09-22 与用户确认):
 *  1) 流量类列(客运量/周转量/货运量/订单数)跨期求和;
 *  2) 时点类列(车辆数、企业数、站点数)不参与汇总,不出现在结果里;
 *  3) 比率类列按累计口径重算:累计同比 = 本期累计 ÷ 上年同期累计 − 1;平均运距 = 累计周转量 ÷ 累计客运量;
 *  4) 排名默认留空,可由前端选择"按累计值重排";
 *  5) 结果 = 17 市州(RegionUtil 规范名/顺序)+ 全省合计行。
 */
@Service
public class CityRollupService {
    public static final String TOTAL_CITY = "全省合计";
    @Resource
    private CityBusMonthlyMapper cityBusMapper;
    @Resource
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Resource
    private H2032EnterpriseMonthlyMapper h2032Mapper;
    @Resource
    private PassengerEnterpriseMonthlyMapper h2031Mapper;
    @Resource
    private PassengerIndividualMonthlyMapper individualMapper;
    @Resource
    private WycOrderMonthlyMapper wycOrderMapper;
    @Resource
    private FreightTurnoverImportMapper freightTurnoverMapper;
    @Resource
    private WycSplitCalc wycSplitCalc;
    /** 汇总列定义。kind:FLOW 求和 / AVG 分子÷分母 / YOY 本期累计↔上年同期累计 / YOYS 同行去年字段 / RANK 按某输出列重排名次 */
    private static class Col {
        final String prop;
        final String label;
        final int width;
        final String kind;
        final String src;
        final String den;
        final String last;
        final String rankBy;
        Col(String prop, String label, int width, String kind, String src, String den, String last, String rankBy) {
            this.prop = prop;
            this.label = label;
            this.width = width;
            this.kind = kind;
            this.src = src;
            this.den = den;
            this.last = last;
            this.rankBy = rankBy;
        }
    }
    private static Col flow(String prop, String label, int width, String src) {
        return new Col(prop, label, width, "FLOW", src, null, null, null);
    }
    private static Col avg(String prop, String label, String src, String den) {
        return new Col(prop, label, 130, "AVG", src, den, null, null);
    }
    private static Col yoy(String prop, String label, String src) {
        return new Col(prop, label, 150, "YOY", src, null, null, null);
    }
    private static Col yoys(String prop, String label, String src, String last) {
        return new Col(prop, label, 150, "YOYS", src, null, last, null);
    }
    private static Col rank(String prop, String label, String rankBy) {
        return new Col(prop, label, 110, "RANK", null, null, null, rankBy);
    }
    private static final Map<String, String> TYPE_LABELS = new LinkedHashMap<>();
    private static final Map<String, List<Col>> SPECS = new LinkedHashMap<>();
    static {
        TYPE_LABELS.put("cityBus", "城市公共交通(按市州汇总)");
        SPECS.put("cityBus", Arrays.asList(
                flow("passengerVolume", "客运量(万人次)", 140, "passengerVolume"),
                flow("turnover", "旅客周转量(万人公里)", 180, "turnover"),
                flow("passengerCity", "城市内客运量(万人次)", 170, "passengerCity"),
                flow("turnoverCity", "城市内周转量(万人公里)", 190, "turnoverCity"),
                flow("passengerChengxiang", "城际城乡客运量(万人次)", 190, "passengerChengxiang"),
                flow("turnoverChengxiang", "城际城乡周转量(万人公里)", 210, "turnoverChengxiang"),
                avg("avgDistance", "平均运距(公里)", "turnover", "passengerVolume"),
                yoy("passengerYoy", "客运量累计同比(%)", "passengerVolume"),
                rank("passengerRank", "客运量排名", "passengerVolume")));
        TYPE_LABELS.put("cityTaxi", "巡游出租汽车(按市州汇总)");
        SPECS.put("cityTaxi", Arrays.asList(
                flow("passengerVolume", "客运量(万人次)", 140, "passengerVolume"),
                flow("turnover", "旅客周转量(万人公里)", 180, "turnover"),
                flow("passengerCity", "城市内客运量(万人次)", 170, "passengerCity"),
                flow("turnoverCity", "城市内周转量(万人公里)", 190, "turnoverCity"),
                flow("tripTotal", "载客车次总数(车次)", 170, "tripTotal"),
                flow("tripCity", "载客车次_城市内(车次)", 190, "tripCity"),
                avg("avgDistance", "平均运距(公里)", "turnover", "passengerVolume"),
                yoy("passengerYoy", "客运量累计同比(%)", "passengerVolume"),
                rank("passengerRank", "客运量排名", "passengerVolume")));
        TYPE_LABELS.put("h2032", "道路货物运输企业(按市州汇总)");
        SPECS.put("h2032", Arrays.asList(
                flow("freightTotal", "货运量(吨)", 150, "freightTotal"),
                flow("turnoverTotal", "货运周转量(吨公里)", 180, "turnoverTotal"),
                avg("avgDistance", "平均运距(公里)", "turnoverTotal", "freightTotal"),
                yoy("freightYoy", "货运量累计同比(%)", "freightTotal"),
                rank("freightRank", "货运量排名", "freightTotal"),
                rank("turnoverRank", "周转量排名", "turnoverTotal")));
        TYPE_LABELS.put("h2031", "公路旅客运输企业(按市州汇总)");
        SPECS.put("h2031", Arrays.asList(
                flow("passengerTotal", "客运量(人)", 140, "passengerTotal"),
                flow("turnoverTotal", "旅客周转量(人公里)", 180, "turnoverTotal"),
                avg("avgDistance", "平均运距(公里)", "turnoverTotal", "passengerTotal"),
                yoy("passengerYoy", "客运量累计同比(%)", "passengerTotal"),
                rank("passengerRank", "客运量排名", "passengerTotal")));
        TYPE_LABELS.put("passengerIndividual", "个体客运量/周转量(按市州汇总)");
        SPECS.put("passengerIndividual", Arrays.asList(
                flow("passengerCount", "个体客运量(人)", 150, "passengerCount"),
                flow("turnover", "个体旅客周转量(人公里)", 190, "turnover"),
                yoy("passengerYoy", "客运量累计同比(%)", "passengerCount")));
        TYPE_LABELS.put("wycOrder", "网约车(按市州、拆分口径汇总)");
        SPECS.put("wycOrder", Arrays.asList(
                flow("orderCount", "订单数(单)", 150, "orderCount"),
                flow("pax", "客运量(万人次)", 140, "pax"),
                flow("turnover", "旅客周转量(万人公里)", 180, "turnover"),
                flow("cityPax", "城市内客运量(万人次)", 170, "cityPax"),
                flow("cityTurnover", "城市内旅客周转量(万人公里)", 210, "cityTurnover"),
                yoy("paxYoy", "客运量累计同比(%)", "pax"),
                rank("paxRank", "客运量排名", "pax")));
        TYPE_LABELS.put("freightTurnover", "货运量周转量(按市州汇总)");
        SPECS.put("freightTurnover", Arrays.asList(
                flow("freight", "货运量(万吨)", 140, "freightM"),
                flow("turnover", "周转量(万吨公里)", 170, "turnoverM"),
                yoys("freightYoy", "货运量累计同比(%)", "freightM", "lastFreightM"),
                yoys("turnoverYoy", "周转量累计同比(%)", "turnoverM", "lastTurnoverM"),
                rank("freightRank", "货运量排名", "freight"),
                rank("turnoverRank", "周转量排名", "turnover")));
    }
    public List<Map<String, Object>> supportedTypes() {
        List<Map<String, Object>> list = new ArrayList<>();
        for (Map.Entry<String, String> e : TYPE_LABELS.entrySet()) {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("type", e.getKey());
            m.put("label", e.getValue());
            list.add(m);
        }
        return list;
    }
    /** 按市州汇总 */
    public Map<String, Object> summary(String type, String from, String to, boolean recomputeRank) {
        List<Col> cols = SPECS.get(type);
        if (cols == null) {
            throw new RuntimeException("该数据类型暂不支持按市州汇总:" + type);
        }
        String f = trimPeriod(from);
        String t = trimPeriod(to);
        if (f == null || t == null) {
            throw new RuntimeException("请先选择起止月(例如 2026-01 ~ 2026-08)");
        }
        if (f.compareTo(t) > 0) {
            String tmp = f;
            f = t;
            t = tmp;
        }
        String lastFrom = yearBefore(f);
        String lastTo = yearBefore(t);
        Map<String, Map<String, Double>> cur = sumByCity(longRows(type, f, t), cols);
        Map<String, Map<String, Double>> last = sumByCity(longRows(type, lastFrom, lastTo), cols);
        List<Map<String, Object>> records = new ArrayList<>();
        for (String city : RegionUtil.cityList()) {
            records.add(outRow(cols, cur.get(city), last.get(city), city));
        }
        records.add(outRow(cols, mergeAll(cur), mergeAll(last), TOTAL_CITY));
        if (recomputeRank) {
            applyRank(records, cols);
        }
        List<Map<String, Object>> columns = new ArrayList<>();
        columns.add(colMeta("city", "市州", 120));
        for (Col c : cols) {
            columns.add(colMeta(c.prop, c.label, c.width));
        }
        Map<String, Object> res = new LinkedHashMap<>();
        res.put("type", type);
        res.put("label", TYPE_LABELS.get(type));
        res.put("from", f);
        res.put("to", t);
        res.put("lastFrom", lastFrom);
        res.put("lastTo", lastTo);
        res.put("rankMode", recomputeRank ? "recompute" : "blank");
        res.put("columns", columns);
        res.put("records", records);
        return res;
    }
    /** 导出为 xlsx(数值保留全精度,显示格式 2 位小数 / 百分比) */
    public byte[] exportExcel(String type, String from, String to, boolean recomputeRank) throws IOException {
        Map<String, Object> data = summary(type, from, to, recomputeRank);
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> columns = (List<Map<String, Object>>) data.get("columns");
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> records = (List<Map<String, Object>>) data.get("records");
        String label = String.valueOf(data.get("label"));
        String title = label + " " + data.get("from") + " ~ " + data.get("to") + "(累计)";
        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
            XSSFSheet sheet = wb.createSheet("按市州汇总");
            Font bold = wb.createFont();
            bold.setBold(true);
            XSSFCellStyle titleStyle = wb.createCellStyle();
            titleStyle.setFont(bold);
            XSSFCellStyle headStyle = wb.createCellStyle();
            headStyle.setFont(bold);
            headStyle.setAlignment(HorizontalAlignment.CENTER);
            headStyle.setBorderBottom(BorderStyle.THIN);
            XSSFCellStyle numStyle = wb.createCellStyle();
            numStyle.setDataFormat(wb.createDataFormat().getFormat("0.00"));
            XSSFCellStyle pctStyle = wb.createCellStyle();
            pctStyle.setDataFormat(wb.createDataFormat().getFormat("0.00%"));
            XSSFRow titleRow = sheet.createRow(0);
            XSSFCell titleCell = titleRow.createCell(0);
            titleCell.setCellValue(title);
            titleCell.setCellStyle(titleStyle);
            XSSFRow headRow = sheet.createRow(1);
            for (int i = 0; i < columns.size(); i++) {
                XSSFCell cell = headRow.createCell(i);
                cell.setCellValue(String.valueOf(columns.get(i).get("label")));
                cell.setCellStyle(headStyle);
            }
            int r = 2;
            for (Map<String, Object> rec : records) {
                XSSFRow row = sheet.createRow(r++);
                for (int i = 0; i < columns.size(); i++) {
                    String prop = String.valueOf(columns.get(i).get("prop"));
                    String colLabel = String.valueOf(columns.get(i).get("label"));
                    Object v = rec.get(prop);
                    XSSFCell cell = row.createCell(i);
                    if (v instanceof Number) {
                        cell.setCellValue(((Number) v).doubleValue());
                        cell.setCellStyle(colLabel.contains("同比") ? pctStyle : numStyle);
                    } else {
                        cell.setCellValue(v == null ? "" : String.valueOf(v));
                    }
                }
            }
            for (int i = 0; i < columns.size(); i++) {
                sheet.setColumnWidth(i, Math.min(40, Math.max(10, Integer.parseInt(String.valueOf(columns.get(i).get("width"))) / 7)) * 256);
            }
            wb.write(bos);
            return bos.toByteArray();
        }
    }
    // ==================== 内部实现 ====================
    private static String trimPeriod(String p) {
        if (p == null) {
            return null;
        }
        String s = p.trim();
        if (s.length() >= 7) {
            return s.substring(0, 7);
        }
        return s.isEmpty() ? null : s;
    }
    private static String yearBefore(String period) {
        int year = Integer.parseInt(period.substring(0, 4)) - 1;
        return year + period.substring(4);
    }
    private static Map<String, Object> colMeta(String prop, String label, int width) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("prop", prop);
        m.put("label", label);
        m.put("width", width);
        return m;
    }
    /** 只有 17 个规范市州名才算市州;"湖北省""全省"等合计行返回 null(避免重复计入) */
    private String cityOrNull(String name) {
        if (name == null) {
            return null;
        }
        String normalized = RegionUtil.normalizeCityName(name);
        return normalized != null && RegionUtil.cityList().contains(normalized) ? normalized : null;
    }
    private String cityOfCode(String regionCode) {
        if (regionCode == null || regionCode.trim().isEmpty()) {
            return null;
        }
        return RegionUtil.cityByCode(regionCode.trim());
    }
    private static double nz(Double v) {
        return v == null ? 0.0 : v;
    }
    private static double num(Object v) {
        if (v instanceof Number) {
            return ((Number) v).doubleValue();
        }
        if (v instanceof String) {
            try {
                return Double.parseDouble(((String) v).trim());
            } catch (Exception ignore) {
                return 0.0;
            }
        }
        return 0.0;
    }
    /** 取某类型在 [from,to] 的"长表"行:每行含 _city 与参与汇总的源字段 */
    private List<Map<String, Object>> longRows(String type, String from, String to) {
        List<Map<String, Object>> rows = new ArrayList<>();
        switch (type) {
            case "cityBus": {
                List<CityBusMonthly> list = cityBusMapper.selectList(new LambdaQueryWrapper<CityBusMonthly>()
                        .ge(CityBusMonthly::getReportPeriod, from).le(CityBusMonthly::getReportPeriod, to));
                for (CityBusMonthly e : list) {
                    String city = e.getCity() != null ? cityOrNull(e.getCity()) : cityOfCode(e.getRegionCode());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> m = BeanUtil.beanToMap(e);
                    m.put("_city", city);
                    rows.add(m);
                }
                break;
            }
            case "cityTaxi": {
                List<CityTaxiMonthly> list = cityTaxiMapper.selectList(new LambdaQueryWrapper<CityTaxiMonthly>()
                        .ge(CityTaxiMonthly::getReportPeriod, from).le(CityTaxiMonthly::getReportPeriod, to));
                for (CityTaxiMonthly e : list) {
                    String city = e.getCity() != null ? cityOrNull(e.getCity()) : cityOfCode(e.getRegionCode());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> m = BeanUtil.beanToMap(e);
                    m.put("_city", city);
                    rows.add(m);
                }
                break;
            }
            case "h2032": {
                List<H2032EnterpriseMonthly> list = h2032Mapper.selectList(new LambdaQueryWrapper<H2032EnterpriseMonthly>()
                        .ge(H2032EnterpriseMonthly::getReportPeriod, from).le(H2032EnterpriseMonthly::getReportPeriod, to));
                for (H2032EnterpriseMonthly e : list) {
                    String city = cityOfCode(e.getRegionCode());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> m = BeanUtil.beanToMap(e);
                    m.put("_city", city);
                    rows.add(m);
                }
                break;
            }
            case "h2031": {
                List<PassengerEnterpriseMonthly> list = h2031Mapper.selectList(new LambdaQueryWrapper<PassengerEnterpriseMonthly>()
                        .ge(PassengerEnterpriseMonthly::getReportPeriod, from).le(PassengerEnterpriseMonthly::getReportPeriod, to));
                for (PassengerEnterpriseMonthly e : list) {
                    String city = cityOfCode(e.getRegionCode());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> m = BeanUtil.beanToMap(e);
                    m.put("_city", city);
                    rows.add(m);
                }
                break;
            }
            case "passengerIndividual": {
                List<PassengerIndividualMonthly> list = individualMapper.selectList(new LambdaQueryWrapper<PassengerIndividualMonthly>()
                        .ge(PassengerIndividualMonthly::getReportPeriod, from).le(PassengerIndividualMonthly::getReportPeriod, to));
                for (PassengerIndividualMonthly e : list) {
                    String city = cityOfCode(e.getRegionCode());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> m = BeanUtil.beanToMap(e);
                    m.put("_city", city);
                    rows.add(m);
                }
                break;
            }
            case "wycOrder": {
                List<WycOrderMonthly> list = wycOrderMapper.selectList(new LambdaQueryWrapper<WycOrderMonthly>()
                        .ge(WycOrderMonthly::getReportPeriod, from).le(WycOrderMonthly::getReportPeriod, to));
                List<String> cities = RegionUtil.cityList();
                for (WycOrderMonthly e : list) {
                    Map<String, WycSplitCalc.WycMetrics> byCity = null;
                    try {
                        byCity = wycSplitCalc.calc(e.getReportPeriod()).getByCity();
                    } catch (Exception ignore) {
                        // 拆分输入不全时只汇总订单数
                    }
                    double[] orders = {nz(e.getOrderWuhan()), nz(e.getOrderHuangshi()), nz(e.getOrderShiyan()),
                            nz(e.getOrderYichang()), nz(e.getOrderXiangyang()), nz(e.getOrderEzhou()), nz(e.getOrderJingmen()),
                            nz(e.getOrderXiaogan()), nz(e.getOrderJingzhou()), nz(e.getOrderHuanggang()), nz(e.getOrderXianning()),
                            nz(e.getOrderSuizhou()), nz(e.getOrderEnshi()), nz(e.getOrderXiantao()), nz(e.getOrderQianjiang()),
                            nz(e.getOrderTianmen()), nz(e.getOrderShennong())};
                    for (int i = 0; i < cities.size() && i < orders.length; i++) {
                        Map<String, Object> m = new LinkedHashMap<>();
                        m.put("_city", cities.get(i));
                        m.put("orderCount", orders[i]);
                        WycSplitCalc.WycMetrics mt = byCity == null ? null : byCity.get(cities.get(i));
                        if (mt != null) {
                            m.put("pax", mt.getTotalPax());
                            m.put("turnover", mt.getTotalTurnover());
                            m.put("cityPax", mt.getCityPax());
                            m.put("cityTurnover", mt.getCityTurnover());
                        }
                        rows.add(m);
                    }
                }
                break;
            }
            case "freightTurnover": {
                // 该表每行含"年初至当期"的各月列(行内累计),故累计只用 to 那一行、对区间月份求和;
                // 且"湖北省"行等于 17 市州之和,必须排除,否则重复计入。
                List<FreightTurnoverImport> list = freightTurnoverMapper.selectList(new LambdaQueryWrapper<FreightTurnoverImport>()
                        .ge(FreightTurnoverImport::getReportPeriod, from)
                        .le(FreightTurnoverImport::getReportPeriod, to)
                        .orderByDesc(FreightTurnoverImport::getReportPeriod));
                if (list.isEmpty()) {
                    break;
                }
                // 该表每行是"年初至当期"的行内累计,取区间内最新一期那一行;结束月无数据时自动回退到最新有数月份
                String latestPeriod = list.get(0).getReportPeriod();
                list = new ArrayList<>();
                for (FreightTurnoverImport e : freightTurnoverMapper.selectList(new LambdaQueryWrapper<FreightTurnoverImport>()
                        .eq(FreightTurnoverImport::getReportPeriod, latestPeriod))) {
                    list.add(e);
                }
                int m1 = Integer.parseInt(from.substring(5, 7));
                int m2 = Integer.parseInt(latestPeriod.substring(5, 7));
                if (m2 < m1) {
                    break;
                }
                for (FreightTurnoverImport e : list) {
                    String city = cityOrNull(e.getRegionName());
                    if (city == null) {
                        continue;
                    }
                    Map<String, Object> src = BeanUtil.beanToMap(e);
                    double freight = 0, turnover = 0, lastFreight = 0, lastTurnover = 0;
                    for (int mm = m1; mm <= m2; mm++) {
                        freight += num(src.get(String.format("freightM%02d", mm)));
                        turnover += num(src.get(String.format("turnoverM%02d", mm)));
                        lastFreight += num(src.get(String.format("lastFreightM%02d", mm)));
                        lastTurnover += num(src.get(String.format("lastTurnoverM%02d", mm)));
                    }
                    Map<String, Object> m = new LinkedHashMap<>();
                    m.put("_city", city);
                    m.put("freightM", freight);
                    m.put("turnoverM", turnover);
                    m.put("lastFreightM", lastFreight);
                    m.put("lastTurnoverM", lastTurnover);
                    rows.add(m);
                }
                break;
            }
            default:
                break;
        }
        return rows;
    }
    /** 按市州累计各列用到的源字段 */
    private Map<String, Map<String, Double>> sumByCity(List<Map<String, Object>> rows, List<Col> cols) {
        Map<String, Map<String, Double>> acc = new LinkedHashMap<>();
        for (Map<String, Object> row : rows) {
            Object cityObj = row.get("_city");
            if (cityObj == null) {
                continue;
            }
            String city = String.valueOf(cityObj);
            Map<String, Double> sums = acc.computeIfAbsent(city, k -> new LinkedHashMap<>());
            Set<String> keys = new LinkedHashSet<>();
            for (Col c : cols) {
                if (c.src != null) {
                    keys.add(c.src);
                }
                if (c.den != null) {
                    keys.add(c.den);
                }
                if (c.last != null) {
                    keys.add(c.last);
                }
            }
            for (String key : keys) {
                sums.merge(key, num(row.get(key)), Double::sum);
            }
        }
        return acc;
    }
    private Map<String, Double> mergeAll(Map<String, Map<String, Double>> acc) {
        Map<String, Double> total = new LinkedHashMap<>();
        for (Map<String, Double> sums : acc.values()) {
            for (Map.Entry<String, Double> e : sums.entrySet()) {
                total.merge(e.getKey(), e.getValue(), Double::sum);
            }
        }
        return total;
    }
    private Map<String, Object> outRow(List<Col> cols, Map<String, Double> cur, Map<String, Double> last, String city) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("city", city);
        for (Col c : cols) {
            Double value = null;
            switch (c.kind) {
                case "FLOW": {
                    value = cur == null ? null : cur.get(c.src);
                    break;
                }
                case "AVG": {
                    double den = cur == null ? 0.0 : num(cur.get(c.den));
                    double nume = cur == null ? 0.0 : num(cur.get(c.src));
                    value = den > 0 ? nume / den : null;
                    break;
                }
                case "YOY": {
                    double base = last == null ? 0.0 : num(last.get(c.src));
                    double now = cur == null ? 0.0 : num(cur.get(c.src));
                    value = base > 0 ? now / base - 1 : null;
                    break;
                }
                case "YOYS": {
                    double base = cur == null ? 0.0 : num(cur.get(c.last));
                    double now = cur == null ? 0.0 : num(cur.get(c.src));
                    value = base > 0 ? now / base - 1 : null;
                    break;
                }
                default: {
                    value = null; // RANK: 由 applyRank 填
                }
            }
            row.put(c.prop, value);
        }
        return row;
    }
    /** 按累计值重新排名(1 = 最大);全省合计行不排名 */
    private void applyRank(List<Map<String, Object>> records, List<Col> cols) {
        List<Map<String, Object>> cities = new ArrayList<>();
        for (Map<String, Object> r : records) {
            if (!TOTAL_CITY.equals(r.get("city"))) {
                cities.add(r);
            }
        }
        for (Col c : cols) {
            if (!"RANK".equals(c.kind)) {
                continue;
            }
            List<Map<String, Object>> sorted = new ArrayList<>(cities);
            sorted.sort(Comparator.comparingDouble((Map<String, Object> r) -> {
                Object v = r.get(c.rankBy);
                return v instanceof Number ? ((Number) v).doubleValue() : Double.NEGATIVE_INFINITY;
            }).reversed());
            for (int i = 0; i < sorted.size(); i++) {
                Object v = sorted.get(i).get(c.rankBy);
                sorted.get(i).put(c.prop, v instanceof Number ? (i + 1) : null);
            }
        }
    }
}
traffic-audit-web/src/views/DataView.vue
@@ -17,8 +17,10 @@
      <el-main>
        <h3>导入数据查看</h3>
        <div class="toolbar">
          <span class="toolbar-label">报表期</span>
          <el-date-picker v-model="period" type="month" placeholder="报表期(全部)" value-format="yyyy-MM" clearable style="width:150px" @change="doQuery"></el-date-picker>
          <template v-if="!isRollup">
            <span class="toolbar-label">报表期</span>
            <el-date-picker v-model="period" type="month" placeholder="报表期(全部)" value-format="yyyy-MM" clearable style="width:150px" @change="doQuery"></el-date-picker>
          </template>
          <span class="toolbar-sep"></span>
          <span class="toolbar-label">数据类型</span>
          <el-select v-model="type" size="small" style="width:300px" placeholder="选择数据类型" filterable
@@ -27,6 +29,26 @@
              <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option>
            </el-option-group>
          </el-select>
          <template v-if="canRollup">
            <span class="toolbar-label">视图</span>
            <el-radio-group v-model="viewMode" size="small" @change="onViewChange">
              <el-radio-button label="detail">明细</el-radio-button>
              <el-radio-button label="rollup">按市州汇总</el-radio-button>
            </el-radio-group>
          </template>
          <template v-if="isRollup">
            <span class="toolbar-label">起止月</span>
            <el-date-picker v-model="from" type="month" placeholder="起始月" value-format="yyyy-MM" style="width:130px"></el-date-picker>
            <span>~</span>
            <el-date-picker v-model="to" type="month" placeholder="结束月" value-format="yyyy-MM" style="width:130px"></el-date-picker>
            <span class="toolbar-label">排名</span>
            <el-select v-model="rankMode" size="small" style="width:150px">
              <el-option label="留空" value="blank"></el-option>
              <el-option label="按累计值重排" value="recompute"></el-option>
            </el-select>
            <el-button type="primary" size="small" @click="loadSummary">汇总</el-button>
            <el-button type="success" size="small" icon="el-icon-download" @click="exportSummary">导出 Excel</el-button>
          </template>
          <template v-if="hasPeriodType">
            <span class="toolbar-label">口径</span>
            <el-select v-model="periodType" size="small" style="width:110px" @change="doQuery">
@@ -35,11 +57,13 @@
              <el-option label="累计" value="CUMULATIVE"></el-option>
            </el-select>
          </template>
          <el-input v-model="keyword" placeholder="企业/市州名称" clearable style="width:180px" @keyup.enter.native="doQuery"></el-input>
          <el-button type="primary" size="small" @click="doQuery">查询</el-button>
          <template v-if="!isRollup">
            <el-input v-model="keyword" placeholder="企业/市州名称" clearable style="width:180px" @keyup.enter.native="doQuery"></el-input>
            <el-button type="primary" size="small" @click="doQuery">查询</el-button>
          </template>
          <el-button size="small" icon="el-icon-full-screen" @click="toggleFullscreen">全屏查看</el-button>
        </div>
        <div class="load-hint">共 {{ total }} 条记录,已加载 {{ records.length }} 条</div>
        <div class="load-hint">共 {{ total }} 条记录,已加载 {{ records.length }} 条<span v-if="isRollup && summaryHint" class="rollup-hint"> | {{ summaryHint }}</span></div>
        <div class="table-wrap" ref="tableWrap">
          <div class="table-area">
            <el-table :data="records" border v-loading="loading" size="small" style="width:100%" height="100%">
@@ -108,6 +132,12 @@
      type: 'h2032',
      period: '',
      periodType: '',
      viewMode: 'detail',
      rollupTypes: [],
      from: '',
      to: '',
      rankMode: 'blank',
      summaryHint: '',
      keyword: '',
      page: 1,
      size: 50,
@@ -119,6 +149,12 @@
    }
  },
  computed: {
    canRollup() {
      return (this.rollupTypes || []).some(t => t.type === this.type)
    },
    isRollup() {
      return this.viewMode === 'rollup' && this.canRollup
    },
    // 规上规下拆分等表有"口径"列(当月/累计),把它做成上方筛选而不是表格列
    hasPeriodType() {
      return (this.columns || []).some(c => c.prop === 'periodType')
@@ -168,10 +204,67 @@
      }
      this.doQuery()
    },
    loadRollupTypes() {
      api.get('/data/rollupTypes').then(res => {
        // 拦截器已返回响应体,故 res.data 即类型数组
        this.rollupTypes = (res && res.data) || []
      }).catch(() => { this.rollupTypes = [] })
    },
    onViewChange() {
      this.records = []
      this.total = 0
      if (this.isRollup) {
        if (!this.to) this.to = this.period || this.defaultMonth()
        if (!this.from) this.from = this.to.substring(0, 4) + '-01'
        this.loadSummary()
      } else {
        this.summaryHint = ''
        this.doQuery()
      }
    },
    defaultMonth() {
      const now = new Date()
      return now.getFullYear() + '-' + ('0' + (now.getMonth() + 1)).slice(-2)
    },
    loadSummary() {
      if (!this.from || !this.to) { this.$message.warning('请先选择起止月'); return }
      this.loading = true
      api.get('/data/summary', { params: { type: this.type, from: this.from, to: this.to, rankMode: this.rankMode }, timeout: 120000 })
        .then(res => {
          const d = res.data || {}
          this.columns = d.columns || []
          this.records = d.records || []
          this.total = this.records.length
          this.summaryHint = (d.label || '') + ' ' + d.from + ' ~ ' + d.to + ' 累计;同比基数 ' + d.lastFrom + ' ~ ' + d.lastTo
            + ';排名口径:' + (d.rankMode === 'recompute' ? '按累计值重排' : '留空')
        })
        .catch(e => { if (!e || !e._toast) this.$message.error('汇总查询失败') })
        .finally(() => { this.loading = false })
    },
    exportSummary() {
      if (!this.from || !this.to) { this.$message.warning('请先选择起止月'); return }
      api.get('/data/export', {
        params: { type: this.type, from: this.from, to: this.to, rankMode: this.rankMode },
        responseType: 'blob', timeout: 180000
      }).then(res => {
        const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
        const url = window.URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = '按市州汇总_' + this.type + '_' + this.from + '_' + this.to + '.xlsx'
        document.body.appendChild(a)
        a.click()
        document.body.removeChild(a)
        window.URL.revokeObjectURL(url)
        this.$message.success('汇总表已导出')
      }).catch(() => this.$message.error('导出失败'))
    },
    onTypeSelect() {
      const g = this.modules.find(x => x.types.some(t => t.value === this.type))
      if (g) this.activeModule = g.key
      this.periodType = ''
      this.viewMode = 'detail'
      this.summaryHint = ''
      this.doQuery()
    },
    doQuery() {
@@ -303,6 +396,7 @@
    if (q.type) this.type = q.type
    if (q.period) this.period = q.period
    if (q.keyword) this.keyword = q.keyword
    this.loadRollupTypes()
    this.loadData()
    window.addEventListener('scroll', this.onScroll, { passive: true })
    if (this.$refs.tableWrap) {
@@ -330,6 +424,7 @@
#app .toolbar-label { color:#606266; font-weight:600; white-space:nowrap; }
#app .toolbar-sep { width:1px; height:22px; background:#DCDFE6; margin:0 2px; }
#app .load-hint { color:#909399; font-size:12px; margin-bottom:6px; }
#app .rollup-hint { color:#409EFF; }
#app .table-wrap { display:flex; flex-direction:column; height:calc(100vh - 250px); min-height:300px; }
#app .table-area { flex:1 1 auto; min-height:0; overflow:hidden; }
#app .table-area .el-table { width:100%; height:100%; }