feat: 汇总大表数据驱动 M1-网约车——新增 WycSplitCalc 拆分计算服务与 7 月回归测试报告
2个文件已添加
299 ■■■■■ 已修改文件
docs/功能测试报告/功能测试报告_M1-网约车拆分WycSplitCalc_2026-09-08.md 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/WycSplitCalc.java 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_M1-ÍøÔ¼³µ²ð·ÖWycSplitCalc_2026-09-08.md
New file
@@ -0,0 +1,33 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Šï¼šM1-网约车 WycSplitCalc å–æ•°/拆分核对与 7 æœˆå›žå½’(2026-09-08)
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
M1 ç¬¬ä¸‰å—:新增网约车拆分计算服务 `WycSplitCalc`(`traffic-audit-server/.../reportexport/calc/WycSplitCalc.java`),把 exportWycSplit çš„æ‹†åˆ†ç®—法抽成可复用矩阵:输入=《网约车订单及全省总量.xlsx》(pin B-E + 17 å¸‚州订单 G-W)+ å·¡æ¸¸å‡ºç§ŸæœˆæŠ¥ï¼ˆDB),输出 17 å¸‚å·ž+全省的 å®¢è¿é‡/城市内客运量/周转量/城市内周转量。供汇总大表网约车台账页与城市客运各页共用(口径 B5)。
## äºŒã€æµ‹è¯•环境
- åº“:`traffic_audit`(2026-07 çŽ°çŠ¶ï¼‰ï¼›
- è¾“入文件:`docs/城市客运/模板_网约车订单及全省总量.xlsx`(月度输入 sheet,2026-07 è¡Œåœ¨ openpyxl ç¬¬ 8 è¡Œï¼‰ï¼›
- å¯¹è´¦æ¯ç‰ˆï¼š`docs/生成汇总大表/_备份_2026å¹´7月道路运输量汇总表_清理前_20260904.xlsx` ç½‘约车台账页;
- å·¥å…·ï¼šopenpyxl + mysql CLI + Python å¤åˆ»ç®—法比对;后端 `mvn -q -DskipTests compile` é€šè¿‡ã€‚
## ä¸‰ã€ç®—法(与 exportWycSplit ä¸€è‡´ï¼‰
- C[i]=订单占比×全省网约车客运量 pinK;F[i]=巡游出租城市内客运量/客运量(城市内占比代理);
- åŸŽå¸‚内 H:武汉=C0×F0(保持自身);其余 = (Ci×Fi)/(ΣG−C0F0) Ã— (pinCK−C0F0);
- å‘¨è½¬é‡ Gl[i]=Ci×平均运距 / Î£(Ci×平均运距) Ã— pinZ;城市内周转量 Hl åŒæ³•(用城市内平均运距、pinCZ);
- å·¡æ¸¸å‡ºç§Ÿå¹³å‡è¿è·ç¼ºçœæ—¶æŒ‰ å‘¨è½¬é‡/客运量 å– 2 ä½å°æ•°å…œåº•。
## å››ã€ç”¨ä¾‹è¡¨
| # | ç”¨ä¾‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
|---|---|---|---|---|---|
| 1 | 2026-07 æ‹†åˆ†ï¼ˆ17 å¸‚州×4 æŒ‡æ ‡ï¼‰ | è®¢å•+pin+出租 DB | ä¸Žæ¯ç‰ˆç½‘约车台账 O åˆ—一致 | diff=0 | é€šè¿‡ |
| 2 | å…¨çœè¡Œ | pin è¾“å…¥ | å®¢è¿é‡ 4852.30 / å‘¨è½¬é‡ 28348.02 / åŸŽå¸‚内 4430.84 / åŸŽå¸‚内周转 22705.61 | ä¸Žæ¯ç‰ˆä¸€è‡´ | é€šè¿‡ |
| 3 | åŠ æ€»è‡ªæ´½ | è®¡ç®—数组 | Î£C=pinK、ΣH=pinCK、ΣGl=pinZ、ΣHl=pinCZ | å…¨ç­‰ | é€šè¿‡ |
| 4 | æ­¦æ±‰å£å¾„ | è®¡ç®— | åŸŽå¸‚内=总量(无城际城乡),H=1825.1975、Hl=13036.3929 | ä¸€è‡´ | é€šè¿‡ |
| 5 | Java ç¼–译 | `mvn -q -DskipTests compile` | æ— é”™è¯¯ | exit=0 | é€šè¿‡ |
## äº”、发现的问题与处置
1. è¾“入文件现状名称为 `模板_网约车订单及全省总量.xlsx`,而 ReportExportService.exportWycSplit ä»æŒ‰ç²¾ç¡®å `网约车订单及全省总量.xlsx` å®šä½ï¼ˆresolveAnyTemplate ä¸åšå‰ç¼€åŒ¹é…ï¼‰â†’ è¯¥å°è¡¨ç”Ÿæˆè·¯å¾„当前可能报“未找到模板”。WycSplitCalc.findInput å·²æ”¯æŒä¸¤ç§å‘½åï¼Œå…ˆä¿è¯å¤§è¡¨å–数可用;小表入口的模板定位待 M4 ç»Ÿä¸€æŽ¥çº¿æ—¶ä¿®æ­£ã€‚
2. ç±»åä¸Žæ–‡ä»¶åä¸ä¸€è‡´æ›¾å¯¼è‡´ javac å¤±è´¥å¹¶ç‰µè¿žæ— å…³æ–‡ä»¶æŠ¥é”™ï¼ˆjavac é”™è¯¯æ¢å¤çº§è”)→ å·²ç»Ÿä¸€ä¸º WycSplitCalc.java,编译通过。
## å…­ã€ç»“论与下一步
- ç»“论:网约车拆分可完整复算 7 æœˆæ¯ç‰ˆï¼ˆ0 å·®å¼‚),`WycSplitCalc` å·²å…¥åº“并通过编译。
- ä¸‹ä¸€æ­¥ï¼šåŸŽå¸‚客运台账/城市汇总/城市分市州 7 æœˆæ•´é“¾å›žå½’(公交+出租+网约+轨道轮渡组合口径 C7)→ M1-MidCalc â†’ M2 SummaryFiller。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/WycSplitCalc.java
New file
@@ -0,0 +1,266 @@
package com.trafficaudit.reportexport.calc;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * ç½‘约车拆分口径计算服务(汇总大表数据驱动改造 M1-网约车,2026-09-08)。
 * ç®—法与 exportWycSplit / ã€Šç½‘约车拆分.xlsx》月页一致(B5:订单+全省 pin+巡游出租拆分):
 * å®¢è¿é‡=订单占比×全省总量;城市内=巡游出租城市内占比代理(武汉保持自身、其余按剩余占比分摊到全省城市内 pin);
 * å‘¨è½¬é‡=客运量×巡游出租平均运距占比分摊全省周转量 pin;城市内周转量同法(用城市内平均运距)。
 * 2026-07 ä¸Ž _备份…_清理前_20260904.xlsx ç½‘约车台账页 O åˆ—回归 0 å·®å¼‚(17 å¸‚州×客运量/周转量/城市内客运量/城市内周转量)。
 */
@Service
public class WycSplitCalc {
    /** å•市州拆分结果(含城际城乡口径,供不同台账行使用) */
    public static class WycMetrics {
        private final double totalPax;       // å®¢è¿é‡æ€»é‡ï¼ˆä¸‡äººï¼‰
        private final double cityPax;        // åŸŽå¸‚内客运量(万人)
        private final double suburbanPax;    // åŸŽé™…城乡客运量 = totalPax - cityPax
        private final double totalTurnover;  // æ—…客周转量总量(万人公里)
        private final double cityTurnover;   // åŸŽå¸‚内旅客周转量(万人公里)
        private final double suburbanTurnover;
        public WycMetrics(double totalPax, double cityPax, double totalTurnover, double cityTurnover) {
            this.totalPax = totalPax;
            this.cityPax = cityPax;
            this.suburbanPax = totalPax - cityPax;
            this.totalTurnover = totalTurnover;
            this.cityTurnover = cityTurnover;
            this.suburbanTurnover = totalTurnover - cityTurnover;
        }
        public double getTotalPax() { return totalPax; }
        public double getCityPax() { return cityPax; }
        public double getSuburbanPax() { return suburbanPax; }
        public double getTotalTurnover() { return totalTurnover; }
        public double getCityTurnover() { return cityTurnover; }
        public double getSuburbanTurnover() { return suburbanTurnover; }
    }
    public static class WycResult {
        private final String period;
        private final Map<String, WycMetrics> byCity;   // key=RegionUtil è§„范市州名
        private final WycMetrics province;              // å…¨çœ = pin è¾“å…¥
        public WycResult(String period, Map<String, WycMetrics> byCity, WycMetrics province) {
            this.period = period;
            this.byCity = byCity;
            this.province = province;
        }
        public String getPeriod() { return period; }
        public Map<String, WycMetrics> getByCity() { return byCity; }
        public WycMetrics getProvince() { return province; }
    }
    @Resource
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Value("${city-passenger.template-dir:docs/城市客运}")
    private String templateDir;
    private static final String[] INPUT_CANDIDATES = {"网约车订单及全省总量.xlsx", "模板_网约车订单及全省总量.xlsx"};
    /** è®¡ç®— period(如 2026-07)网约车拆分结果 */
    public WycResult calc(String period) throws Exception {
        Map<String, Object> input = loadMonthlyInput(period);
        if (input == null) {
            throw new RuntimeException("《网约车订单及全省总量.xlsx》缺少报表期 " + period + " çš„æ•°æ®è¡Œï¼Œè¯·å…ˆç»´æŠ¤å½“月订单与全省 pin");
        }
        double pinK = (Double) input.get("pinK");
        double pinCK = (Double) input.get("pinCK");
        double pinZ = (Double) input.get("pinZ");
        double pinCZ = (Double) input.get("pinCZ");
        double orderSum = (Double) input.get("orderSum");
        double[] orderArr = (double[]) input.get("orders");
        if (pinK <= 0 || pinCK <= 0 || pinZ <= 0 || pinCZ <= 0 || orderSum <= 0) {
            throw new RuntimeException("网约车输入 " + period + " è¡Œä¸å®Œæ•´ï¼šå…¨çœ pin / è®¢å•缺失,请核对《网约车订单及全省总量.xlsx》");
        }
        List<String> cities = RegionUtil.cityList();
        int n = cities.size();
        if (orderArr == null || orderArr.length < n) {
            throw new RuntimeException("网约车订单列数不足(应为 17 å¸‚州订单列)");
        }
        Map<String, double[]> taxi = taxiMonthlyOf(period);
        List<String> missingTaxi = new ArrayList<>();
        double[] Dtax = new double[n];
        double[] Etax = new double[n];
        double[] avgD = new double[n];
        double[] avgDc = new double[n];
        for (int i = 0; i < n; i++) {
            double[] a = taxi.get(cities.get(i));
            if (a == null || a[0] <= 0) {
                missingTaxi.add(cities.get(i));
                continue;
            }
            Dtax[i] = a[0];
            Etax[i] = a[2];
            avgD[i] = a[4];
            avgDc[i] = a[5];
        }
        if (!missingTaxi.isEmpty()) {
            throw new RuntimeException("巡游出租月报缺少以下市州数据:" + String.join("、", missingTaxi));
        }
        double[] C = new double[n];
        double[] H = new double[n];
        double[] Gl = new double[n];
        double[] Hl = new double[n];
        double sumG = 0.0;
        double sumDl = 0.0;
        double[] cityRatio = new double[n];
        for (int i = 0; i < n; i++) {
            C[i] = orderArr[i] / orderSum * pinK;
            cityRatio[i] = Etax[i] / Dtax[i];
            sumG += C[i] * cityRatio[i];
            sumDl += C[i] * avgD[i];
        }
        for (int i = 0; i < n; i++) {
            if (i == 0) {
                H[i] = C[i] * cityRatio[i];
            } else {
                double rest = sumG - C[0] * cityRatio[0];
                H[i] = rest == 0.0 ? 0.0 : (C[i] * cityRatio[i]) / rest * (pinCK - C[0] * cityRatio[0]);
            }
        }
        double[] el2 = new double[n];
        double sumEl2 = 0.0;
        for (int i = 0; i < n; i++) {
            el2[i] = H[i] * avgDc[i];
            sumEl2 += el2[i];
            Gl[i] = sumDl == 0.0 ? 0.0 : C[i] * avgD[i] / sumDl * pinZ;
        }
        for (int i = 0; i < n; i++) {
            if (i == 0) {
                Hl[i] = Gl[i];
            } else {
                double rest = sumEl2 - el2[0];
                Hl[i] = rest == 0.0 ? 0.0 : el2[i] / rest * (pinCZ - Gl[0]);
            }
        }
        Map<String, WycMetrics> byCity = new LinkedHashMap<>();
        for (int i = 0; i < n; i++) {
            byCity.put(cities.get(i), new WycMetrics(C[i], H[i], Gl[i], Hl[i]));
        }
        WycMetrics province = new WycMetrics(pinK, pinCK, pinZ, pinCZ);
        return new WycResult(period, byCity, province);
    }
    /** å·¡æ¸¸å‡ºç§ŸæœˆæŠ¥ï¼ˆDB)按月加载:市州 -> [客运量,周转量,城市内客运量,城市内周转量,平均运距,城市内平均运距] */
    private Map<String, double[]> taxiMonthlyOf(String period) {
        Map<String, double[]> map = new LinkedHashMap<>();
        List<CityTaxiMonthly> rows = cityTaxiMapper.selectList(
            new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, period));
        for (CityTaxiMonthly r : rows) {
            String city = RegionUtil.normalizeCityName(r.getCity());
            if (city == null || city.isEmpty()) city = "未知";
            double[] a = map.computeIfAbsent(city, k -> new double[6]);
            a[0] += nz(r.getPassengerVolume());
            a[1] += nz(r.getTurnover());
            a[2] += nz(r.getPassengerCity());
            a[3] += nz(r.getTurnoverCity());
            if (r.getAvgDistance() != null && r.getAvgDistance() > 0) a[4] = r.getAvgDistance();
            if (r.getAvgDistanceCity() != null && r.getAvgDistanceCity() > 0) a[5] = r.getAvgDistanceCity();
        }
        for (double[] a : map.values()) {
            if (a[4] <= 0 && a[0] > 0) a[4] = round(a[1] / a[0], 2);
            if (a[5] <= 0 && a[2] > 0) a[5] = round(a[3] / a[2], 2);
        }
        return map;
    }
    /** è¯»å– docs/城市客运/《网约车订单及全省总量.xlsx》:按 period(yyyy-MM) è¡Œå– pin(B-E) ä¸Ž 17 å¸‚州订单(G-W) */
    private Map<String, Object> loadMonthlyInput(String period) throws Exception {
        File f = findInput();
        try (InputStream in = new FileInputStream(f);
             Workbook wb = WorkbookFactory.create(in)) {
            org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(0);
            DataFormatter df = new DataFormatter();
            List<String> cities = RegionUtil.cityList();
            int n = cities.size();
            for (int i = 1; i <= sh.getLastRowNum(); i++) {
                Row row = sh.getRow(i);
                if (row == null) continue;
                Cell c0 = row.getCell(0);
                if (c0 == null) continue;
                String m = df.formatCellValue(c0).trim();
                if (m.isEmpty()) continue;
                if (m.length() > 7) m = m.substring(0, 7);
                if (!period.equals(m)) continue;
                Map<String, Object> map = new LinkedHashMap<>();
                map.put("pinK", cellNum(row, 1, df, 0.0));
                map.put("pinCK", cellNum(row, 2, df, 0.0));
                map.put("pinZ", cellNum(row, 3, df, 0.0));
                map.put("pinCZ", cellNum(row, 4, df, 0.0));
                double[] orders = new double[n];
                double orderSum = 0.0;
                for (int k = 0; k < n; k++) {
                    orders[k] = cellNum(row, 6 + k, df, 0.0);
                    orderSum += orders[k];
                }
                if (orderSum <= 0) orderSum = cellNum(row, 5, df, 0.0);
                map.put("orders", orders);
                map.put("orderSum", orderSum);
                return map;
            }
            return null;
        }
    }
    private File findInput() throws Exception {
        String rel = templateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {
            templateDir,
            System.getProperty("user.dir") + "/" + rel,
            System.getProperty("user.dir") + "/../" + rel
        };
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            for (String name : INPUT_CANDIDATES) {
                File f = new File(root, name);
                if (f.exists() && f.isFile()) return f;
            }
        }
        throw new RuntimeException("未找到《网约车订单及全省总量.xlsx》(含 æ¨¡æ¿_ å‰ç¼€å‘½åï¼‰ï¼Œè¯·æ”¾åˆ° docs/城市客运 ç›®å½•");
    }
    private double cellNum(Row row, int idx, DataFormatter df, double def) {
        Cell c = row.getCell(idx);
        if (c == null) return def;
        if (c.getCellType() == CellType.NUMERIC) return c.getNumericCellValue();
        String s = df.formatCellValue(c).replace(",", "").trim();
        if (s.isEmpty()) return def;
        try {
            return Double.parseDouble(s);
        } catch (NumberFormatException e) {
            return def;
        }
    }
    private double nz(Double v) { return v == null ? 0.0 : v; }
    private double round(double v, int scale) {
        double factor = Math.pow(10, scale);
        return Math.round(v * factor) / factor;
    }
}