xyc
4 天以前 67cd940347bfe38ef6aab075a2dbd738bec41ed3
feat(holiday): support 2019 and previous-day data in new templates
2个文件已添加
15个文件已修改
413 ■■■■■ 已修改文件
HANDOFF.md 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_previous_day_migration.sql 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_previous_day_migration.sql 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/SystemConfig.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -6,6 +6,17 @@
## æœ€æ–°ï¼ˆ2026-10-03)
### 10-03 æ™šï¼šé‡è¦èŠ‚å‡æ—¥æ–°æ¨¡æ¿ï¼ˆ2019/前一日)适配
- **模板变化**:`2026年十一数据.xlsx` çš„道路表增加 2019 å¹´åŠåŒæ¯”2019;铁路、民航增加“前一日”行;快报模板增加同比2024 åˆ—。旧模板已放在 `docs/专项统计/重要节假日/备份/`。
- **数据库初始化**:启动时直接写入 2019 é“路数据,以及 2024/2025/2026 å¹´é“è·¯ã€æ°‘航前一日数据;`holiday_calendar.day_index=0` è¡¨ç¤ºâ€œå‰ä¸€æ—¥â€ï¼Œæ—§åº“启动会自动补齐已有年份的前一日配置行,且不覆盖管理员已维护的日期。
- **正常导入口径**:只读取所选年份的当年列(2026 å¹´é“è·¯/民航/道路/水路为 C åˆ—、高速为 C åˆ—、普通国省道为 D åˆ—);同时只额外读取当前年份铁路/民航的“前一日”行。2019/2024/2025 åŽ†å²åˆ—ä¸å‚ä¸Žå¯¼å…¥ã€‚
- **快报口径**:同比只使用 2025、2024;假期第 1 æ—¥çš„铁路、民航环比使用“前一日”数据,公路、水路、总计及其他口径第 1 æ—¥ä»æ˜¾ç¤ºâ€œâ€””;第 2 æ—¥èµ·æ­£å¸¸è®¡ç®—环比。
- **迁移脚本**:`docs/sql_holiday_previous_day_migration.sql`,部署包同步为 `deploy-templates/db/sql_holiday_previous_day_migration.sql`,脚本可重复执行。
- **验证(本机 3305 åº“ + çœŸå®žæ¨¡æ¿ï¼‰**:接口导入结果 `passengerRows=10, vehicleRows=4, successRows=14, failRows=0`;数据库抽查 2026-09-30 é“è·¯=82.1、民航=6.6994,2026-10-01 é“è·¯=113.8、民航=6.7482、道路=78.82、水路=1.311,2026-10-02 é“è·¯=82.0、民航=6.3914、道路=75.61、水路=3.3373;2026-10-01 å¿«æŠ¥é“è·¯çŽ¯æ¯”=+38.6%、民航环比=+0.7%,总行环比为“—”;2026-10-02 é“è·¯çŽ¯æ¯”=-27.9%、民航环比=-5.3%。
- **运行状态**:后端 8090(重启后已加载新代码)、前端 8080 å‡åœ¨è¿è¡Œï¼›è®¿é—® `http://localhost:8080`。
---
### è¡¥å……(10-03 ä¸‹åˆï¼‰ï¼šæœåŠ¡å™¨éƒ¨ç½²æŠ¥ â€œAccess denied ... (using password: NO)” â€”— æ ¹å› ä¸Žä¿®å¤
- **现象**:把 `deploy/traffic-audit-release-20261002/` éƒ¨ç½²åˆ° `61.183.254.94` åŽï¼ŒåŽç«¯å¯åŠ¨æŠ¥ `java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)`。
- **根因**:jar å†…置配置里库口令是占位符 `${TRAFFIC_DB_PASSWORD:}`(默认空串),而 10-02 é‚£ä¸ªåŒ…**只含 jar + web + dist**,没有外置 `application.yml`、没有 `docs/`、`db/`、启动脚本。部署机没有任何外部配置覆盖,Spring å°±æ‹¿ç©ºå¯†ç è¿žæœ¬æœº MySQL â†’ `using password: NO`(注意:这是“末传密码”,不是密码错)。
deploy-templates/README-²¿Êð˵Ã÷.txt
@@ -39,6 +39,7 @@
    sql_holiday.sql                          ä¸“项统计-重要节假日表(旧库缺表时执行)
    sql_holiday_decimal_migration.sql        èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
  README-部署说明.txt
【部署机环境要求】
@@ -68,7 +69,7 @@
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_bus.sql
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_taxi.sql
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_wyc.sql
     æ³¨ï¼šå…¨æ–°åº“çš„ init.sql å·²å«èŠ‚å‡æ—¥ 4 å¼ è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“上补节假日模块,再执行 db/sql_holiday.sql,并按需执行两个迁移脚本。
     æ³¨ï¼šå…¨æ–°åº“çš„ init.sql å·²å«èŠ‚å‡æ—¥ 4 å¼ è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“上补节假日模块,再执行 db/sql_holiday.sql,并按需执行三个迁移脚本(数值精度、环比基期、前一日配置)。前一日迁移脚本可重复执行。
     é¢„置账号:admin / admin123(首次登录后请改密码)
  2. æ”¹é…ç½® backend/application.yml
deploy-templates/db/init.sql
@@ -632,7 +632,7 @@
  year INT NOT NULL COMMENT '年份',
  holiday_type VARCHAR(32) NOT NULL COMMENT '节假日分类:SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
  holiday_date DATE NOT NULL COMMENT '节假日具体日期',
  day_index INT NOT NULL COMMENT '第几日,从 1 å¼€å§‹',
  day_index INT NOT NULL COMMENT '0=前一日,1..N=假期第N日',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
deploy-templates/db/sql_holiday.sql
@@ -4,6 +4,7 @@
  year INT NOT NULL,
  holiday_type VARCHAR(32) NOT NULL,
  holiday_date DATE NOT NULL,
  -- 0 is the configured previous day; normal holiday days are 1..N.
  day_index INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deploy-templates/db/sql_holiday_previous_day_migration.sql
New file
@@ -0,0 +1,8 @@
-- Add the previous-day row used by rail and civil-aviation ring-ratio calculations.
-- day_index = 0 identifies the configured previous day; normal holiday days remain 1..N.
INSERT IGNORE INTO holiday_calendar
  (year, holiday_type, holiday_date, day_index, created_at, updated_at)
VALUES
  (2024, 'NATIONAL_DAY', '2024-09-30', 0, NOW(), NOW()),
  (2025, 'NATIONAL_DAY', '2025-09-30', 0, NOW(), NOW()),
  (2026, 'NATIONAL_DAY', '2026-09-30', 0, NOW(), NOW());
docs/database.md
@@ -491,7 +491,7 @@
## ä¹ã€ä¸“项统计-重要节假日模块
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ï¼Œå¹¶åˆå§‹åŒ– 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ã€2024-2026 å¹´é“è·¯/民航前一日数据、2019 å¹´é“路数据,并初始化 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
### holiday_calendar èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
@@ -500,10 +500,10 @@
| year | int | å¹´ä»½ |
| holiday_type | varchar(32) | æ˜¥èŠ‚ SPRING_FESTIVAL / äº”一 LABOR_DAY / åä¸€ NATIONAL_DAY |
| holiday_date | date | èŠ‚å‡æ—¥å…·ä½“æ—¥æœŸ |
| day_index | int | ç¬¬å‡ æ—¥ï¼Œä»Ž 1 å¼€å§‹ |
| day_index | int | 0=前一日,1..N=假期第 N æ—¥ |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护。
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护;`day_index=0` ç”¨äºŽé“è·¯ã€æ°‘航的前一日环比基数。
### holiday_passenger_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
@@ -547,6 +547,8 @@
  - 2026 å¹´æ™®é€šå›½çœé“如填写环比基期,则只影响普通国省道车流量自身环比:填写时按“当日 / çŽ¯æ¯”åŸºæœŸ - 1”,未填写时按“当日 / å‰ä¸€æ—¥ - 1”;折算人员流动量、总人员流动量等继续按 Excel åŽŸå…¬å¼è®¡ç®—ã€‚2024、2025 åŽ†å²æ•°æ®ä¸ä½¿ç”¨è¯¥å­—æ®µã€‚
  - å…¬è·¯äººå‘˜æµåŠ¨é‡ = é“路营业性客运量 + éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ã€‚
- æ€»äººå‘˜æµåŠ¨é‡ = å…¬è·¯ + é“è·¯ + æ°‘航 + æ°´è·¯ã€‚
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;日常只读取所选年份的当年列,历史年份仅首次初始化。
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` ä¸­ `day_index>=1` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;同时读取当前年份铁路、民航的“前一日”行,写入 `day_index=0` å¯¹åº”的日期。
- æ—¥å¸¸å¯¼å…¥åªè¯»å–所选年份的当年列(道路表的 2019/2024/2025 åˆ—均不读取);2019 å¹´é“路数据和 2024/2025/2026 å¹´å‰ä¸€æ—¥é“è·¯ã€æ°‘航数据由启动初始化直接写入数据库。
- å¯¼å…¥å‰æ£€æŸ¥æ¯”本次最新导入日更早的历史日期是否发生变化;如有变化先不写库,返回差异清单,用户确认后才允许覆盖。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word å¹¶æ›¿æ¢æ¨¡æ¿ä¸­çš„当日、累计、环比和同比内容。
- è®°å½•“前一日”的铁路、民航数据后,假期第 1 æ—¥çš„铁路、民航环比按该数据计算;公路、水路、总人员流动量在第 1 æ—¥ä»ä¸è®¡ç®—环比。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word,同比固定使用 2025、2024 ä¸¤å¹´ï¼Œä¸ä½¿ç”¨ 2019 æ•°æ®ã€‚
docs/init.sql
@@ -632,7 +632,7 @@
  year INT NOT NULL COMMENT '年份',
  holiday_type VARCHAR(32) NOT NULL COMMENT '节假日分类:SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
  holiday_date DATE NOT NULL COMMENT '节假日具体日期',
  day_index INT NOT NULL COMMENT '第几日,从 1 å¼€å§‹',
  day_index INT NOT NULL COMMENT '0=前一日,1..N=假期第N日',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
docs/sql_holiday_previous_day_migration.sql
New file
@@ -0,0 +1,8 @@
-- Add the previous-day row used by rail and civil-aviation ring-ratio calculations.
-- day_index = 0 identifies the configured previous day; normal holiday days remain 1..N.
INSERT IGNORE INTO holiday_calendar
  (year, holiday_type, holiday_date, day_index, created_at, updated_at)
VALUES
  (2024, 'NATIONAL_DAY', '2024-09-30', 0, NOW(), NOW()),
  (2025, 'NATIONAL_DAY', '2025-09-30', 0, NOW(), NOW()),
  (2026, 'NATIONAL_DAY', '2026-09-30', 0, NOW(), NOW());
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -73,7 +73,8 @@
│  â”œâ”€ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_holiday.sql                           ä¸“项统计-重要节假日表(旧库补建时执行)
│  â”œâ”€ sql_holiday_decimal_migration.sql         èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â””─ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â””─ sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
└─ README-部署说明.txt
```
@@ -94,7 +95,7 @@
```
> âš ï¸ **`init.sql` æ˜¯ç ´åæ€§çš„**:里面的基础表都带 `DROP TABLE IF EXISTS`。**已有数据的库上千万不要再跑 init.sql**,否则表数据会被清空重建。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日 4 å¼ è¡¨ï¼Œæ‰§è¡Œ `sql_holiday.sql`;若已有节假日表但缺精度或环比基期字段,按需执行两个迁移脚本。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日 4 å¼ è¡¨ï¼Œæ‰§è¡Œ `sql_holiday.sql`;若已有节假日表但缺精度、环比基期字段或前一日配置,按需执行 `sql_holiday_decimal_migration.sql`、`sql_holiday_comparison_base_migration.sql`、`sql_holiday_previous_day_migration.sql`。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 33 å¼ è¡¨ï¼ˆinit.sql 28 å¼ ï¼Œå…¶ä¸­å«èŠ‚å‡æ—¥ 4 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
#### è·¯çº¿ B:把现网/开发机的数据整套搬过去(推荐,不用跑脚本)
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -1,6 +1,7 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -10,6 +11,7 @@
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.annotation.Resource;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
@@ -43,6 +45,8 @@
    private String bulletinTemplatePath;
    private final HolidayStatService statService;
    @Resource
    private HolidayConfigService configService;
    private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
    public HolidayBulletinService(HolidayStatService statService) {
@@ -56,9 +60,16 @@
        }
        DailyMetrics previousDay = current.getDayIndex() > 1
                ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : null;
        HolidayCalendar previousDayConfig = configService.findPreviousDay(current.getYear(), current.getHolidayType());
        LocalDate previousDayDate = previousDayConfig == null || previousDayConfig.getHolidayDate() == null
                ? current.getDate().minusDays(1) : previousDayConfig.getHolidayDate();
        DailyMetrics previousCalendarDay = previousDay != null
                ? previousDay : statService.dailyMetricsByDate(previousDayDate);
        DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        DailyMetrics twoYearsAgo = statService.dailyMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
        DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex());
        DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        DailyMetrics twoYearsAgoCumulative = statService.cumulativeMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
        if (current.getTotalPersonFlow() == null) {
            throw new IllegalArgumentException("所选日期的全社会跨区域人员流动量没有足够数据,无法生成快报");
@@ -69,14 +80,17 @@
                ? null : HolidayNumberUtils.divide(currentCumulative.getTotalPersonFlow(), BigDecimal.valueOf(10000)));
        String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase(
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow());
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s。",
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s,比2024年同期%s。",
                date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
                ringRatioClause,
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()));
        String paragraph2 = String.format("假期以来,全省全社会跨区域人员流动量累计%s亿人次,比2025年同期%s。",
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()),
                ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow()));
        String paragraph2 = String.format("假期以来,全省全社会跨区域人员流动量累计%s亿人次,比2025年同期%s,比2024年同期%s。",
                cumulativeTotalYi,
                ratioPhrase(currentCumulative.getTotalPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()),
                ratioPhrase(currentCumulative.getTotalPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
@@ -94,7 +108,8 @@
            try {
                replaceParagraph(document, text -> text.startsWith("10月") && text.contains("假期第"), paragraph1);
                replaceParagraph(document, text -> text.startsWith("假期以来"), paragraph2);
                fillTable(document, current, previousDay, previousYear, currentCumulative, previousYearCumulative);
                fillTable(document, current, previousDay, previousCalendarDay, previousYear, twoYearsAgo,
                        currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
                byte[] replacedXml = serializeDocument(document);
                return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
            } finally {
@@ -106,8 +121,11 @@
    }
    private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
                           DailyMetrics previousYear, DailyMetrics currentCumulative,
                           DailyMetrics previousYearCumulative) {
                           DailyMetrics previousCalendarDay, DailyMetrics previousYear, DailyMetrics twoYearsAgo,
                           DailyMetrics currentCumulative, DailyMetrics previousYearCumulative,
                           DailyMetrics twoYearsAgoCumulative) {
        DailyMetrics firstDayRailBase = previousDay == null ? previousCalendarDay : previousDay;
        DailyMetrics firstDayCivilAviationBase = previousDay == null ? previousCalendarDay : previousDay;
        Element table = firstElement(document, "tbl");
        if (table == null) {
            throw new IllegalArgumentException("快报模板中找不到数据表");
@@ -116,54 +134,74 @@
                number(current.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()),
                "—",
                ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow()),
                number(currentCumulative.getNonBusinessPersonFlow()),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()),
                "—"
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow())
        });
        writeTableRow(table, 3, new String[]{
                number(current.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()),
                "—",
                ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad()),
                number(currentCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()),
                "—"
                ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad())
        });
        writeTableRow(table, 4, new String[]{
                number(current.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()),
                "—",
                ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow()),
                number(currentCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                "—"
                ratio(currentCumulative.getRoadPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow())
        });
        writeTableRow(table, 5, transportRow(current.getRail(), previousDay == null ? null : previousDay.getRail(),
                previousYear == null ? null : previousYear.getRail(), currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail()));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(), previousDay == null ? null : previousDay.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(), currentCumulative.getCivilAviation(),
                previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation()));
        writeTableRow(table, 7, transportRow(current.getWater(), previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(), currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(), currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
        writeTableRow(table, 5, transportRow(current.getRail(),
                firstDayRailBase == null ? null : firstDayRailBase.getRail(),
                previousYear == null ? null : previousYear.getRail(),
                twoYearsAgo == null ? null : twoYearsAgo.getRail(),
                currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRail()));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(),
                firstDayCivilAviationBase == null ? null : firstDayCivilAviationBase.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(),
                twoYearsAgo == null ? null : twoYearsAgo.getCivilAviation(),
                currentCumulative.getCivilAviation(),
                previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getCivilAviation()));
        writeTableRow(table, 7, transportRow(current.getWater(),
                previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(),
                twoYearsAgo == null ? null : twoYearsAgo.getWater(),
                currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getWater()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(),
                previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(),
                twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
                currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
    }
    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
                                  BigDecimal currentCumulative, BigDecimal previousYearCumulative) {
                                  BigDecimal twoYearsAgo, BigDecimal currentCumulative,
                                  BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative) {
        return new String[]{
                number(current),
                ratio(current, previousDay),
                ratio(current, previousYear),
                "—",
                ratio(current, twoYearsAgo),
                number(currentCumulative),
                ratio(currentCumulative, previousYearCumulative),
                "—"
                ratio(currentCumulative, twoYearsAgoCumulative)
        };
    }
@@ -175,7 +213,7 @@
        int logicalColumn = 0;
        for (Element cell : cells) {
            int span = gridSpan(cell);
            if (logicalColumn >= 2 && logicalColumn < 9) {
            if (logicalColumn >= 2) {
                int valueIndex = logicalColumn - 2;
                if (valueIndex < values.length) {
                    setCellText(cell, values[valueIndex]);
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
@@ -39,7 +39,16 @@
        return holidayCalendarMapper.selectList(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .ge(HolidayCalendar::getDayIndex, 1)
                .orderByAsc(HolidayCalendar::getDayIndex));
    }
    public HolidayCalendar findPreviousDay(int year, String holidayType) {
        return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .eq(HolidayCalendar::getDayIndex, 0)
                .last("limit 1"));
    }
    public HolidayCalendar findHolidayByDate(LocalDate date) {
@@ -65,8 +74,8 @@
        if (holidayDate == null || holidayDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (dayIndex == null || dayIndex < 1) {
            throw new IllegalArgumentException("第几日必须从 1 å¼€å§‹");
        if (dayIndex == null || dayIndex < 0) {
            throw new IllegalArgumentException("日期序号不能小于 0");
        }
        LambdaQueryWrapper<HolidayCalendar> duplicate = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
@@ -138,16 +138,24 @@
            }
        }
        if (target == null) return null;
        return dailyMetrics(year, holidayType, dayIndex, target.getHolidayDate());
    }
    public DailyMetrics dailyMetricsByDate(LocalDate date) {
        if (date == null) return null;
        return dailyMetrics(date.getYear(), null, 0, date);
    }
    private DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex, LocalDate date) {
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(date, Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(date, Collections.emptyMap());
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
        BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        BigDecimal comparisonBase = comparisonBaseByDate.get(target.getHolidayDate());
        BigDecimal comparisonBase = comparisonBaseByDate.get(date);
        BigDecimal nonBusiness = null;
        if (coefficient != null && (expressway != null || nationalProvincial != null)) {
            nonBusiness = HolidayNumberUtils.multiply(
@@ -158,7 +166,7 @@
        metrics.setYear(year);
        metrics.setHolidayType(holidayType);
        metrics.setDayIndex(dayIndex);
        metrics.setDate(target.getHolidayDate());
        metrics.setDate(date);
        metrics.setRail(passenger.get(HolidayConstants.MODE_RAIL));
        metrics.setCivilAviation(passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
        metrics.setRoad(passenger.get(HolidayConstants.MODE_ROAD));
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
@@ -46,6 +46,7 @@
    private static final Map<String, String> PASSENGER_SHEET_MODE = new LinkedHashMap<>();
    private static final int FIRST_DATA_ROW_INDEX = 2;
    private static final int DAY_ROW_SCAN_LIMIT = 30;
    private static final int[] REFERENCE_YEARS = {2024, 2025, 2026};
    static {
@@ -115,7 +116,7 @@
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            importPassengerSheets(workbook, evaluator, year, dateByDay, summary);
            importPassengerSheets(workbook, evaluator, year, holidayType, dateByDay, summary);
            importVehicleSheets(workbook, evaluator, year, dateByDay, summary);
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
@@ -161,7 +162,7 @@
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
            Row row = findDayRow(sheet, evaluator, dayIndex);
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) return true;
@@ -174,7 +175,7 @@
        if (expressway != null) {
            Integer yearColumn = findYearColumn(expressway, evaluator, year, 2, 7);
            if (yearColumn != null) {
                Row row = expressway.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                Row row = findDayRow(expressway, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
@@ -182,7 +183,7 @@
        if (nationalProvincial != null) {
            Integer yearColumn = findYearColumn(nationalProvincial, evaluator, year, 3, 8);
            if (yearColumn != null) {
                Row row = nationalProvincial.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                Row row = findDayRow(nationalProvincial, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
@@ -200,7 +201,7 @@
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                if (day.getKey() >= latestDay) continue;
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
                if (incoming == null) continue;
@@ -236,7 +237,7 @@
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
            if (incoming == null) continue;
@@ -261,7 +262,7 @@
            if (day.getKey() >= latestDay) continue;
            Map<String, BigDecimal> existingDay = existingVehicle.get(day.getValue());
            if (existingDay == null || existingDay.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL) == null) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming;
            try {
@@ -278,8 +279,8 @@
        }
    }
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
    private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
        for (HolidayPassengerFlow row : rows) {
@@ -337,6 +338,7 @@
        try (InputStream in = new FileInputStream(reference); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            seedNationalDayCalendar();
            ensurePreviousDayCalendarIfMissing();
            for (int year : REFERENCE_YEARS) {
                if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)) == 0) {
@@ -351,6 +353,8 @@
                    seedCoefficientYear(workbook, evaluator, year);
                }
            }
            seedPreviousDayPassengerIfMissing(workbook, evaluator);
            seedRoad2019IfMissing(workbook, evaluator);
        } catch (Exception e) {
            log.error("Failed to seed holiday reference data", e);
        }
@@ -381,7 +385,8 @@
    }
    private void importPassengerSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
                                       String holidayType, Map<Integer, LocalDate> dateByDay,
                                       ImportSummary summary) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) {
@@ -394,7 +399,7 @@
                continue;
            }
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) {
                    summary.addError(entry.getKey() + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                    continue;
@@ -418,6 +423,39 @@
                upsertPassenger(year, day.getValue(), entry.getValue(), value);
                summary.incrementPassenger();
            }
        }
        importPreviousDayPassengerRows(workbook, evaluator, year, holidayType, dateByDay, summary);
    }
    private void importPreviousDayPassengerRows(Workbook workbook, FormulaEvaluator evaluator, int year,
                                                 String holidayType, Map<Integer, LocalDate> dateByDay,
                                                 ImportSummary summary) {
        LocalDate previousDate = previousDayDate(year, holidayType, dateByDay);
        if (previousDate == null) return;
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
                    && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                continue;
            }
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = findPreviousDayRow(sheet, evaluator);
            if (row == null) continue;
            BigDecimal value;
            try {
                value = numberValue(row.getCell(yearColumn), evaluator);
            } catch (Exception e) {
                summary.addError(entry.getKey() + " å‰ä¸€æ—¥è¡Œï¼š" + e.getMessage());
                continue;
            }
            if (value == null) {
                summary.incrementSkipped();
                continue;
            }
            upsertPassenger(year, previousDate, entry.getValue(), value);
            summary.incrementPassenger();
        }
    }
@@ -445,7 +483,7 @@
            return;
        }
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) {
                summary.addError(sheetName + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                continue;
@@ -491,7 +529,17 @@
                Map<LocalDate, BigDecimal> values = loadPassengerValues(year, entry.getValue());
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!values.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
                }
                LocalDate previousDate = previousDayDate(year, holidayType, dayDates);
                Row previousRow = findPreviousDayRow(sheet, evaluator);
                if (previousDate != null && previousRow != null) {
                    BigDecimal previousValue = values.get(previousDate);
                    if (previousValue != null) {
                        setNumeric(sheet, previousRow.getRowNum(), yearColumn, previousValue);
                    }
                }
            }
        }
@@ -515,17 +563,23 @@
            Map<LocalDate, BigDecimal> values = loadVehicleValues(year, roadCategory);
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                if (!values.containsKey(day.getValue())) continue;
                setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
            }
            if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, comparisonBaseColumn, bases.get(day.getValue()));
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
                }
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    int rowIndex = FIRST_DATA_ROW_INDEX + day.getKey() - 1;
                    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);
                }
@@ -579,6 +633,84 @@
                calendar.setUpdatedAt(LocalDateTime.now());
                configService.saveHoliday(toMap(calendar));
            }
            HolidayCalendar previousDay = new HolidayCalendar();
            previousDay.setYear(year);
            previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            previousDay.setHolidayDate(LocalDate.of(year, 10, 1).minusDays(1));
            previousDay.setDayIndex(0);
            previousDay.setCreatedAt(LocalDateTime.now());
            previousDay.setUpdatedAt(LocalDateTime.now());
            configService.saveHoliday(toMap(previousDay));
        }
    }
    private void ensurePreviousDayCalendarIfMissing() {
        for (int year : REFERENCE_YEARS) {
            if (configService.findPreviousDay(year, HolidayConstants.TYPE_NATIONAL_DAY) != null) {
                continue;
            }
            LocalDate firstHolidayDate = firstDate(dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY));
            if (firstHolidayDate == null) {
                continue;
            }
            HolidayCalendar previousDay = new HolidayCalendar();
            previousDay.setYear(year);
            previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            previousDay.setHolidayDate(firstHolidayDate.minusDays(1));
            previousDay.setDayIndex(0);
            previousDay.setCreatedAt(LocalDateTime.now());
            previousDay.setUpdatedAt(LocalDateTime.now());
            configService.saveHoliday(toMap(previousDay));
        }
    }
    private void seedPreviousDayPassengerIfMissing(Workbook workbook, FormulaEvaluator evaluator) {
        for (int year : REFERENCE_YEARS) {
            Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
            LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
            if (previousDate == null) continue;
            for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
                if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
                        && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                    continue;
                }
                if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)
                        .eq(HolidayPassengerFlow::getFlowDate, previousDate)
                        .eq(HolidayPassengerFlow::getTransportMode, entry.getValue())) > 0) {
                    continue;
                }
                Sheet sheet = workbook.getSheet(entry.getKey());
                if (sheet == null) continue;
                Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
                if (yearColumn == null) continue;
                Row row = findPreviousDayRow(sheet, evaluator);
                if (row == null) continue;
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null) upsertPassenger(year, previousDate, entry.getValue(), value);
            }
        }
    }
    private void seedRoad2019IfMissing(Workbook workbook, FormulaEvaluator evaluator) {
        if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                .eq(HolidayPassengerFlow::getYear, 2019)
                .eq(HolidayPassengerFlow::getTransportMode, HolidayConstants.MODE_ROAD)) > 0) {
            return;
        }
        Sheet sheet = workbook.getSheet("道路");
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, 2019, 2, 20);
        if (yearColumn == null) return;
        for (int rowIndex = 0; rowIndex <= Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT); rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            Integer dayIndex = parseDayIndex(row.getCell(0), evaluator);
            if (dayIndex == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
                upsertPassenger(2019, LocalDate.of(2019, 10, dayIndex), HolidayConstants.MODE_ROAD, value);
            }
        }
    }
@@ -591,11 +723,22 @@
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null) {
                    upsertPassenger(year, day.getValue(), entry.getValue(), value);
                }
            }
            if (HolidayConstants.MODE_RAIL.equals(entry.getValue())
                    || HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                Row previousRow = findPreviousDayRow(sheet, evaluator);
                LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
                if (previousRow != null && previousDate != null) {
                    BigDecimal value = numberValue(previousRow.getCell(yearColumn), evaluator);
                    if (value != null) {
                        upsertPassenger(year, previousDate, entry.getValue(), value);
                    }
                }
            }
        }
@@ -618,7 +761,7 @@
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
@@ -659,6 +802,46 @@
        }
    }
    private LocalDate previousDayDate(int year, String holidayType, Map<Integer, LocalDate> dateByDay) {
        HolidayCalendar configured = configService.findPreviousDay(year, holidayType);
        if (configured != null && configured.getHolidayDate() != null) {
            return configured.getHolidayDate();
        }
        LocalDate firstDate = firstDate(dateByDay);
        return firstDate == null ? null : firstDate.minusDays(1);
    }
    private LocalDate firstDate(Map<Integer, LocalDate> dateByDay) {
        LocalDate result = null;
        for (LocalDate date : dateByDay.values()) {
            if (date != null && (result == null || date.isBefore(result))) result = date;
        }
        return result;
    }
    private Row findDayRow(Sheet sheet, FormulaEvaluator evaluator, int dayIndex) {
        int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
        for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
            if (rowDay != null && rowDay.intValue() == dayIndex) return row;
        }
        return null;
    }
    private Row findPreviousDayRow(Sheet sheet, FormulaEvaluator evaluator) {
        int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
        DataFormatter formatter = new DataFormatter();
        for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null || row.getCell(0) == null) continue;
            String text = formatter.formatCellValue(row.getCell(0), evaluator).trim();
            if (text.contains("前一日")) return row;
        }
        return null;
    }
    private Map<Integer, LocalDate> dayDates(int year, String holidayType) {
        Map<Integer, LocalDate> result = new LinkedHashMap<>();
        for (HolidayCalendar row : configService.findHolidayDays(year, holidayType)) {
traffic-audit-web/src/views/SystemConfig.vue
@@ -40,7 +40,9 @@
                  <template slot-scope="scope">{{ labelOf(holidayTypeOptions, scope.row.holidayType) }}</template>
                </el-table-column>
                <el-table-column prop="holidayDate" label="日期" width="150" align="center"></el-table-column>
                <el-table-column prop="dayIndex" label="第几日" width="100" align="center"></el-table-column>
                <el-table-column label="日期类型" width="120" align="center">
                  <template slot-scope="scope">{{ dayIndexLabel(scope.row.dayIndex) }}</template>
                </el-table-column>
                <el-table-column label="操作" width="150" align="center">
                  <template slot-scope="scope">
                    <el-button type="text" size="mini" @click="openHolidayDialog(scope.row)">编辑</el-button>
@@ -93,8 +95,11 @@
              <el-date-picker v-model="holidayForm.holidayDate" type="date" value-format="yyyy-MM-dd"
                              style="width:100%" placeholder="选择日期"></el-date-picker>
            </el-form-item>
            <el-form-item label="第几日" prop="dayIndex">
              <el-input-number v-model="holidayForm.dayIndex" :min="1" :max="31" style="width:100%"></el-input-number>
            <el-form-item label="日期类型" prop="dayIndex">
              <el-select v-model="holidayForm.dayIndex" style="width:100%">
                <el-option label="前一日" :value="0"></el-option>
                <el-option v-for="day in 31" :key="day" :label="'第' + day + '日'" :value="day"></el-option>
              </el-select>
            </el-form-item>
          </el-form>
          <span slot="footer">
@@ -157,7 +162,7 @@
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        holidayType: [{ required: true, message: '请选择节假日分类', trigger: 'change' }],
        holidayDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
        dayIndex: [{ required: true, message: '请输入第几日', trigger: 'change' }]
        dayIndex: [{ required: true, message: '请选择日期类型', trigger: 'change' }]
      },
      coefficientRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
@@ -172,6 +177,10 @@
    this.loadCoefficients()
  },
  methods: {
    dayIndexLabel(value) {
      if (Number(value) === 0) return '前一日'
      return value === null || value === undefined || value === '' ? '—' : '第' + value + '日'
    },
    loadOptions() {
      api.get('/holiday/config/options').then(res => {
        const data = res.data || {}