xyc
昨天 2b1af100a2402e65b44a929489754949198a19df
fix(holiday): 国省道环比基期改为纯备用值,不进环比计算
1个文件已添加
4个文件已修改
162 ■■■■■ 已修改文件
HANDOFF.md 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,26 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
## 最新(2026-10-06)
### 10-06:普通国省道「环比基期」改为人工备用值(不再参与计算)
- **背景**:10-04 鄂州樊家庄站中小客流量断崖下跌(35419 -> 8856),领导要求剔除。手工稿快报的做法是:先用剔站后的数值算出 10-04 环比,把 10-03 国省道值挪到「环比基期」C 列备用,再把 10-03 恢复原值以免影响累计。用户明确口径:**「环比基期」只用于数据备用,不参与任何计算**。
- **旧行为(错)**:`HolidayStatService.nationalProvincialRingRatio` 在 2026 年只要 `comparison_base` 有值就按「当日 / 环比基期 - 1」算国省道环比;导出时 E 列写 `=(D{行}-C{行})/C{行}`。按手工稿的 C 列摆法(基期放在被比日那行),这样算还会错位(10-03 环比被算成 +1.11%)。
- **新行为(本次已改)**:国省道环比固定「当日 / 前一日 - 1」,2024/2025/2026 一致;`comparison_base` 只在导入/导出/页面展示时原样保存与回写,不参与环比、折算、累计。导出 E 列改为 `=(D{行}-D{上一日行})/D{上一日行}`,C 列照常回写备用值。
- **改动**:`HolidayStatService.java`(`nationalProvincialRingRatio` 去掉 year/comparisonBase 形参)、`HolidayWorkbookService.java`(新增 `ringRatioFormula`,导出按上一行同年 D 列)、`docs/database.md`(`comparison_base` 与计算口径两处)。
- **验证**:新增 `HolidayComparisonBaseTest`:① 库内存 `comparison_base=320.2418` 时,10-04 国省道环比仍按前一日 323.7837 计算,且不等于按基期算出的值;② 导出公式 = `=(D6-D5)/D5`。`mvn -o test` 全绿(4 个测试类)。
### 10-06:待确认——10-04 国省道剔站(鄂州樊家庄)怎么进系统
- **手工口径已逐项核对,与库内 `observation_station_flow` 完全对得上**:
  - 10-04 国省道值(2026/2025/2024)= 原值 − 樊家庄当日值 -> 322.7695 / 337.5009 / 322.5277 万辆次(樊家庄 8856 / 4492 / 2328)。
  - 10-04 环比分母 = 2026-10-03 原值 − 樊家庄 10-03 值 = 323.7837 − 3.5419 = **320.2418** 万辆次 -> 环比 = 322.7695/320.2418 − 1 = **+0.79%**。
  - 10-03 显示值与累计仍用原值 323.7837,被剔的 320.2418 只落到 C 列备用。
- **趋势结论(需先跟领导对齐)**:只剔樊家庄,10-04 国省道环比只有 **+0.79%**;同日高速小客车 **+9.78%**、铁路 +9.94%、民航 +11.69%、道路 +4.99%、水路 +4.33%。**剔一站填不平约 9 个百分点的趋势差**,与「让国省道和高速趋势一致」的预期并不相符,建议同时请鄂州核实原始记录。
- **待决策的代码方案**:因 `comparison_base` 已定为纯备用,系统无法从「当日值」反推剔站基期,需要另设机制。推荐新增「异常站剔除」配置(站点编号 + 生效日期 + 年份),算环比分母时用 `observation_station_flow` 的「该日站点合计 − 剔站值」作基期;显示值与累计仍用原值。
- **决策点**:① 做成可配置的通用「异常站剔除」(推荐,可复用于其他节假日/站点),还是只按本次 10-04 一次性处理;② 剔站只影响国省道环比分母,还是也要下沉到非营业性小客车/公路/全社会的环比(手工稿实际把 4.17% / 4.55% 也一起改了)。
## 最新(2026-10-05)
### 10-05 上午:关机快照(当前状态)
docs/database.md
@@ -526,7 +526,7 @@
| road_category | varchar(32) | 高速公路 EXPRESSWAY / 普通国省道 NATIONAL_PROVINCIAL |
| vehicle_type | varchar(32) | 小客车 SMALL_PASSENGER / 中小客车 MEDIUM_SMALL_PASSENGER |
  | vehicle_count | decimal(30,15) | 车流量(万辆次) |
  | comparison_base | decimal(30,15) | 普通国省道环比基期(万辆次,仅当年使用) |
  | comparison_base | decimal(30,15) | 普通国省道环比基期(万辆次,人工备用值,仅 2026 年启用、不参与计算) |
  | created_at / updated_at | datetime | 创建/更新时间 |
唯一键:`(year, flow_date, road_category, vehicle_type)`。
@@ -544,7 +544,7 @@
### 计算与导入导出口径
  - 非营业性人员流动量 =(高速车流 + 普通国省道车流)× 当年节假日客运系数。
  - 2026 年普通国省道如填写环比基期,则只影响普通国省道车流量自身环比:填写时按“当日 / 环比基期 - 1”,未填写时按“当日 / 前一日 - 1”;折算人员流动量、总人员流动量等继续按 Excel 原公式计算。2024、2025 历史数据不使用该字段。
  - 普通国省道环比固定按“当日 / 前一日 - 1”计算;2024/2025/2026 各年一致。`comparison_base`(Excel 普通国省道表 C 列“环比基期”)自 2026-10-05 起仅作人工备用:导入导出原样保存/回写,不参与环比、折算或累计;导出时 C 列照常写出,E 列“环比”公式始终引用上一行同年 D 列。
  - 公路人员流动量 = 道路营业性客运量 + 非营业性人员流动量。
- 总人员流动量 = 公路 + 铁路 + 民航 + 水路。
- 导入按“年份 + 节假日分类”读取 `holiday_calendar` 中 `day_index>=1` 的日期,将工作簿“第N日”行覆盖写入当年客流/车流;同时读取当前年份铁路、民航的“前一日”行,写入 `day_index=0` 对应的日期。
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
@@ -102,7 +102,7 @@
            BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
            BigDecimal comparisonBase = comparisonBaseByDate.get(day.getHolidayDate());
            BigDecimal nationalProvincialRingRatio = nationalProvincialRingRatio(
                    year, nationalProvincial, previousNationalProvincial, comparisonBase);
                    nationalProvincial, previousNationalProvincial);
            BigDecimal nonBusinessPersonFlow = null;
            if (coefficient != null && (expressway != null || nationalProvincial != null)) {
                nonBusinessPersonFlow = HolidayNumberUtils.multiply(
@@ -252,12 +252,11 @@
        return total;
    }
    private BigDecimal nationalProvincialRingRatio(int year, BigDecimal current,
                                                   BigDecimal previous, BigDecimal comparisonBase) {
    // The national/provincial ring ratio always compares the current day with the previous day of
    // the same year. The stored comparison base (Excel column C) is kept as an operator backup
    // value only and never feeds this calculation.
    private BigDecimal nationalProvincialRingRatio(BigDecimal current, BigDecimal previous) {
        if (current == null) return null;
        if (HolidayConstants.supportsComparisonBase(year) && comparisonBase != null) {
            return ratio(current, comparisonBase);
        }
        return previous == null ? null : ratio(current, previous);
    }
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
@@ -568,6 +568,9 @@
                setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
            }
            if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
                // Column C (comparison base) is written as an operator backup value only. The ring
                // ratio in column E always compares the current day with the previous day row of
                // the same year, so the base never changes the calculated percentage.
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
@@ -575,16 +578,27 @@
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
                }
                Integer previousRowIndex = null;
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    int rowIndex = row.getRowNum();
                    int excelRow = rowIndex + 1;
                    setFormula(sheet, rowIndex, 4, "=(D" + excelRow + "-C" + excelRow + ")/C" + excelRow);
                    if (row == null) {
                        previousRowIndex = null;
                        continue;
                    }
                    if (previousRowIndex != null) {
                        setFormula(sheet, row.getRowNum(), 4,
                                ringRatioFormula(row.getRowNum() + 1, previousRowIndex + 1));
                    }
                    previousRowIndex = row.getRowNum();
                }
            }
        }
    }
    // Ring ratio = (current day value - previous day value) / previous day value, always taken
    // from the active year column (D). This intentionally ignores the comparison base column.
    static String ringRatioFormula(int excelRow, int previousExcelRow) {
        return "=(D" + excelRow + "-D" + previousExcelRow + ")/D" + previousExcelRow;
    }
    private void fillCoefficients(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
@@ -1013,7 +1027,9 @@
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        cell.setCellFormula(formula);
        // POI's setCellFormula rejects a leading '='; the helper accepts either form.
        String normalized = formula.startsWith("=") ? formula.substring(1) : formula;
        cell.setCellFormula(normalized);
    }
    private Map<String, Object> toMap(HolidayCalendar calendar) {
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
New file
@@ -0,0 +1,99 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
 * The Excel comparison base column (C) is an operator backup value only: it must never change the
 * national/provincial ring ratio. This locks in that behaviour.
 */
class HolidayComparisonBaseTest {
    @Test
    void ringRatioUsesPreviousDayEvenWhenComparisonBaseIsStored() throws Exception {
        HolidayConfigService configService = mock(HolidayConfigService.class);
        HolidayPassengerFlowMapper passengerMapper = mock(HolidayPassengerFlowMapper.class);
        HolidayVehicleFlowMapper vehicleMapper = mock(HolidayVehicleFlowMapper.class);
        when(configService.coefficientOf(2026, HolidayConstants.CATEGORY_HOLIDAY)).thenReturn(new BigDecimal("2.4"));
        when(passengerMapper.selectList(any())).thenReturn(new ArrayList<HolidayPassengerFlow>());
        when(vehicleMapper.selectList(any())).thenReturn(vehicleRows());
        HolidayStatService service = new HolidayStatService();
        inject(service, "configService", configService);
        inject(service, "passengerFlowMapper", passengerMapper);
        inject(service, "vehicleFlowMapper", vehicleMapper);
        List<Map<String, Object>> rows = service.buildOverviewRows(2026, HolidayConstants.TYPE_NATIONAL_DAY, calendar());
        BigDecimal ringRatio = (BigDecimal) rows.get(3).get("nationalProvincialRingRatio");
        assertEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("323.7837")), ringRatio);
        assertNotEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("320.2418")), ringRatio);
        assertEquals(new BigDecimal("320.2418"), rows.get(2).get("comparisonBase"));
    }
    @Test
    void exportFormulaComparesPreviousRowInsteadOfComparisonBaseColumn() {
        assertEquals("=(D6-D5)/D5", HolidayWorkbookService.ringRatioFormula(6, 5));
    }
    private static BigDecimal ratio(BigDecimal current, BigDecimal base) {
        return HolidayNumberUtils.normalize(HolidayNumberUtils.divide(current, base).subtract(BigDecimal.ONE));
    }
    private static List<HolidayVehicleFlow> vehicleRows() {
        List<HolidayVehicleFlow> rows = new ArrayList<>();
        rows.add(vehicle("2026-10-03", "323.7837", "320.2418"));
        rows.add(vehicle("2026-10-04", "322.7695", null));
        return rows;
    }
    private static HolidayVehicleFlow vehicle(String date, String value, String base) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(2026);
        row.setFlowDate(LocalDate.parse(date));
        row.setRoadCategory(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        row.setVehicleType(HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER);
        row.setVehicleCount(new BigDecimal(value));
        row.setComparisonBase(base == null ? null : new BigDecimal(base));
        return row;
    }
    private static List<HolidayCalendar> calendar() {
        List<HolidayCalendar> days = new ArrayList<>();
        for (int i = 1; i <= 4; i++) {
            HolidayCalendar day = new HolidayCalendar();
            day.setYear(2026);
            day.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            day.setDayIndex(i);
            day.setHolidayDate(LocalDate.of(2026, 10, i));
            days.add(day);
        }
        return days;
    }
    private static void inject(Object target, String field, Object value) throws Exception {
        Field declared = target.getClass().getDeclaredField(field);
        declared.setAccessible(true);
        declared.set(target, value);
    }
}