8个文件已添加
16个文件已修改
1283 ■■■■■ 已修改文件
HANDOFF.md 83 ●●●●● 补丁 | 查看 | 原始文档 | 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/database.md 4 ●●●●● 补丁 | 查看 | 原始文档 | 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 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java 26 ●●●●● 补丁 | 查看 | 原始文档 | 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 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,87 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
## æœ€æ–°ï¼ˆ2026-10-06)
### 10-06:折线图纵向间距加高(验收通过)
- **用户背景**:客运量图线间距较好、标签摆放合理;其他多序列折线图因线距偏近,标签仍可能压线。用户同意适当增加图高,Word åªæ˜¯å–图来源,不要求当前分页为最终版,但图不能过高。
- **实现**:
  - `HolidayTrendChartService`:折线纵轴留白收紧为 `lowerMargin=0.04 / upperMargin=0.07`;`LINE_CHART_HEIGHT_SCALE=1.20`,所有折线 PNG é«˜åº¦æ”¾å¤§çº¦ 20%。
  - `HolidayTrendService`:12 å¼ æŠ˜çº¿å›¾çš„ Word å†…嵌图片高度同步放大约 20%,保持 PNG ä¸Ž Word å›¾ç‰‡æ¡†æ¯”例一致;饼图、堆叠图未改。
  - `HolidayTrendChartServiceTest`:新增折线图高度缩放断言。
- **验收**:用户于 2026-10-06 æŸ¥çœ‹ `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(折线加高20实验).docx` åŽéªŒæ”¶é€šè¿‡ã€‚
- **验证结果**:
  1. 15% åŠ é«˜ç‰ˆåœ¨é“è·¯å®¢è¿é‡å›¾ `image5` æ£€å‡º OCR æ ‡ç­¾æ¡†é‡å ï¼›20% ç‰ˆ 12 å¼ æŠ˜çº¿å›¾ï¼ˆ`image1/2/4/5/6/7/8/9/10/11/14/15`)未检出标签框重叠。
  2. PNG å®½é«˜æ¯”与 Word `wp:extent` å®½é«˜æ¯”最大误差约 `0.162%`,未发生明显拉伸。
  3. Word ä»ä¸º 8 é¡µã€æ¯é¡µ 2 å¼ å›¾ï¼ˆé¦–页 1 å¼ ï¼‰ï¼Œä¸Ž 15% åŠ é«˜å®žéªŒç›¸åŒã€‚
  4. åŽç«¯å…¨é‡ `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›`HolidayTrendChartServiceTest` èšç„¦æµ‹è¯•通过。
  5. æ ‡ç­¾äº²å’Œåº¦æ£€æŸ¥è®°å½•到 3 å¤„标签框更靠近邻近线(`image1` çš„ `1,518.02`、`image4` çš„ `1,346.17`、`image9` çš„ `248.65`);白描边保证可读,用户整体验收通过并保留该排版策略。
  6. `image13` å †å å›¾æœ‰ 4 å¤„ OCR æ ‡ç­¾é‡å ï¼Œä½†è¯¥å›¾æœªå‚与本实验且代码未改。
- **数据边界**:未导入、覆盖或重导任何业务数据;仅本地只读生成走势图做实验。
### 10-06:平均拥挤度标签恢复为 2 ä½å°æ•°
- **调整**:按用户要求,“普通国省道观测站平均拥挤度”不再单独使用 3 ä½å°æ•°ï¼Œæ¢å¤ä¸ºæŠ˜çº¿å›¾é»˜è®¤ 2 ä½ã€‚
- **一致性**:由于折线图现已统一按显示标签精度绘制,10-04、10-05 çš„æ‹¥æŒ¤åº¦éƒ½ä¼šæ˜¾ç¤ºå¹¶ç»˜åˆ¶ä¸º `0.49`,不会再出现同标签不同高度。
- **验证**:后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚
### 10-06:折线图统一按显示标签精度绘制
- **口径(用户 2026-10-06 ç¡®è®¤ï¼‰**:同一张折线图中,同一个显示标签必须对应同一个点位高度;分析文字不得引用比该图显示精度更细的差异。
- **实现**:`HolidayTrendChartService.lineChart` åœ¨æ‰€æœ‰åŽŸå§‹ç»Ÿè®¡è®¡ç®—å®ŒæˆåŽï¼ŒäºŽæœ€ç»ˆå…¥å›¾è¾¹ç•Œè°ƒç”¨ `displayValue`;普通图按该图 `valueDecimals` åš `HALF_UP` èˆå…¥ï¼Œç™¾åˆ†æ¯”点标签按 1 ä½ç™¾åˆ†æ¯”精度(即原始小数 3 ä½ï¼‰èˆå…¥ã€‚折线坐标和点标签共用同一舍入值,纵轴格式沿用该图精度。
- **边界**:只在最终图表显示层舍入,不改数据库、不改累计、快报、同比、环比或调整口径;该规则用于静态分析报告图,不能让文字引用图上无法分辨的微小差异。
- **验证**:`HolidayTrendChartServiceTest` èšç„¦æµ‹è¯•通过;后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚新增测试覆盖“两个不同原值按 2 ä½æ ‡ç­¾èˆå…¥åŽç”Ÿæˆç›¸åŒå‡ ä½•”和百分比精度换算。测试报告:`docs/功能测试报告/功能测试报告_折线图按标签精度绘制_2026-10-06.md`(本地留存,不入库)。
### 10-06:观测站平均拥挤度走势图改为 3 ä½å°æ•°
- **问题**:用户导入 10-05 è§‚测站数据后反馈,最后一张拥挤度图中 10-04、10-05 æ ‡ç­¾éƒ½æ˜¾ç¤º `0.49`,但点位高度不同。
- **根因**:平均拥挤度原值分别为 `0.490890014472` å’Œ `0.492414492754`,折线按未取整原值绘制,标签原为最多 2 ä½å°æ•°ï¼Œå¯¼è‡´æ ‡ç­¾ç²¾åº¦ä¸Žå›¾å½¢ç²¾åº¦ä¸ä¸€è‡´ï¼Œä¸æ˜¯æ—¥æ±‡æ€»æˆ–点位计算错误。
- **修正**:`HolidayTrendChartService.lineChart` æ–°å¢žå¯é€‰ `valueDecimals`,默认仍为 2;仅“普通国省道观测站平均拥挤度”图传 3 ä½ã€‚10-04、10-05 çŽ°åœ¨åˆ†åˆ«æ˜¾ç¤º `0.491`、`0.492`,其余图口径不变。
- **验证**:后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›é‡å¯åŽåªè¯»ç”Ÿæˆæ–° DOCX,OCR æ ‡ç­¾ä¸º `0.504/0.476/0.467/0.491/0.492`;与用户提供的文档逐图比对,15 å¼ å›¾ä¸­ä»…拥挤度图 `image15` å˜åŒ–,其他 14 å¼  SHA-256 å®Œå…¨ä¸€è‡´ã€‚测试报告:`docs/功能测试报告/功能测试报告_拥挤度走势图精度_2026-10-06.md`(本地留存,不入库)。
- **数据边界**:未导入、覆盖或重导任何业务数据。
### 10-06:往年国省道调整列表补充调整前值
- **改动**:调整列表接口在同一年份、同一日期、普通国省道口径下,从 `holiday_vehicle_flow` è¡¥è¿”回 `originalValue`;没有原始记录时保持空,页面不显示占位符。表格新增“调整前国省道车流量(万辆次)”,原“调整后国省道车流”改为“调整后国省道车流量”。
- **弹窗**:新增/编辑弹窗同步增加只读“调整前国省道车流量(万辆次)”;编辑已有记录时展示 `originalValue`,无原值或新选择日期时不显示内容。切换年份或日期会清空旧原值,避免错日展示。
- **随日期刷新**:新增接口 `GET /api/holiday/adjustments/source-value`,按所选年份和日期实时读取普通国省道原值;新增/编辑弹窗选日期后自动刷新,未查到源数据时显示为空。前端用请求序号丢弃迟到的旧请求,避免快速换日期时串值。
- **边界**:`originalValue` åªæ˜¯åˆ—表展示字段,标记为 `@TableField(exist = false)`,不写库、不参与调整、环比分母、累计或走势图计算。
- **验证**:`mvn -o test` 11 ä¸ªæµ‹è¯•全部通过;`npm run build` é€šè¿‡ï¼›æœ¬åœ°é‡å¯åŽåªè¯»æ ¸å¯¹ 2025-10-03/04/05 åŽŸå€¼åˆ†åˆ«ä¸º `362.0035/337.9501/334.2040`,无源数据的 2025-10-09 è¿”回空。测试报告:`docs/功能测试报告/功能测试报告_往年国省道调整原值展示_2026-10-06.md`(本地留存,不入库)。
### 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` é€šè¿‡ï¼›é¡µé¢å®žæµ‹â€œå¾€å¹´å›½çœé“数据调整”卡片与新增弹窗正常。
- **取整口径(用户 2026-10-06 ç¡®è®¤ï¼‰**:不做“从原始值一次取整”的备选。唯一要求是与 Excel æ˜¾ç¤ºå€¼ä¸€è‡´ï¼šå…ˆæŒ‰æº Excel å•元格格式生成显示值,再按快报统一 1 ä½å°æ•°ã€‚10-04 æ€»è®¡çŽ¯æ¯”åŽŸå§‹å€¼çº¦ `4.5481%`,源格显示 `4.55%`,快报取 1 ä½å¾—到 `4.6%`;这属于既定口径,不是需要修正的边界问题。
> è¯´æ˜Žï¼šæœ¬æ–‡ä»¶æ—©å‰â€œçŽ¯æ¯”åŸºæœŸåªä½œå¤‡ç”¨ã€ä¸å‚ä¸Žè®¡ç®—â€çš„æ®µè½å·²ä½œåºŸã€‚è¯¥ç‰ˆæœ¬é’ˆå¯¹çš„æ˜¯æ—§å®žçŽ°è¯¯æŠŠ 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%)。
- **新行为(本次已改)**:国省道环比固定「当日 / å‰ä¸€æ—¥ - 1」,2024/2025/2026 ä¸€è‡´ï¼›`comparison_base` åªåœ¨å¯¼å…¥/导出/页面展示时原样保存与回写,不参与环比、折算、累计。导出 E åˆ—改为 `=(D{行}-D{上一日行})/D{上一日行}`,C åˆ—照常回写备用值。
- **改动**:`HolidayStatService.java`(`nationalProvincialRingRatio` åŽ»æŽ‰ year/comparisonBase å½¢å‚)、`HolidayWorkbookService.java`(新增 `ringRatioFormula`,导出按上一行同年 D åˆ—)、`docs/database.md`(`comparison_base` ä¸Žè®¡ç®—口径两处)。
- **验证**:新增 `HolidayComparisonBaseTest`:① åº“内存 `comparison_base=320.2418` æ—¶ï¼Œ10-04 å›½çœé“环比仍按前一日 323.7837 è®¡ç®—,且不等于按基期算出的值;② å¯¼å‡ºå…¬å¼ = `=(D6-D5)/D5`。`mvn -o test` å…¨ç»¿ï¼ˆ4 ä¸ªæµ‹è¯•类)。
### 10-06:待确认——10-04 å›½çœé“剔站(鄂州樊家庄)怎么进系统
- **手工口径已逐项核对,与库内 `observation_station_flow` å®Œå…¨å¯¹å¾—上**:
  - 10-04 å›½çœé“值(2026/2025/2024)= åŽŸå€¼ âˆ’ æ¨Šå®¶åº„当日值 -> 322.7695 / 337.5009 / 322.5277 ä¸‡è¾†æ¬¡ï¼ˆæ¨Šå®¶åº„ 8856 / 4492 / 2328)。
  - 10-04 çŽ¯æ¯”åˆ†æ¯ = 2026-10-03 åŽŸå€¼ âˆ’ æ¨Šå®¶åº„ 10-03 å€¼ = 323.7837 âˆ’ 3.5419 = **320.2418** ä¸‡è¾†æ¬¡ -> çŽ¯æ¯” = 322.7695/320.2418 âˆ’ 1 = **+0.79%**。
  - 10-03 æ˜¾ç¤ºå€¼ä¸Žç´¯è®¡ä»ç”¨åŽŸå€¼ 323.7837,被剔的 320.2418 åªè½åˆ° C åˆ—备用。
- **趋势结论(需先跟领导对齐)**:只剔樊家庄,10-04 å›½çœé“环比只有 **+0.79%**;同日高速小客车 **+9.78%**、铁路 +9.94%、民航 +11.69%、道路 +4.99%、水路 +4.33%。**剔一站填不平约 9 ä¸ªç™¾åˆ†ç‚¹çš„趋势差**,与「让国省道和高速趋势一致」的预期并不相符,建议同时请鄂州核实原始记录。
- **待决策的代码方案**:因 `comparison_base` å·²å®šä¸ºçº¯å¤‡ç”¨ï¼Œç³»ç»Ÿæ— æ³•从「当日值」反推剔站基期,需要另设机制。推荐新增「异常站剔除」配置(站点编号 + ç”Ÿæ•ˆæ—¥æœŸ + å¹´ä»½ï¼‰ï¼Œç®—环比分母时用 `observation_station_flow` çš„「该日站点合计 âˆ’ å‰”站值」作基期;显示值与累计仍用原值。
- **决策点**:① åšæˆå¯é…ç½®çš„通用「异常站剔除」(推荐,可复用于其他节假日/站点),还是只按本次 10-04 ä¸€æ¬¡æ€§å¤„理;② å‰”站只影响国省道环比分母,还是也要下沉到非营业性小客车/公路/全社会的环比(手工稿实际把 4.17% / 4.55% ä¹Ÿä¸€èµ·æ”¹äº†ï¼‰ã€‚
## æœ€æ–°ï¼ˆ2026-10-05)
### 10-05 ä¸Šåˆï¼šå…³æœºå¿«ç…§ï¼ˆå½“前状态)
@@ -286,7 +367,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/database.md
@@ -526,7 +526,7 @@
| road_category | varchar(32) | é«˜é€Ÿå…¬è·¯ EXPRESSWAY / æ™®é€šå›½çœé“ NATIONAL_PROVINCIAL |
| vehicle_type | varchar(32) | å°å®¢è½¦ SMALL_PASSENGER / ä¸­å°å®¢è½¦ MEDIUM_SMALL_PASSENGER |
  | vehicle_count | decimal(30,15) | è½¦æµé‡ï¼ˆä¸‡è¾†æ¬¡ï¼‰ |
  | comparison_base | decimal(30,15) | æ™®é€šå›½çœé“环比基期(万辆次,仅当年使用) |
  | comparison_base | decimal(30,15) | æ™®é€šå›½çœé“环比基期(万辆次,人工备用值,仅 2026 å¹´å¯ç”¨ã€ä¸å‚与计算) |
  | created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, flow_date, road_category, vehicle_type)`。
@@ -544,7 +544,7 @@
### è®¡ç®—与导入导出口径
  - éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ =(高速车流 + æ™®é€šå›½çœé“车流)נ当年节假日客运系数。
  - 2026 å¹´æ™®é€šå›½çœé“如填写环比基期,则只影响普通国省道车流量自身环比:填写时按“当日 / çŽ¯æ¯”åŸºæœŸ - 1”,未填写时按“当日 / å‰ä¸€æ—¥ - 1”;折算人员流动量、总人员流动量等继续按 Excel åŽŸå…¬å¼è®¡ç®—ã€‚2024、2025 åŽ†å²æ•°æ®ä¸ä½¿ç”¨è¯¥å­—æ®µã€‚
  - æ™®é€šå›½çœé“环比固定按“当日 / å‰ä¸€æ—¥ - 1”计算;2024/2025/2026 å„年一致。`comparison_base`(Excel æ™®é€šå›½çœé“表 C åˆ—“环比基期”)自 2026-10-05 èµ·ä»…作人工备用:导入导出原样保存/回写,不参与环比、折算或累计;导出时 C åˆ—照常写出,E åˆ—“环比”公式始终引用上一行同年 D åˆ—。
  - å…¬è·¯äººå‘˜æµåŠ¨é‡ = é“路营业性客运量 + éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ã€‚
- æ€»äººå‘˜æµåŠ¨é‡ = å…¬è·¯ + é“è·¯ + æ°‘航 + æ°´è·¯ã€‚
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` ä¸­ `day_index>=1` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;同时读取当前年份铁路、民航的“前一日”行,写入 `day_index=0` å¯¹åº”的日期。
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,71 @@
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.math.BigDecimal;
import java.time.LocalDate;
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));
    }
    @GetMapping("/source-value")
    public Result<BigDecimal> sourceValue(@RequestParam("year") Integer year,
                                         @RequestParam("flowDate") String flowDate) {
        try {
            return Result.ok(adjustmentService.findOriginalValue(year, LocalDate.parse(flowDate)));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @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,26 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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;
    @TableField(exist = false)
    private BigDecimal originalValue;
    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(
                    year, nationalProvincial, previousNationalProvincial, comparisonBase);
            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,23 +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;
    }
    private BigDecimal nationalProvincialRingRatio(int year, BigDecimal current,
                                                   BigDecimal previous, BigDecimal comparisonBase) {
        if (current == null) return null;
        if (HolidayConstants.supportsComparisonBase(year) && comparisonBase != null) {
            return ratio(current, comparisonBase);
        }
        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/HolidayTrendChartService.java
@@ -49,6 +49,7 @@
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
@@ -76,6 +77,8 @@
    private static final double RIGHT_LABEL_BAND = 74.0;
    private static final double ITEM_LABEL_GAP = 10.0;
    private static final float HALO_OUTLINE_WIDTH = 4.0f;
    private static final int PERCENT_ITEM_LABEL_DECIMALS = 1;
    private static final double LINE_CHART_HEIGHT_SCALE = 1.20;
    private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
    private static final Color[] SERIES_COLORS = {
            new Color(192, 80, 77),
@@ -95,6 +98,11 @@
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int width, int height) {
        return lineChart(yAxisLabel, series, percentAxis, 2, width, height);
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, int width, int height) {
        XYSeriesCollection dataset = new XYSeriesCollection();
        for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
            ChartSeries item = series.get(seriesIndex);
@@ -102,7 +110,7 @@
            for (int i = 0; i < item.getValues().size(); i++) {
                BigDecimal value = item.getValues().get(i);
                if (value != null) {
                    xySeries.add(i, value.doubleValue());
                    xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue());
                }
            }
            dataset.addSeries(xySeries);
@@ -134,8 +142,8 @@
        NumberAxis rangeAxis = new NumberAxis();
        rangeAxis.setAutoRangeIncludesZero(false);
        rangeAxis.setLowerMargin(0.12);
        rangeAxis.setUpperMargin(0.16);
        rangeAxis.setLowerMargin(0.04);
        rangeAxis.setUpperMargin(0.07);
        rangeAxis.setTickLabelFont(TICK_FONT);
        rangeAxis.setLabelFont(LABEL_FONT);
        rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
@@ -148,7 +156,7 @@
        }
        NumberFormat valueFormat = percentAxis
                ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2);
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals);
        rangeAxis.setNumberFormatOverride(valueFormat);
        plot.setRangeAxis(rangeAxis);
        plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
@@ -166,7 +174,8 @@
                    BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        }
        plot.setRenderer(renderer);
        return render(chart, width, height, yAxisLabel, true, percentAxis);
        return render(chart, width, scaleLineChartHeight(height),
                yAxisLabel, true, percentAxis, valueDecimals);
    }
    public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
@@ -212,7 +221,7 @@
        for (ChartCategory category : categories) {
            plot.setSectionPaint(category.getName(), seriesColor(index++));
        }
        return render(chart, width, height, valueSuffix, false, false);
        return render(chart, width, height, valueSuffix, false, false, 2);
    }
    public byte[] stackedBarChart(String yAxisLabel, List<String> categories,
@@ -282,7 +291,7 @@
            renderer.setSeriesPaint(i, seriesColor(i));
        }
        plot.setRenderer(renderer);
        return render(chart, width, height, yAxisLabel, true, percentAxis);
        return render(chart, width, height, yAxisLabel, true, percentAxis, 2);
    }
    private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> {
@@ -445,7 +454,7 @@
    }
    private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
                         boolean axisChart, boolean percentItemLabels) {
                         boolean axisChart, boolean percentItemLabels, int valueDecimals) {
        try {
            reserveUnitLabelBand(chart, axisChart, unitLabel);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -465,7 +474,7 @@
                    drawDateLabel(graphics, info.getPlotInfo().getDataArea());
                }
                drawXYItemLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel);
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
                drawStackedBarLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels);
            } finally {
@@ -830,7 +839,7 @@
    private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                  Rectangle2D dataArea, boolean percentItemLabels,
                                  String unitLabel) {
                                  String unitLabel, int valueDecimals) {
        if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
@@ -859,7 +868,7 @@
                Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
                if (yValue == null || point == null) continue;
                labels.add(new DayLabel(seriesIndex, point,
                        formatLineLabel(yValue, percentItemLabels)));
                        formatLineLabel(yValue, percentItemLabels, valueDecimals)));
            }
            // A day's labels are stacked in the same top-to-bottom order as its data
            // points, so a label never looks attached to a neighbouring line.
@@ -1108,13 +1117,29 @@
        return total;
    }
    private String formatLineLabel(Number value, boolean percentItemLabels) {
    static int scaleLineChartHeight(int height) {
        return (int) Math.round(height * LINE_CHART_HEIGHT_SCALE);
    }
    static long scaleLineChartHeight(long height) {
        return Math.round(height * LINE_CHART_HEIGHT_SCALE);
    }
    static BigDecimal displayValue(BigDecimal value, boolean percentItemLabels, int valueDecimals) {
        if (value == null) return null;
        int scale = percentItemLabels ? PERCENT_ITEM_LABEL_DECIMALS + 2 : valueDecimals;
        return value.setScale(scale, RoundingMode.HALF_UP);
    }
    private String formatLineLabel(Number value, boolean percentItemLabels, int valueDecimals) {
        if (percentItemLabels) {
            NumberFormat formatter = NumberFormat.getPercentInstance();
            formatter.setMaximumFractionDigits(1);
            formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS);
            return formatter.format(value.doubleValue());
        }
        return new DecimalFormat("#,##0.##").format(value.doubleValue());
        DecimalFormat formatter = new DecimalFormat("#,##0");
        formatter.setMaximumFractionDigits(valueDecimals);
        return formatter.format(value.doubleValue());
    }
    private void drawLabelWithHalo(Graphics2D graphics, String text, Rectangle2D rectangle,
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
@@ -64,12 +64,12 @@
                prefix + "全省跨区域人员流动量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.totalDaily), false, 1100, 715),
                new HolidayTrendWordService.ImageSize(5268595L, 3424555L)));
                lineImageSize(5268595L, 3424555L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期累计跨区域人员流动量走势",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.totalCumulative), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期各交通运输方式客运量占比情况",
                chartService.pieChart("人次", selected.modalShare(), 1000, 607), true,
@@ -80,44 +80,44 @@
                "“" + holidayLabel + "”假期自驾出行量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.selfDrivingPersonFlow), false, 1100, 522),
                new HolidayTrendWordService.ImageSize(5232400L, 2484120L)));
                lineImageSize(5232400L, 2484120L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期铁路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.rail), false, 1100, 445),
                new HolidayTrendWordService.ImageSize(5264785L, 2129155L)));
                lineImageSize(5264785L, 2129155L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期水路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.water), false, 1100, 433),
                new HolidayTrendWordService.ImageSize(5274310L, 2075815L)));
                lineImageSize(5274310L, 2075815L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期民航客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.civilAviation), false, 1100, 521),
                new HolidayTrendWordService.ImageSize(5271135L, 2496185L)));
                lineImageSize(5271135L, 2496185L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期道路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.road), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.SectionBlock("三、车流量走势"));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期高速公路车流量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.expressway), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期普通国省道车流量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.nationalProvincial), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期全省车流总量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.totalVehicleFlow), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期公路出行结构",
                chartService.stackedBarChart("万人次",
@@ -139,12 +139,12 @@
                prefix + "普通国省道观测站平均机动车速度",
                chartService.lineChart("公里/小时",
                        Collections.singletonList(series("平均速度", observation.speed)), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均拥挤度",
                chartService.lineChart("拥挤度",
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 1100, 590),
                new HolidayTrendWordService.ImageSize(5268595L, 2821305L)));
                lineImageSize(5268595L, 2821305L)));
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
@@ -347,6 +347,11 @@
        return result;
    }
    private HolidayTrendWordService.ImageSize lineImageSize(long width, long height) {
        return new HolidayTrendWordService.ImageSize(
                width, HolidayTrendChartService.scaleLineChartHeight(height));
    }
    private File resolveTemplateFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(trendTemplatePath);
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java
New file
@@ -0,0 +1,177 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
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.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class HolidayVehicleAdjustmentService {
    private static final int REASON_MAX_LENGTH = 500;
    @Resource
    private HolidayVehicleAdjustmentMapper adjustmentMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    public List<HolidayVehicleAdjustment> list(Integer year) {
        List<HolidayVehicleAdjustment> rows = adjustmentMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>()
                        .eq(year != null, HolidayVehicleAdjustment::getYear, year)
                        .orderByDesc(HolidayVehicleAdjustment::getYear)
                        .orderByAsc(HolidayVehicleAdjustment::getFlowDate));
        if (rows.isEmpty()) return rows;
        Set<Integer> years = new HashSet<>();
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getYear() != null) years.add(row.getYear());
        }
        if (years.isEmpty()) return rows;
        List<HolidayVehicleFlow> sourceRows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
                        .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
                        .in(HolidayVehicleFlow::getYear, years));
        Map<String, BigDecimal> originalValues = new HashMap<>();
        for (HolidayVehicleFlow sourceRow : sourceRows) {
            if (sourceRow.getYear() == null || sourceRow.getFlowDate() == null || sourceRow.getVehicleCount() == null) {
                continue;
            }
            originalValues.put(sourceKey(sourceRow.getYear(), sourceRow.getFlowDate()), sourceRow.getVehicleCount());
        }
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getYear() != null && row.getFlowDate() != null) {
                row.setOriginalValue(originalValues.get(sourceKey(row.getYear(), row.getFlowDate())));
            }
        }
        return rows;
    }
    public BigDecimal findOriginalValue(Integer year, LocalDate flowDate) {
        if (year == null || flowDate == null || flowDate.getYear() != year) {
            throw new IllegalArgumentException("年份和日期不正确");
        }
        HolidayVehicleFlow sourceRow = vehicleFlowMapper.selectOne(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getFlowDate, flowDate)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
                        .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
                        .last("limit 1"));
        return sourceRow == null ? null : sourceRow.getVehicleCount();
    }
    @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 String sourceKey(Integer year, LocalDate flowDate) {
        return year + "|" + flowDate;
    }
    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/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
@@ -568,6 +568,9 @@
                setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
            }
            if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
                // Column C (comparison base) is written as an operator backup value only. The ring
                // ratio in column E always compares the current day with the previous day row of
                // the same year, so the base never changes the calculated percentage.
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
@@ -575,16 +578,27 @@
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
                }
                Integer previousRowIndex = null;
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    int rowIndex = row.getRowNum();
                    int excelRow = rowIndex + 1;
                    setFormula(sheet, rowIndex, 4, "=(D" + excelRow + "-C" + excelRow + ")/C" + excelRow);
                    if (row == null) {
                        previousRowIndex = null;
                        continue;
                    }
                    if (previousRowIndex != null) {
                        setFormula(sheet, row.getRowNum(), 4,
                                ringRatioFormula(row.getRowNum() + 1, previousRowIndex + 1));
                    }
                    previousRowIndex = row.getRowNum();
                }
            }
        }
    }
    // Ring ratio = (current day value - previous day value) / previous day value, always taken
    // from the active year column (D). This intentionally ignores the comparison base column.
    static String ringRatioFormula(int excelRow, int previousExcelRow) {
        return "=(D" + excelRow + "-D" + previousExcelRow + ")/D" + previousExcelRow;
    }
    private void fillCoefficients(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
@@ -1013,7 +1027,9 @@
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        cell.setCellFormula(formula);
        // POI's setCellFormula rejects a leading '='; the helper accepts either form.
        String normalized = formula.startsWith("=") ? formula.substring(1) : formula;
        cell.setCellFormula(normalized);
    }
    private Map<String, Object> toMap(HolidayCalendar calendar) {
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
New file
@@ -0,0 +1,181 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.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;
import java.lang.reflect.Field;
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;
/**
 * 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 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(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);
        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 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(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 roadCategory, String value, String base) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        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<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(year);
            day.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            day.setDayIndex(i);
            day.setHolidayDate(LocalDate.of(year, 10, i));
            days.add(day);
        }
        return days;
    }
    private static void inject(Object target, String field, Object value) throws Exception {
        Field declared = target.getClass().getDeclaredField(field);
        declared.setAccessible(true);
        declared.set(target, value);
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -5,7 +5,11 @@
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -40,6 +44,76 @@
        assertNoLargeLightGrayBackground(stacked);
    }
    @Test
    void roundsLineDataToTheSamePrecisionAsLabels() {
        assertEquals(new BigDecimal("0.49"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4904"), false, 2));
        assertEquals(new BigDecimal("0.49"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 2));
        assertEquals(new BigDecimal("0.491"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 3));
        assertEquals(new BigDecimal("0.123"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.1234"), true, 2));
    }
    @Test
    void rendersSameGeometryWhenValuesRoundToSameLabel() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries();
        first.setName("平均拥挤度");
        first.setValues(java.util.Arrays.asList(
                new BigDecimal("0.4904"), new BigDecimal("0.4923"), new BigDecimal("0.5100")));
        HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries();
        second.setName("平均拥挤度");
        second.setValues(java.util.Arrays.asList(
                new BigDecimal("0.4911"), new BigDecimal("0.4934"), new BigDecimal("0.5100")));
        byte[] firstChart = service.lineChart("拥挤度",
                java.util.Collections.singletonList(first), false, 2, 1100, 590);
        byte[] secondChart = service.lineChart("拥挤度",
                java.util.Collections.singletonList(second), false, 2, 1100, 590);
        assertPng(firstChart);
        assertPng(secondChart);
        assertArrayEquals(firstChart, secondChart);
    }
    @Test
    void scalesLineChartHeightForBetterVerticalSeparation() throws Exception {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries();
        series.setName("测试");
        series.setValues(java.util.Arrays.asList(
                new BigDecimal("1.00"), new BigDecimal("2.00"), new BigDecimal("3.00")));
        byte[] png = service.lineChart("万人次",
                java.util.Collections.singletonList(series), false, 1100, 590);
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(png));
        assertNotNull(image);
        assertEquals(HolidayTrendChartService.scaleLineChartHeight(590), image.getHeight());
    }
    @Test
    void rendersHigherPrecisionLineLabelsWhenRequested() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries();
        congestion.setName("平均拥挤度");
        congestion.setValues(java.util.Arrays.asList(
                new java.math.BigDecimal("0.467029"),
                new java.math.BigDecimal("0.490890"),
                new java.math.BigDecimal("0.492414")));
        byte[] twoDecimals = service.lineChart("拥挤度",
                java.util.Collections.singletonList(congestion), false, 2, 1100, 590);
        byte[] threeDecimals = service.lineChart("拥挤度",
                java.util.Collections.singletonList(congestion), false, 3, 1100, 590);
        assertPng(twoDecimals);
        assertPng(threeDecimals);
        assertFalse(java.util.Arrays.equals(twoDecimals, threeDecimals));
    }
    private void assertNoLargeLightGrayBackground(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        int matches = 0;
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java
New file
@@ -0,0 +1,85 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HolidayVehicleAdjustmentServiceTest {
    @Test
    void listAddsOriginalNationalProvincialValueAndLeavesMissingSourceBlank() throws Exception {
        HolidayVehicleAdjustment adjusted = adjustment("2025-10-04", "337.5009");
        HolidayVehicleAdjustment missingSource = adjustment("2025-10-05", "330.0000");
        HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.class);
        HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
        when(adjustmentMapper.selectList(any())).thenReturn(Arrays.asList(adjusted, missingSource));
        when(vehicleFlowMapper.selectList(any())).thenReturn(Collections.singletonList(
                vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000")));
        HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
        inject(service, "adjustmentMapper", adjustmentMapper);
        inject(service, "vehicleFlowMapper", vehicleFlowMapper);
        List<HolidayVehicleAdjustment> rows = service.list(2025);
        assertEquals(0, new BigDecimal("350.0000").compareTo(rows.get(0).getOriginalValue()));
        assertNull(rows.get(1).getOriginalValue());
    }
    @Test
    void findOriginalValueReturnsMatchedValueAndNullWhenMissing() throws Exception {
        HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
        when(vehicleFlowMapper.selectOne(any()))
                .thenReturn(vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "337.9501"))
                .thenReturn(null);
        HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
        inject(service, "vehicleFlowMapper", vehicleFlowMapper);
        assertEquals(0, new BigDecimal("337.9501")
                .compareTo(service.findOriginalValue(2025, LocalDate.of(2025, 10, 4))));
        assertNull(service.findOriginalValue(2025, LocalDate.of(2025, 10, 5)));
        assertThrows(IllegalArgumentException.class,
                () -> service.findOriginalValue(2025, LocalDate.of(2024, 10, 4)));
    }
    private static HolidayVehicleAdjustment adjustment(String date, String adjustedValue) {
        HolidayVehicleAdjustment row = new HolidayVehicleAdjustment();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setAdjustedValue(new BigDecimal(adjustedValue));
        return row;
    }
    private static HolidayVehicleFlow vehicleFlow(String date, String roadCategory, String value) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setRoadCategory(roadCategory);
        row.setVehicleCount(new BigDecimal(value));
        return row;
    }
    private static void inject(Object target, String fieldName, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }
}
traffic-audit-web/src/views/HolidayStat.vue
@@ -84,6 +84,69 @@
          <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="240" align="right">
                <template slot-scope="s">{{ formatOriginalAdjustmentValue(s.row.originalValue) }}</template>
              </el-table-column>
              <el-table-column label="调整后国省道车流量(万辆次)" width="240" 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="220px">
                <el-form-item label="年份">
                  <el-select v-model="adjustmentForm.year" style="width:100%" @change="onAdjustmentYearChange">
                    <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="选择日期"
                                  @change="onAdjustmentDateChange"></el-date-picker>
                </el-form-item>
                <el-form-item label="调整前国省道车流量(万辆次)">
                  <el-input :value="formatOriginalAdjustmentValue(adjustmentForm.originalValue)" readonly
                            :placeholder="adjustmentOriginalLoading ? '查询中...' : ''"></el-input>
                </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 +251,44 @@
      observationDialogVisible: false,
      observationDialogDate: '',
      observationRows: [],
      observationRowsLoading: false
      observationRowsLoading: false,
      adjustmentYear: new Date().getFullYear() - 1,
      adjustments: [],
      adjustmentLoading: false,
      adjustmentDialogVisible: false,
      adjustmentSaving: false,
      adjustmentOriginalLoading: false,
      adjustmentOriginalRequestId: 0,
      adjustmentForm: {
        id: null,
        year: new Date().getFullYear() - 1,
        flowDate: '',
        originalValue: null,
        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 +313,110 @@
        })
        .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 || '',
        originalValue: source.originalValue === undefined || source.originalValue === null
          ? null : Number(source.originalValue),
        adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
          ? null : Number(source.adjustedValue),
        reason: source.reason || ''
      }
      this.adjustmentDialogVisible = true
      this.loadAdjustmentOriginalValue()
    },
    onAdjustmentYearChange() {
      this.adjustmentOriginalRequestId += 1
      this.adjustmentOriginalLoading = false
      this.adjustmentForm.flowDate = ''
      this.adjustmentForm.originalValue = null
    },
    onAdjustmentDateChange() {
      this.loadAdjustmentOriginalValue()
    },
    loadAdjustmentOriginalValue() {
      const year = this.adjustmentForm.year
      const flowDate = this.adjustmentForm.flowDate
      const requestId = ++this.adjustmentOriginalRequestId
      this.adjustmentForm.originalValue = null
      if (!year || !flowDate) {
        this.adjustmentOriginalLoading = false
        return
      }
      this.adjustmentOriginalLoading = true
      api.get('/holiday/adjustments/source-value', { params: { year: year, flowDate: flowDate } })
        .then(res => {
          if (requestId !== this.adjustmentOriginalRequestId) return
          if (res.code !== 200) throw new Error(res.message || '调整前值加载失败')
          this.adjustmentForm.originalValue = res.data === null || res.data === undefined || res.data === ''
            ? null : Number(res.data)
        })
        .catch(err => {
          if (requestId !== this.adjustmentOriginalRequestId) return
          this.adjustmentForm.originalValue = null
          this.showError(err, '调整前值加载失败')
        })
        .finally(() => {
          if (requestId === this.adjustmentOriginalRequestId) this.adjustmentOriginalLoading = false
        })
    },
    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 +625,20 @@
        })
        .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)
    },
    formatOriginalAdjustmentValue(value) {
      if (value === null || value === undefined || value === '') return ''
      return this.formatAdjustmentValue(value)
    },
    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)