From e9f1ee7341bcf55904f6c0d31aa91ff52aa441dc Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期二, 22 九月 2026 11:28:40 +0800
Subject: [PATCH] feat(数据查看): 按市州汇总(任意起止月/自动上年同期/时点列不参与/排名可选) + 导出 Excel,新增 CityRollupService 与 3 个接口

---
 traffic-audit-web/src/views/DataView.vue                                                          |  105 +++++++
 traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java |   37 ++
 traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java     |  626 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 763 insertions(+), 5 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java b/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java
index f769ca6..6ce7914 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/controller/DataViewController.java
+++ b/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();
     }
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java b/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java
new file mode 100644
index 0000000..761cd02
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/CityRollupService.java
@@ -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;
+
+/**
+ * 鏁版嵁鏌ョ湅 - 鎸夊競宸炴眹鎬伙紙浠绘剰璧锋鏈堬級銆�
+ *
+ * 鍙e緞锛�2026-09-22 涓庣敤鎴风‘璁わ級锛�
+ *  1) 娴侀噺绫诲垪锛堝杩愰噺/鍛ㄨ浆閲�/璐ц繍閲�/璁㈠崟鏁帮級璺ㄦ湡姹傚拰锛�
+ *  2) 鏃剁偣绫诲垪锛堣溅杈嗘暟銆佷紒涓氭暟銆佺珯鐐规暟锛変笉鍙備笌姹囨�伙紝涓嶅嚭鐜板湪缁撴灉閲岋紱
+ *  3) 姣旂巼绫诲垪鎸夌疮璁″彛寰勯噸绠楋細绱鍚屾瘮 = 鏈湡绱 梅 涓婂勾鍚屾湡绱 鈭� 1锛涘钩鍧囪繍璺� = 绱鍛ㄨ浆閲� 梅 绱瀹㈣繍閲忥紱
+ *  4) 鎺掑悕榛樿鐣欑┖锛屽彲鐢卞墠绔�夋嫨"鎸夌疮璁″�奸噸鎺�"锛�
+ *  5) 缁撴灉 = 17 甯傚窞锛圧egionUtil 瑙勮寖鍚�/椤哄簭锛�+ 鍏ㄧ渷鍚堣琛屻��
+ */
+@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;
+
+    /** 姹囨�诲垪瀹氫箟銆俴ind锛欶LOW 姹傚拰 / 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 閭d竴琛屻�佸鍖洪棿鏈堜唤姹傚拰锛�
+                // 涓�"婀栧寳鐪�"琛岀瓑浜� 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);
+            }
+        }
+    }
+}
diff --git a/traffic-audit-web/src/views/DataView.vue b/traffic-audit-web/src/views/DataView.vue
index cdf1f2f..d72dc3a 100644
--- a/traffic-audit-web/src/views/DataView.vue
+++ b/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">鍙e緞</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
+    },
     // 瑙勪笂瑙勪笅鎷嗗垎绛夎〃鏈�"鍙e緞"鍒楋紙褰撴湀/绱锛夛紝鎶婂畠鍋氭垚涓婃柟绛涢�夎�屼笉鏄〃鏍煎垪
     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%; }

--
Gitblit v1.9.1