xyc
昨天 e92968f85531df814268b6afbe62f6e062f5a07f
feat(holiday): 新增往年国省道调整并按前一日环比基期重算汇总
6个文件已添加
12个文件已修改
779 ■■■■■ 已修改文件
HANDOFF.md 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/init.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/init.sql 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_vehicle_adjustment_migration.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pack-deploy.ps1 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -6,7 +6,22 @@
## æœ€æ–°ï¼ˆ2026-10-06)
### 10-06:普通国省道「环比基期」改为人工备用值(不再参与计算)
### 10-06:方案 D å·²å®žçŽ°ï¼ˆå¾€å¹´å›½çœé“è°ƒæ•´è¡¨ + çŽ¯æ¯”åŸºæœŸåªä½œç”¨äºŽå‰ä¸€æ—¥åˆ†æ¯ï¼‰
- **最终口径**:`HolidayStatService.loadVehicleByDate` å…ˆç”¨ `holiday_vehicle_adjustment` è¦†ç›–对应历史年份的“当日值”,供走势图、同比、累计和快报使用;随后构造“前一日环比分母”,若前一日普通国省道 C åˆ—有值,则用 C æ›¿æ¢è¯¥æ—¥å‰ä¸€æ—¥å›½çœé“值,并据此重算非营业性人员流动量、公路人员流动量和总计。所有环比行只继承这份分母。
- **关键边界**:C åªå½±å“â€œä¸‹ä¸€æ—¥çš„环比分母”,不影响 C æ‰€åœ¨æ—¥çš„当日值、累计或走势图;调整表只维护历史年份(`year < å½“前年份`),2026 å¹´è°ƒæ•´ä»ç”±å¯¼å…¥ Excel çš„ D åˆ—进入当日值。
- **本轮实现**:新增 `holiday_vehicle_adjustment` è¡¨åŠ mapper/service/controller;前端在专项统计-节假日页的“节假日数据”和“观测站分日调查数据”之间新增“往年国省道数据调整”卡片(后续按用户提议保持该位置),支持年份筛选、新增、编辑、删除和原因留痕;部署脚本与 migration æ¸…单已同步。
- **10-04 å¯¼å…¥é¢„检(只读,不写库)**:用 `C:\Users\jcxiong\Downloads\2026年十一数据 (2).xlsx` ä»¥ `allowHistoricalOverwrite=false` è°ƒå¯¼å…¥æŽ¥å£ï¼Œè¿”回确认项:
  - `第4日 2026-10-04 æ™®é€šå›½çœé“:原值 323.6551,导入值 322.7695`
  - `第3日 2026-10-03 æ™®é€šå›½çœé“环比基期:原值 ç©ºï¼Œå¯¼å…¥å€¼ 320.2418`
  å› æ­¤ C5 ç”±ç©ºå˜æœ‰å€¼ä¸ä¼šç¡¬æ‹’绝导入,只会要求历史变更确认;用户确认后即可随调整后的 Excel é‡å¯¼å†™å…¥ã€‚
- **当前库状态**:本地 `traffic_audit.holiday_vehicle_adjustment` å·²æ‰§è¡Œ DDL(0 è¡Œï¼‰ï¼›2026-10-04 ä¸šåŠ¡å€¼ä»ä¸ºæ—§å€¼ `323.6551`,等待用户自行重导调整后的 Excel。助手未执行任何业务数据导入、覆盖或重导。
- **验证**:后端 `mvn -o test` 9 ä¸ªæµ‹è¯•全绿(含 C5 åªæ”¹ 10-04 çŽ¯æ¯”åˆ†æ¯ã€åŽ†å²è°ƒæ•´è¿›å…¥ 2025 å½“日值)、前端 `npm run build` é€šè¿‡ï¼›é¡µé¢å®žæµ‹â€œå¾€å¹´å›½çœé“数据调整”卡片与新增弹窗正常。
- **已知取整边界(需沿用现有口径或另作决策)**:快报百分比目前按既定规则先复现源 Excel å•元格格式,再取 1 ä½å°æ•°ã€‚10-04 æ€»è®¡çŽ¯æ¯”åŽŸå§‹å€¼çº¦ `4.5481%`,源格显示 `4.55%`,再取 1 ä½å¾—到 `4.6%`;若直接从原始值一次取 1 ä½åˆ™æ˜¯ `4.5%`。当前代码保留“先 Excel æ˜¾ç¤ºã€å†å¿«æŠ¥ 1 ä½â€çš„æ—¢æœ‰å£å¾„;若领导要求改为一次取整,需要单独改快报百分比路径。
> è¯´æ˜Žï¼šæœ¬æ–‡ä»¶æ—©å‰â€œçŽ¯æ¯”åŸºæœŸåªä½œå¤‡ç”¨ã€ä¸å‚ä¸Žè®¡ç®—â€çš„æ®µè½å·²ä½œåºŸã€‚è¯¥ç‰ˆæœ¬é’ˆå¯¹çš„æ˜¯æ—§å®žçŽ°è¯¯æŠŠ C æ”¾åˆ° C æ‰€åœ¨æ—¥æœ¬èº«è®¡ç®—的问题;自方案 D èµ·ï¼ŒC çš„æ­£ç¡®è¯­ä¹‰æ˜¯**前一日环比分母的调整值**。
### 10-06(已作废,见上方方案 D):普通国省道「环比基期」改为人工备用值(不再参与计算)
- **背景**: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%)。
@@ -306,7 +321,7 @@
- **Excel æµ®ç‚¹ç²¾åº¦ï¼ˆ2026-10-02 è¿½åŠ ï¼‰**:参考 `docs/Excel浮点数存储.txt`,新增 `HolidayNumberUtils`(`NumberToTextConverter.toText` + `BigDecimal`,`MathContext(15, HALF_UP)`);节假日模块由 `Double` å…¨é¢æ”¹ `BigDecimal`;三张表数值列改 `DECIMAL(30,15)`;库内 6 æ¡å®¢è¿ç³»æ•°ç”± 2.3999999999999995 æ ¡æ­£ä¸º 2.4。迁移脚本 `docs/sql_holiday_decimal_migration.sql`。
- **菜单顺序**:“专项统计”已移到“报表生成”之后,当前为 æŠ¥è¡¨ç”Ÿæˆ -> ä¸“项统计 -> è§„则管理 -> ç”¨æˆ·ç®¡ç† -> ç³»ç»Ÿé…ç½®ã€‚
- **节假日删除复活修复(2026-10-02 è¿½åŠ ï¼‰**:原初始化逻辑每次启动补齐缺失的第 N æ—¥ï¼Œå¯¼è‡´ admin åˆ é™¤ 2026-10-08 åŽè¢«é‡æ–°æ’入;现改为仅 `holiday_calendar` æ•´è¡¨ä¸ºç©ºæ—¶é¦–次初始化。重启后 2026 å¹´åä¸€ä¿æŒ 10-01 è‡³ 10-07 å…± 7 å¤©ã€‚
- **普通国省道环比基期(2026-10-02 è¿½åŠ ï¼‰**:普通国省道 C åˆ—为可选环比基期,仅 2026 å¹´å¯ç”¨ï¼›å¡«å†™åŽåªå½±å“æ™®é€šå›½çœé“车流量自身环比 `当日 / åŸºæœŸ - 1`,未填写按前一日。`holiday_vehicle_flow.comparison_base` å·²å…¥åº“,导入导出与历史变更确认均已接入;折算人员流动量、总人员流动量和 Word å¿«æŠ¥ç»§ç»­æŒ‰ Excel åŽŸå…¬å¼ï¼Œä¸å—åŸºæœŸå½±å“ã€‚2024/2025 ä¸ä½¿ç”¨ã€‚
- **普通国省道环比基期(2026-10-02 è¿½åŠ ï¼Œ2026-10-06 æ–¹æ¡ˆ D å·²ä¿®æ­£ï¼‰**:C åˆ—语义最终确定为“下一日环比分母的调整值”;填写后只影响下一日的环比分母,不影响 C æ‰€åœ¨æ—¥åŠç´¯è®¡ã€èµ°åŠ¿å›¾ã€‚`holiday_vehicle_flow.comparison_base` å·²å…¥åº“,导入导出与历史变更确认均已接入。
- **快报首日口径(2026-10-02 è¿½åŠ ï¼‰**:假期第1天没有前一日环比基数,正文不输出“环比增长/下降/持平”短语,表格当日环比列为“—”;第2天起仍按实际前一日输出环比。已用 2025-10-01/02 å¿«æŠ¥å›žå½’验证。
- **快报百分比精度(2026-10-02 è¿½åŠ ï¼‰**:Word å¿«æŠ¥ä¸­åŒæ¯”/环比百分比统一保留1位小数(如6.79%显示6.8%、11.46%显示11.5%);人次、车流等业务数值仍保留2位小数。
- **验证结果**:后端编译、前端构建、权限/CRUD/负向导入、历史变更两阶段导入、Excel å¯¼å‡ºã€Word å¿«æŠ¥ã€å‰ç«¯ admin/非 admin çƒŸæµ‹å‡é€šè¿‡ï¼›èœå•顺序、客运系数 2.4 å±•示(系统配置页 + èŠ‚å‡æ—¥ç»Ÿè®¡é¡µï¼‰ã€`decimal(30,15)` åˆ—型、历史变更两阶段导入回归均已实测;功能测试报告与工作日结按 2026-09-27 æäº¤è§„范仅本地留存(不入库):`docs/功能测试报告/功能测试报告_重要节假日专项统计_2026-10-02.md`、`docs/工作日结/工作日结_2026-10-02.md`。
deploy-templates/README-²¿Êð˵Ã÷.txt
@@ -40,6 +40,7 @@
    sql_holiday_decimal_migration.sql        èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_vehicle_adjustment_migration.sql å¾€å¹´å›½çœé“数据调整表(旧库升级)
    sql_observation_station.sql               è§‚测站分日调查数据表(新功能,旧库缺表时执行)
  README-部署说明.txt
@@ -80,7 +81,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 å·²å«èŠ‚å‡æ—¥åŠç›¸å…³è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“ä¸Šè¡¥èŠ‚å‡æ—¥æ¨¡å—ï¼Œå†æ‰§è¡Œ db/sql_holiday.sql,并按需执行迁移脚本(数值精度、环比基期、前一日配置、往年国省道数据调整、观测站分日调查数据)。迁移脚本均可重复执行。
     é¢„置账号:admin / admin123(首次登录后请改密码)
  2. æ”¹é…ç½® backend/application.yml
deploy-templates/db/init.sql
@@ -673,6 +673,21 @@
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输非营业性车流情况';
-- å¾€å¹´å›½çœé“车流量调整
DROP TABLE IF EXISTS holiday_vehicle_adjustment;
CREATE TABLE holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT '调整后普通国省道车流量(万辆次)',
  reason VARCHAR(500) NOT NULL COMMENT '调整原因',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往年国省道车流量调整';
-- å®¢è¿ç³»æ•°é…ç½®
DROP TABLE IF EXISTS holiday_passenger_coefficient;
CREATE TABLE holiday_passenger_coefficient (
deploy-templates/db/sql_holiday.sql
@@ -42,6 +42,19 @@
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  adjusted_value DECIMAL(30,15) NOT NULL,
  reason VARCHAR(500) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql
New file
@@ -0,0 +1,14 @@
-- Add the historical national/provincial road adjustment table.
-- Run against the traffic_audit database on upgrade.
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT 'year',
  flow_date DATE NOT NULL COMMENT 'holiday date',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
  reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
docs/init.sql
@@ -673,6 +673,21 @@
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输非营业性车流情况';
-- å¾€å¹´å›½çœé“车流量调整
DROP TABLE IF EXISTS holiday_vehicle_adjustment;
CREATE TABLE holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT '调整后普通国省道车流量(万辆次)',
  reason VARCHAR(500) NOT NULL COMMENT '调整原因',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往年国省道车流量调整';
-- å®¢è¿ç³»æ•°é…ç½®
DROP TABLE IF EXISTS holiday_passenger_coefficient;
CREATE TABLE holiday_passenger_coefficient (
docs/sql_holiday.sql
@@ -41,6 +41,19 @@
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  adjusted_value DECIMAL(30,15) NOT NULL,
  reason VARCHAR(500) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
docs/sql_holiday_vehicle_adjustment_migration.sql
New file
@@ -0,0 +1,14 @@
-- Add the historical national/provincial road adjustment table.
-- Run against the traffic_audit database on upgrade.
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT 'year',
  flow_date DATE NOT NULL COMMENT 'holiday date',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
  reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -103,7 +103,9 @@
│  â”œâ”€ sql_holiday.sql                           ä¸“项统计-重要节假日表(旧库补建时执行)
│  â”œâ”€ sql_holiday_decimal_migration.sql         èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â””─ sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_vehicle_adjustment_migration.sql å¾€å¹´å›½çœé“数据调整表(旧库升级)
│  â””─ sql_observation_station.sql               è§‚测站分日调查数据表(旧库补建时执行)
└─ README-部署说明.txt
```
@@ -124,7 +126,7 @@
```
> âš ï¸ **`init.sql` æ˜¯ç ´åæ€§çš„**:里面的基础表都带 `DROP TABLE IF EXISTS`。**已有数据的库上千万不要再跑 init.sql**,否则表数据会被清空重建。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日 4 å¼ è¡¨ï¼Œæ‰§è¡Œ `sql_holiday.sql`;若已有节假日表但缺精度、环比基期字段或前一日配置,按需执行 `sql_holiday_decimal_migration.sql`、`sql_holiday_comparison_base_migration.sql`、`sql_holiday_previous_day_migration.sql`。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日相关表,执行 `sql_holiday.sql`;若已有节假日表但缺精度、环比基期字段、前一日配置、往年国省道调整表或观测站表,按需执行对应的迁移脚本。迁移脚本均可重复执行。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 33 å¼ è¡¨ï¼ˆinit.sql 28 å¼ ï¼Œå…¶ä¸­å«èŠ‚å‡æ—¥ 4 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
#### è·¯çº¿ B:把现网/开发机的数据整套搬过去(推荐,不用跑脚本)
@@ -281,7 +283,7 @@
| å¯¼å‡ºæŠ¥ã€Œæœªæ‰¾åˆ°æ±‡æ€»è¡¨æ¯ç‰ˆã€ŠYYYYå¹´M月…》」 | `docs/生成汇总大表` ç¼ºè¯¥æœˆæ¯ç‰ˆ |
| ç™»å½•接口 500 | `jwt.secret` å¡«äº†éžæ³•字符(如中文);改成 32 ä½åå…­è¿›åˆ¶ä¸² |
| é¡µé¢èƒ½å¼€ä½†æŽ¥å£ 404 | èµ° nginx æ–¹æ¡ˆæ—¶æ²¡é… `/api` åä»£ï¼›æ”¹ç”¨ jar å†…嵌访问 8090 |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认 5 ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œ |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认初始化脚本和对应升级迁移脚本已执行 |
| å¯åŠ¨æŠ¥ `Access denied ... (using password: NO)` | åŽç«¯æ‹¿åˆ°çš„库口令是**空串**:jar å†…ç½® `${TRAFFIC_DB_PASSWORD:}` æœªè¢«å¤–置配置覆盖。把 `backend/application.yml` æ”¾åœ¨ jar åŒçº§å¹¶å¡« `spring.datasource.password`,或设环境变量 `TRAFFIC_DB_PASSWORD`;改完重启,无需重打包 |
| ç«¯å£å ç”¨ | æ”¹ `server.port`;前端开发端口 8080 |
| æœåŠ¡å·²æ³¨å†Œä½† 8090 æ‰“不开 | çœ‹ `backend\logs\trafficAudit.err.log`;服务方式读的是 `backend\application.yml`,`password` å¿…须是真实口令 |
pack-deploy.ps1
@@ -231,7 +231,7 @@
cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache å·¥ä½œæ—¥ç»“ åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š é—®é¢˜æ±‡æ€» /XF ç”Ÿæˆ_*.xlsx _bak_* >nul"
if ($LASTEXITCODE -ge 8) { throw "docs å¤åˆ¶å¤±è´¥ï¼ˆrobocopy è¿”回 $LASTEXITCODE)" }
# db è„šæœ¬ä¹Ÿæ”¾ä¸€ä»½åˆ° docs(与旧包一致:docs/init.sql ç­‰ï¼‰
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_vehicle_adjustment_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java
New file
@@ -0,0 +1,59 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.service.HolidayVehicleAdjustmentService;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday/adjustments")
public class HolidayVehicleAdjustmentController {
    @Resource
    private HolidayVehicleAdjustmentService adjustmentService;
    @Resource
    private JwtUtils jwtUtils;
    @GetMapping
    public Result<List<HolidayVehicleAdjustment>> list(@RequestParam(value = "year", required = false) Integer year) {
        return Result.ok(adjustmentService.list(year));
    }
    @PostMapping("/save")
    public Result<HolidayVehicleAdjustment> save(@RequestHeader(value = "Authorization", required = false) String auth,
                                                 @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç»´æŠ¤å¾€å¹´å›½çœé“数据调整");
        try {
            return Result.ok(adjustmentService.save(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/delete")
    public Result<Void> delete(@RequestHeader(value = "Authorization", required = false) String auth,
                               @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç»´æŠ¤å¾€å¹´å›½çœé“数据调整");
        try {
            adjustmentService.delete(id);
            return Result.ok();
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    private boolean isAdmin(String auth) {
        return "admin".equals(jwtUtils.resolveUsername(auth));
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java
New file
@@ -0,0 +1,23 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_vehicle_adjustment")
public class HolidayVehicleAdjustment {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private BigDecimal adjustedValue;
    private String reason;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayVehicleAdjustmentMapper extends BaseMapper<HolidayVehicleAdjustment> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -65,7 +65,7 @@
            throw new IllegalArgumentException("所选日期不在已配置的节假日范围内");
        }
        DailyMetrics previousDay = current.getDayIndex() > 1
                ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : null;
                ? statService.ringRatioBaseMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex()) : null;
        HolidayCalendar previousDayConfig = configService.findPreviousDay(current.getYear(), current.getHolidayType());
        LocalDate previousDayDate = previousDayConfig == null || previousDayConfig.getHolidayDate() == null
                ? current.getDate().minusDays(1) : previousDayConfig.getHolidayDate();
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
@@ -8,7 +8,9 @@
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import lombok.Data;
import org.springframework.stereotype.Service;
@@ -36,6 +38,8 @@
    private HolidayPassengerFlowMapper passengerFlowMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    @Resource
    private HolidayVehicleAdjustmentMapper vehicleAdjustmentMapper;
    @Resource
    private ImportBatchMapper importBatchMapper;
@@ -93,37 +97,33 @@
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        List<Map<String, Object>> rows = new ArrayList<>();
        BigDecimal previousNationalProvincial = null;
        Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : calendar) {
            Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(day.getHolidayDate(), Collections.emptyMap());
            Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(day.getHolidayDate(), Collections.emptyMap());
            BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
            BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
            BigDecimal comparisonBase = comparisonBaseByDate.get(day.getHolidayDate());
            BigDecimal nationalProvincialRingRatio = nationalProvincialRingRatio(
                    nationalProvincial, previousNationalProvincial);
            BigDecimal nonBusinessPersonFlow = null;
            if (coefficient != null && (expressway != null || nationalProvincial != null)) {
                nonBusinessPersonFlow = HolidayNumberUtils.multiply(
                        HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
            }
            if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
        }
        List<Map<String, Object>> rows = new ArrayList<>();
        for (HolidayCalendar day : calendar) {
            DailyMetrics current = buildDailyMetrics(year, holidayType, day.getDayIndex(), day.getHolidayDate(),
                    passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
            DailyMetrics ringRatioBase = ringRatioBaseFromMaps(year, holidayType, day.getDayIndex(), calendarByDay,
                    passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
            BigDecimal nationalProvincialRingRatio = ratio(current.getNationalProvincial(),
                    ringRatioBase == null ? null : ringRatioBase.getNationalProvincial());
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("dayIndex", day.getDayIndex());
            row.put("date", day.getHolidayDate());
            row.put("rail", passenger.get(HolidayConstants.MODE_RAIL));
            row.put("civilAviation", passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
            row.put("road", passenger.get(HolidayConstants.MODE_ROAD));
            row.put("water", passenger.get(HolidayConstants.MODE_WATER));
            row.put("expressway", expressway);
            row.put("nationalProvincial", nationalProvincial);
            row.put("comparisonBase", comparisonBase);
            row.put("rail", current.getRail());
            row.put("civilAviation", current.getCivilAviation());
            row.put("road", current.getRoad());
            row.put("water", current.getWater());
            row.put("expressway", current.getExpressway());
            row.put("nationalProvincial", current.getNationalProvincial());
            row.put("comparisonBase", current.getComparisonBase());
            row.put("nationalProvincialRingRatio", nationalProvincialRingRatio);
            row.put("coefficient", coefficient);
            row.put("nonBusinessPersonFlow", nonBusinessPersonFlow);
            row.put("totalPersonFlow", totalPersonFlow(passenger, nonBusinessPersonFlow));
            row.put("coefficient", current.getCoefficient());
            row.put("nonBusinessPersonFlow", current.getNonBusinessPersonFlow());
            row.put("totalPersonFlow", current.getTotalPersonFlow());
            rows.add(row);
            previousNationalProvincial = nationalProvincial;
        }
        return rows;
    }
@@ -150,17 +150,37 @@
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        return buildDailyMetrics(year, holidayType, dayIndex, date, passengerByDate, vehicleByDate,
                comparisonBaseByDate, coefficient);
    }
    public DailyMetrics ringRatioBaseMetrics(int year, String holidayType, int dayIndex) {
        if (dayIndex <= 1) return null;
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : calendar) {
            if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
        }
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        return ringRatioBaseFromMaps(year, holidayType, dayIndex, calendarByDay, passengerByDate,
                vehicleByDate, comparisonBaseByDate, coefficient);
    }
    private DailyMetrics buildDailyMetrics(int year, String holidayType, int dayIndex, LocalDate date,
                                           Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
                                           Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
                                           Map<LocalDate, BigDecimal> comparisonBaseByDate,
                                           BigDecimal coefficient) {
        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(date);
        BigDecimal nonBusiness = null;
        if (coefficient != null && (expressway != null || nationalProvincial != null)) {
            nonBusiness = HolidayNumberUtils.multiply(
                    HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
        }
        BigDecimal nonBusiness = nonBusinessPersonFlow(expressway, nationalProvincial, coefficient);
        DailyMetrics metrics = new DailyMetrics();
        metrics.setYear(year);
@@ -176,9 +196,43 @@
        metrics.setComparisonBase(comparisonBase);
        metrics.setCoefficient(coefficient);
        metrics.setNonBusinessPersonFlow(nonBusiness);
        metrics.setRoadPersonFlow(sumNullable(metrics.getRoad(), nonBusiness));
        metrics.setTotalPersonFlow(totalPersonFlow(passenger, nonBusiness));
        recalculateDerived(metrics);
        return metrics;
    }
    private DailyMetrics ringRatioBaseFromMaps(int year, String holidayType, int dayIndex,
                                               Map<Integer, HolidayCalendar> calendarByDay,
                                               Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
                                               Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
                                               Map<LocalDate, BigDecimal> comparisonBaseByDate,
                                               BigDecimal coefficient) {
        if (dayIndex <= 1) return null;
        HolidayCalendar previous = calendarByDay.get(dayIndex - 1);
        if (previous == null || previous.getHolidayDate() == null) return null;
        DailyMetrics base = buildDailyMetrics(year, holidayType, previous.getDayIndex(), previous.getHolidayDate(),
                passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
        BigDecimal comparisonBase = comparisonBaseByDate.get(previous.getHolidayDate());
        if (comparisonBase != null) {
            base.setNationalProvincial(comparisonBase);
            recalculateDerived(base);
        }
        return base;
    }
    private void recalculateDerived(DailyMetrics metrics) {
        BigDecimal nonBusiness = nonBusinessPersonFlow(metrics.getExpressway(), metrics.getNationalProvincial(),
                metrics.getCoefficient());
        metrics.setNonBusinessPersonFlow(nonBusiness);
        metrics.setRoadPersonFlow(sumNullable(metrics.getRoad(), nonBusiness));
        metrics.setTotalPersonFlow(totalPersonFlow(nonBusiness, metrics.getRoad(), metrics.getRail(),
                metrics.getCivilAviation(), metrics.getWater()));
    }
    private BigDecimal nonBusinessPersonFlow(BigDecimal expressway, BigDecimal nationalProvincial,
                                            BigDecimal coefficient) {
        if (coefficient == null || (expressway == null && nationalProvincial == null)) return null;
        return HolidayNumberUtils.multiply(
                HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
    }
    public DailyMetrics cumulativeMetrics(int year, String holidayType, int dayIndex) {
@@ -228,6 +282,23 @@
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getRoadCategory(), row.getVehicleCount());
        }
        Map<LocalDate, BigDecimal> adjustments = loadNationalProvincialAdjustmentsByDate(year);
        for (Map.Entry<LocalDate, BigDecimal> entry : adjustments.entrySet()) {
            result.computeIfAbsent(entry.getKey(), key -> new LinkedHashMap<>())
                    .put(HolidayConstants.ROAD_NATIONAL_PROVINCIAL, entry.getValue());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadNationalProvincialAdjustmentsByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleAdjustment> rows = vehicleAdjustmentMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>().eq(HolidayVehicleAdjustment::getYear, year));
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getFlowDate() != null && row.getAdjustedValue() != null) {
                result.put(row.getFlowDate(), row.getAdjustedValue());
            }
        }
        return result;
    }
@@ -243,22 +314,16 @@
        return result;
    }
    private BigDecimal totalPersonFlow(Map<String, BigDecimal> passenger, BigDecimal nonBusinessPersonFlow) {
    private BigDecimal totalPersonFlow(BigDecimal nonBusinessPersonFlow, BigDecimal road, BigDecimal rail,
                                       BigDecimal civilAviation, BigDecimal water) {
        BigDecimal total = nonBusinessPersonFlow;
        total = sumNullable(total, passenger.get(HolidayConstants.MODE_ROAD));
        total = sumNullable(total, passenger.get(HolidayConstants.MODE_RAIL));
        total = sumNullable(total, passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
        total = sumNullable(total, passenger.get(HolidayConstants.MODE_WATER));
        total = sumNullable(total, road);
        total = sumNullable(total, rail);
        total = sumNullable(total, civilAviation);
        total = sumNullable(total, water);
        return total;
    }
    // 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;
        return previous == null ? null : ratio(current, previous);
    }
    private BigDecimal ratio(BigDecimal current, BigDecimal base) {
        BigDecimal quotient = HolidayNumberUtils.divide(current, base);
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java
New file
@@ -0,0 +1,124 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class HolidayVehicleAdjustmentService {
    private static final int REASON_MAX_LENGTH = 500;
    @Resource
    private HolidayVehicleAdjustmentMapper adjustmentMapper;
    public List<HolidayVehicleAdjustment> list(Integer year) {
        return adjustmentMapper.selectList(new LambdaQueryWrapper<HolidayVehicleAdjustment>()
                .eq(year != null, HolidayVehicleAdjustment::getYear, year)
                .orderByDesc(HolidayVehicleAdjustment::getYear)
                .orderByAsc(HolidayVehicleAdjustment::getFlowDate));
    }
    @Transactional
    public HolidayVehicleAdjustment save(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        LocalDate flowDate = dateValue(params.get("flowDate"));
        BigDecimal adjustedValue = decimalValue(params.get("adjustedValue"));
        String reason = textValue(params.get("reason"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (year >= LocalDate.now().getYear()) {
            throw new IllegalArgumentException("往年国省道数据调整仅维护历史年份");
        }
        if (flowDate == null || flowDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (adjustedValue == null || adjustedValue.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("调整后的国省道车流量不能小于 0");
        }
        if (reason.isEmpty()) {
            throw new IllegalArgumentException("调整原因不能为空");
        }
        if (reason.length() > REASON_MAX_LENGTH) {
            throw new IllegalArgumentException("调整原因不能超过 " + REASON_MAX_LENGTH + " ä¸ªå­—符");
        }
        HolidayVehicleAdjustment sameKey = adjustmentMapper.selectOne(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>()
                        .eq(HolidayVehicleAdjustment::getYear, year)
                        .eq(HolidayVehicleAdjustment::getFlowDate, flowDate)
                        .last("limit 1"));
        HolidayVehicleAdjustment entity;
        if (id == null) {
            entity = sameKey == null ? new HolidayVehicleAdjustment() : sameKey;
        } else {
            entity = adjustmentMapper.selectById(id);
            if (entity == null) {
                throw new IllegalArgumentException("调整记录不存在");
            }
            if (sameKey != null && !sameKey.getId().equals(id)) {
                throw new IllegalArgumentException("该年份和日期已存在调整记录");
            }
        }
        if (entity.getId() == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setFlowDate(flowDate);
        entity.setAdjustedValue(HolidayNumberUtils.normalize(adjustedValue));
        entity.setReason(reason);
        entity.setUpdatedAt(LocalDateTime.now());
        if (entity.getId() == null) {
            adjustmentMapper.insert(entity);
        } else {
            adjustmentMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void delete(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        adjustmentMapper.deleteById(id);
    }
    private Long longValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Long.valueOf(value.toString().trim());
    }
    private Integer intValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Integer.valueOf(value.toString().trim());
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return HolidayNumberUtils.decimalValue(value);
    }
    private LocalDate dateValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return LocalDate.parse(value.toString().trim());
    }
    private String textValue(Object value) {
        return value == null ? "" : value.toString().trim();
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
@@ -4,8 +4,10 @@
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import org.junit.jupiter.api.Test;
@@ -13,79 +15,159 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
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.junit.jupiter.api.Assertions.assertNotNull;
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.
 * Ring-ratio denominators are built one day at a time. The Excel comparison base (column C) is
 * consumed on the source day and inherited by the next day's denominator, while historical
 * adjustment records change only that historical day's same-day value.
 */
class HolidayComparisonBaseTest {
    @Test
    void ringRatioUsesPreviousDayEvenWhenComparisonBaseIsStored() throws Exception {
    void comparisonBaseChangesOnlyTheNextDayRingRatioDenominator() throws Exception {
        HolidayStatService service = serviceWithData(
                2026,
                passengerRows(2026, "50", "10", "6", "3"),
                vehicleRows(2026),
                Collections.<HolidayVehicleAdjustment>emptyList());
        List<Map<String, Object>> rows = service.buildOverviewRows(
                2026, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2026));
        assertEquals(new BigDecimal("323.7837"), rows.get(2).get("nationalProvincial"));
        assertEquals(new BigDecimal("320.2418"), rows.get(2).get("comparisonBase"));
        assertEquals(new BigDecimal("322.7695"), rows.get(3).get("nationalProvincial"));
        BigDecimal nationalRatio = (BigDecimal) rows.get(3).get("nationalProvincialRingRatio");
        assertEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("320.2418")), nationalRatio);
        assertNotEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("323.7837")), nationalRatio);
        HolidayStatService.DailyMetrics base = service.ringRatioBaseMetrics(
                2026, HolidayConstants.TYPE_NATIONAL_DAY, 4);
        assertNotNull(base);
        assertEquals(new BigDecimal("320.2418"), base.getNationalProvincial());
        assertDecimalEquals("1008.58032", base.getNonBusinessPersonFlow());
        assertDecimalEquals("1058.58032", base.getRoadPersonFlow());
        assertDecimalEquals("1077.58032", base.getTotalPersonFlow());
    }
    @Test
    void historicalAdjustmentChangesTheHistoricalSameDayValue() throws Exception {
        HolidayVehicleAdjustment adjustment = new HolidayVehicleAdjustment();
        adjustment.setYear(2025);
        adjustment.setFlowDate(LocalDate.of(2025, 10, 4));
        adjustment.setAdjustedValue(new BigDecimal("337.5009"));
        HolidayStatService service = serviceWithData(
                2025,
                passengerRows(2025, "50", "10", "6", "3"),
                Arrays.asList(
                        vehicle("2025-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null),
                        vehicle("2025-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "362.0035", null),
                        vehicle("2025-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null),
                        vehicle("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000", null)),
                Collections.singletonList(adjustment));
        List<Map<String, Object>> rows = service.buildOverviewRows(
                2025, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2025));
        assertEquals(new BigDecimal("337.5009"), rows.get(3).get("nationalProvincial"));
    }
    private static HolidayStatService serviceWithData(int year,
                                                      List<HolidayPassengerFlow> passengerRows,
                                                      List<HolidayVehicleFlow> vehicleRows,
                                                      List<HolidayVehicleAdjustment> adjustments) throws Exception {
        HolidayConfigService configService = mock(HolidayConfigService.class);
        HolidayPassengerFlowMapper passengerMapper = mock(HolidayPassengerFlowMapper.class);
        HolidayVehicleFlowMapper vehicleMapper = mock(HolidayVehicleFlowMapper.class);
        HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.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());
        when(configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY)).thenReturn(new BigDecimal("2.4"));
        when(configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY)).thenReturn(calendar(year));
        when(passengerMapper.selectList(any())).thenReturn(passengerRows);
        when(vehicleMapper.selectList(any())).thenReturn(vehicleRows);
        when(adjustmentMapper.selectList(any())).thenReturn(adjustments);
        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));
        inject(service, "vehicleAdjustmentMapper", adjustmentMapper);
        return service;
    }
    private static BigDecimal ratio(BigDecimal current, BigDecimal base) {
        return HolidayNumberUtils.normalize(HolidayNumberUtils.divide(current, base).subtract(BigDecimal.ONE));
    }
    private static List<HolidayVehicleFlow> vehicleRows() {
    private static void assertDecimalEquals(String expected, BigDecimal actual) {
        assertNotNull(actual);
        assertEquals(0, new BigDecimal(expected).compareTo(actual));
    }
    private static List<HolidayVehicleFlow> vehicleRows(int year) {
        List<HolidayVehicleFlow> rows = new ArrayList<>();
        rows.add(vehicle("2026-10-03", "323.7837", "320.2418"));
        rows.add(vehicle("2026-10-04", "322.7695", null));
        rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null));
        rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "323.7837", "320.2418"));
        rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null));
        rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "322.7695", null));
        return rows;
    }
    private static HolidayVehicleFlow vehicle(String date, String value, String base) {
    private static HolidayVehicleFlow vehicle(String date, String roadCategory, String value, String base) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(2026);
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setRoadCategory(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        row.setRoadCategory(roadCategory);
        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() {
    private static List<HolidayPassengerFlow> passengerRows(int year, String road, String rail,
                                                            String civilAviation, String water) {
        List<HolidayPassengerFlow> rows = new ArrayList<>();
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_ROAD, road));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_RAIL, rail));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_WATER, water));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_ROAD, road));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_RAIL, rail));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_WATER, water));
        return rows;
    }
    private static HolidayPassengerFlow passenger(String date, String mode, String value) {
        HolidayPassengerFlow row = new HolidayPassengerFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setTransportMode(mode);
        row.setPassengerCount(new BigDecimal(value));
        return row;
    }
    private static List<HolidayCalendar> calendar(int year) {
        List<HolidayCalendar> days = new ArrayList<>();
        for (int i = 1; i <= 4; i++) {
            HolidayCalendar day = new HolidayCalendar();
            day.setYear(2026);
            day.setYear(year);
            day.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            day.setDayIndex(i);
            day.setHolidayDate(LocalDate.of(2026, 10, i));
            day.setHolidayDate(LocalDate.of(year, 10, i));
            days.add(day);
        }
        return days;
traffic-audit-web/src/views/HolidayStat.vue
@@ -84,6 +84,61 @@
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">
              <span>往年国省道数据调整</span>
            </div>
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">年份</span>
                <el-select v-model="adjustmentYear" style="width:120px" @change="loadAdjustments">
                  <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
                </el-select>
              </div>
              <div v-if="operatorIsAdmin" class="operation-item">
                <el-button type="primary" icon="el-icon-plus" @click="openAdjustmentDialog()">新增调整</el-button>
              </div>
            </div>
            <el-table :data="adjustments" border stripe size="small" style="margin-top:12px" v-loading="adjustmentLoading">
              <el-table-column prop="year" label="年份" width="90" align="center"></el-table-column>
              <el-table-column prop="flowDate" label="日期" width="130" align="center"></el-table-column>
              <el-table-column label="调整后国省道车流(万辆次)" width="230" align="right">
                <template slot-scope="s">{{ formatAdjustmentValue(s.row.adjustedValue) }}</template>
              </el-table-column>
              <el-table-column prop="reason" label="调整原因" min-width="280" show-overflow-tooltip></el-table-column>
              <el-table-column label="更新时间" min-width="170"><template slot-scope="s">{{ formatDateTime(s.row.updatedAt) }}</template></el-table-column>
              <el-table-column v-if="operatorIsAdmin" label="操作" width="130" align="center">
                <template slot-scope="s">
                  <el-button type="text" @click="openAdjustmentDialog(s.row)">编辑</el-button>
                  <el-button type="text" class="danger-text" @click="deleteAdjustment(s.row)">删除</el-button>
                </template>
              </el-table-column>
            </el-table>
            <el-dialog :title="adjustmentForm.id ? '编辑调整' : '新增调整'" :visible.sync="adjustmentDialogVisible" width="560px" v-dialog-drag>
              <el-form label-width="120px">
                <el-form-item label="年份">
                  <el-select v-model="adjustmentForm.year" style="width:100%" @change="adjustmentForm.flowDate = ''">
                    <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
                  </el-select>
                </el-form-item>
                <el-form-item label="日期">
                  <el-date-picker v-model="adjustmentForm.flowDate" type="date" value-format="yyyy-MM-dd"
                                  style="width:100%" :picker-options="adjustmentDatePickerOptions" placeholder="选择日期"></el-date-picker>
                </el-form-item>
                <el-form-item label="调整后车流量">
                  <el-input-number v-model="adjustmentForm.adjustedValue" :min="0" :precision="4" :step="0.0001" controls-position="right" style="width:100%"></el-input-number>
                </el-form-item>
                <el-form-item label="调整原因">
                  <el-input v-model="adjustmentForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit></el-input>
                </el-form-item>
              </el-form>
              <div slot="footer">
                <el-button @click="adjustmentDialogVisible = false">取消</el-button>
                <el-button type="primary" :loading="adjustmentSaving" @click="saveAdjustment">保存</el-button>
              </div>
            </el-dialog>
          </el-card>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">
              <span>观测站分日调查数据</span>
              <span class="hint">用于原始数据留存,不与上方节假日统计数据关联</span>
            </div>
@@ -188,19 +243,41 @@
      observationDialogVisible: false,
      observationDialogDate: '',
      observationRows: [],
      observationRowsLoading: false
      observationRowsLoading: false,
      adjustmentYear: new Date().getFullYear() - 1,
      adjustments: [],
      adjustmentLoading: false,
      adjustmentDialogVisible: false,
      adjustmentSaving: false,
      adjustmentForm: {
        id: null,
        year: new Date().getFullYear() - 1,
        flowDate: '',
        adjustedValue: null,
        reason: ''
      }
    }
  },
  computed: {
    holidayTypeLabel() {
      const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
      return item ? item.label : this.holidayType
    },
    availableAdjustmentYears() {
      const currentYear = new Date().getFullYear()
      return this.years.filter(item => item < currentYear)
    },
    adjustmentDatePickerOptions() {
      return {
        disabledDate: date => date.getFullYear() !== Number(this.adjustmentForm.year)
      }
    }
  },
  mounted() {
    this.loadOptions()
    this.loadOverview()
    this.loadObservationDates()
    this.loadAdjustments()
  },
  methods: {
    previousDateString() {
@@ -225,6 +302,72 @@
        })
        .catch(err => this.showError(err, '节假日数据加载失败'))
        .finally(() => { this.loading = false })
    },
    loadAdjustments() {
      this.adjustmentLoading = true
      api.get('/holiday/adjustments', { params: { year: this.adjustmentYear } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.adjustments = res.data || []
        })
        .catch(err => this.showError(err, '往年国省道数据调整加载失败'))
        .finally(() => { this.adjustmentLoading = false })
    },
    openAdjustmentDialog(row) {
      const source = row || {}
      this.adjustmentForm = {
        id: source.id || null,
        year: source.year || this.adjustmentYear,
        flowDate: source.flowDate || '',
        adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
          ? null : Number(source.adjustedValue),
        reason: source.reason || ''
      }
      this.adjustmentDialogVisible = true
    },
    saveAdjustment() {
      const form = this.adjustmentForm
      if (!form.year || !form.flowDate) {
        this.$message.warning('请选择年份和日期')
        return
      }
      if (form.adjustedValue === null || form.adjustedValue === undefined || Number(form.adjustedValue) < 0) {
        this.$message.warning('请输入不低于 0 çš„调整后车流量')
        return
      }
      if (!form.reason || !form.reason.trim()) {
        this.$message.warning('请填写调整原因')
        return
      }
      this.adjustmentSaving = true
      api.post('/holiday/adjustments/save', {
        id: form.id,
        year: form.year,
        flowDate: form.flowDate,
        adjustedValue: form.adjustedValue,
        reason: form.reason
      })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '保存失败')
          this.adjustmentDialogVisible = false
          this.adjustmentYear = form.year
          this.loadAdjustments()
          this.loadOverview()
          this.$message.success('调整已保存')
        })
        .catch(err => this.showError(err, '保存失败'))
        .finally(() => { this.adjustmentSaving = false })
    },
    deleteAdjustment(row) {
      this.$confirm('确定删除 ' + row.flowDate + ' çš„国省道数据调整吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/adjustments/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res && res.code !== 200) throw new Error(res.message || '删除失败')
          this.loadAdjustments()
          this.loadOverview()
          this.$message.success('调整已删除')
        })
        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '删除失败') })
    },
    onFileChange(file) {
      this.selectedFile = file.raw
@@ -433,6 +576,16 @@
        })
        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '删除失败') })
    },
    formatAdjustmentValue(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(4)
    },
    formatDateTime(value) {
      if (!value) return '—'
      return String(value).replace('T', ' ').slice(0, 19)
    },
    formatNumber(value) {
      if (value === null || value === undefined || value === '') return '—'
      return Number(value).toFixed(2)