xyc
4 小时以前 b509d59af23c6afba086fa3fb4c2afb47ab0becb
feat(节假日): 新增重要节假日专项统计模块
14个文件已修改
29个文件已添加
3599 ■■■■■ 已修改文件
HANDOFF.md 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/Excel浮点数存储.txt 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/init.sql 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_comparison_base_migration.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_decimal_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/功能测试报告/功能测试报告_重要节假日专项统计_2026-10-02.md 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-10-02.md 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 430 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 880 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/main.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/router/index.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Dashboard.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 302 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/RuleManage.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/SystemConfig.vue 295 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/UserManage.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,23 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
## æœ€æ–°ï¼ˆ2026-10-02)
- **新模块(用户已验收,本次提交/推送)**:新增“专项统计-重要节假日”模块,含节假日配置、营业性客流、非营业性车流、客运系数 4 å¼ è¡¨ï¼›ç³»ç»Ÿé…ç½®èœå•ä»… admin å¯ç»´æŠ¤ï¼Œä¸“项统计-节假日支持导入、导出、模板下载和 Word å¿«æŠ¥ä¸‹è½½ã€‚
- **数据初始化**:从 `docs/专项统计/重要节假日/2026年十一数据.xlsx` åˆå§‹åŒ– 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµå’Œ 2024-2026 å¹´å®¢è¿ç³»æ•°ï¼›æºè¡¨ 90 ä¸ªæ•°æ®å•元格入库逐项比对差异 0。
- **日常导入口径**:只读取所选年份的当年列(2026 å¹´ä¸ºå„客流工作表 C åˆ—、普通国省道 D åˆ—);历史年份不要求日常重导。
- **历史数据保护**:导入时先检查更早历史日期是否被修改。有变化则先不写库,返回差异清单,前端弹窗让用户选择“允许导入/不允许”;确认后才覆盖。接口参数 `allowHistoricalOverwrite=true`。
- **快报**:默认下载系统日期前一天的《湖北省国庆假期综合运输客流快报(10月X日).docx》,用户可自选日期;环比/同比按增长、下降、持平输出。
- **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 è¿½åŠ ï¼‰**:假期第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)` åˆ—型、历史变更两阶段导入回归均已实测;报告见 `docs/功能测试报告/功能测试报告_重要节假日专项统计_2026-10-02.md`,日结见 `docs/工作日结/工作日结_2026-10-02.md`。
- **运行状态**:后端 8090、前端 8080 å·²å¯åŠ¨ï¼›æ•°æ®åº“ `127.0.0.1:3305/traffic_audit`。测试产生的 2026 ä¸´æ—¶æ•°æ®å’Œå¯¼å…¥æ‰¹æ¬¡å·²æ¸…理。
- **Git**:用户已于 2026-10-02 æ˜Žç¡®éªŒæ”¶é€šè¿‡ï¼›æœ¬æ¬¡æŒ‰è¦æ±‚合并远端 `origin/main` åŽæäº¤å¹¶æŽ¨é€ï¼Œä¸è¦†ç›–远端同事提交。
## æœ€æ–°ï¼ˆ2026-09-23)
- **已同步**:本地 `main` å¿«è¿›åˆå¹¶å…¬å¸ `origin/main` æœ€æ–° `8b739ce`(同事 09-22 å…³æœºå¿«ç…§ç»Ÿä¸€ç‰ˆï¼‰ï¼Œåˆå¹¶åŽ `HEAD = 8b739ce = origin/main`,工作区干净。
docs/Excel¸¡µãÊý´æ´¢.txt
New file
@@ -0,0 +1,152 @@
excel的数值计算非常精确,其内部是怎么存储数值的呢,是用浮点数吗
用户想知道Excel内部如何存储数值,是否使用浮点数。我需要提供准确的解释。为了全面获取信息,我将同时进行多项搜索,覆盖Excel数值存储机制、浮点数精度问题、与IEEE 754标准的关系以及可能的优化技术。
搜索到 16 ä¸ªç½‘页
搜索结果显示了一些相关页面。为了全面获取信息,我们需要同时打开这些页面。
浏览 1 ä¸ªé¡µé¢
Floating Point Errors in Excel
这些资料提供了关于Excel数值存储的详细信息。微软的文档明确指出Excel遵循IEEE 754标准,使用双精度浮点数,有效数字为15位。Stack Overflow的讨论也确认了这一点。ExcelJet的文章解释了浮点数误差的原因。我的回答将涵盖以下几个方面:Excel使用IEEE 754双精度浮点数(64位),有效精度为15位,存储范围,以及浮点数误差及其原因。我还会提及一些特殊情况,比如整数存储和日期时间存储。为了更全面地覆盖这些方面,我需要同时进行多项搜索。
搜索到 12 ä¸ªç½‘页
现在可以组织答案了。答案将涵盖:Excel使用IEEE 754双精度浮点数(64位),有效精度为15位,存储范围,以及浮点数误差及其原因。还会提及整数和日期时间的存储方式。回答时会引用微软文档、Stack Overflow等来源。
是的,Excel çš„æ•°å€¼è®¡ç®—确实基于浮点数,具体来说,它遵循 IEEE 754 æ ‡å‡†çš„ 64 ä½åŒç²¾åº¦æµ®ç‚¹æ•°æ ¼å¼æ¥å­˜å‚¨å’Œè®¡ç®—数值。
核心机制:64位双精度浮点数
Excel å†…部将所有工作表单元格中的数字(包括整数、日期和时间)都统一存储为 8字节(64位)的双精度浮点数。
这种存储格式的 64 ä¸ªæ¯”特位被划分为三个部分:
符号位 (1位):表示正负。
指数位 (11位):决定数值的范围。
尾数位 (52位):决定数值的精度。
精度限制:15位有效数字
尽管双精度浮点数能提供约 15 åˆ° 16 ä½çš„十进制有效数字,但 Excel ä¸ºäº†ç¡®ä¿è®¡ç®—结果的可靠性,将有效精度限制在 15 ä½ã€‚
这意味着,如果一个数字的有效位数超过 15 ä½ï¼Œä»Žç¬¬ 16 ä½å¼€å§‹å°†è¢«æ›¿æ¢ä¸º 0。例如,输入 123456789012345678,Excel å®žé™…存储的将是 123456789012345000。
数值范围
基于此标准,Excel å¯ä»¥å¤„理的数值范围如下:
最大正数:1.79769313486232E+308
最小正数:2.2250738585072E-308
超出此范围的数值会被视为“无穷大”并显示为错误值。
为什么会有浮点数误差?
你可能会遇到 Excel è®¡ç®— 0.1 + 0.2 å¾—到 0.30000000000000004 çš„æƒ…况,这并非 Bug,而是二进制浮点数的固有特性。
其根本原因在于,像 0.1 è¿™æ ·çš„十进制小数无法用二进制浮点数精确表示,就像十进制无法精确表示 1/3 ä¸€æ ·ã€‚Excel å­˜å‚¨çš„æ˜¯ä¸€ä¸ªæžå…¶æŽ¥è¿‘ 0.1 çš„近似值,当多个这样的近似值进行运算时,微小的误差会累积,最终导致结果出现极小的偏差。
其他数据类型的存储
整数:也按双精度浮点数存储,因此 Excel èƒ½ç²¾ç¡®è¡¨ç¤ºçš„æ•´æ•°ä¸Šé™æ˜¯ 2^53(约 9,007,199,254,740,992)。超过此范围的整数可能会丢失精度。
日期和时间:同样是浮点数。整数部分代表日期(自 1900 å¹´ 1 æœˆ 1 æ—¥ä»¥æ¥çš„天数),小数部分代表时间(一天中的比例)。
总结来说,Excel çš„æ•°å€¼è®¡ç®—在底层就是标准的 IEEE 754 åŒç²¾åº¦æµ®ç‚¹æ•°ï¼Œå…¶â€œç²¾ç¡®â€æ˜¯ç›¸å¯¹çš„,受限于 15 ä½æœ‰æ•ˆæ•°å­—的精度和二进制浮点数本身的表示限制。
那么用Java处理excel的数据时,为了保持与excel的计算结果一致,数据库和Java中如何存储和计算呢
用户想知道如何在Java中处理Excel数据,以保持与Excel计算结果一致,涉及数据库和Java中的存储与计算。我需要提供关于数据类型选择、计算策略以及可能遇到的精度问题方面的建议。为了全面获取信息,我将同时进行多项搜索,覆盖Java处理Excel精度、数据库存储策略、BigDecimal使用、Apache POI处理方式以及IEEE 754兼容性等关键方面。
搜索到 36 ä¸ªç½‘页
搜索结果显示了一些相关资源。为了全面获取信息,我们需要同时打开这些结果。
浏览 4 ä¸ªé¡µé¢
NumberComparer (POI API Documentation)
EasyExcel读入数字类型数据时出现小数位增长现象 - EasyExcel读入数字类型数据时出现小数位增长现象
【完美解决POI读取Excel文档数值类型缺失精度问题】
MySQL :: MySQL 9.0 åƒè€ƒæ‰‹å†Š :: 13.1.3 å®šé»žé¡žåž‹ï¼ˆç²¾ç¢ºå€¼ï¼‰- DECIMAL、NUMERIC
这些资料提供了关于精度问题、Apache POI的NumberToTextConverter、Excel的15位精度限制以及数据库DECIMAL类型的信息。我的回答将涵盖Java中的存储与计算、数据库中的存储、以及如何保持结果一致。回答会引用这些资料。现在开始组织回答。
要在Java中处理Excel数据并保持结果一致,核心思路是将Excel的15位有效数字精度作为基准,在Java和数据库中采用更高精度或精确类型进行存储,并统一计算与舍入规则。
💾 ç¬¬ä¸€æ­¥ï¼šåœ¨Java中如何存储与读取
Java的 double ç±»åž‹åŒæ ·éµå¾ª IEEE 754 æ ‡å‡†ï¼Œä½†å…¶æœ‰æ•ˆæ•°å­—可达15-17位,而Excel严格限制为15位,这是差异的根源。
读取时:推荐用 BigDecimal æˆ– String
直接用 double æŽ¥æ”¶ Excel æ•°å€¼ä¼šå¼•入额外的二进制浮点误差。例如,Excel ä¸­çš„ 9699.1 è¯»æˆ double å¯èƒ½å˜æˆ 9699.0999999999999996。
推荐方案:
读取为 BigDecimal:在实体类中,将金额、数量等数值字段声明为 BigDecimal ç±»åž‹ï¼Œè€Œéž double æˆ– float。
使用 POI çš„ NumberToTextConverter:Apache POI æä¾›äº† NumberToTextConverter.toText(double) å·¥å…·ç±»ï¼Œå¯ä»¥å°† Excel çš„原始数值精确地转换为字符串,再由字符串构造 BigDecimal,避免中间精度损失。
java
import org.apache.poi.ss.util.NumberToTextConverter;
import java.math.BigDecimal;
if (cell.getCellType() == CellType.NUMERIC) {
    String exactText = NumberToTextConverter.toText(cell.getNumericCellValue());
    BigDecimal value = new BigDecimal(exactText);
    // æ­¤æ—¶ value æ˜¯ Excel ä¸­å­˜å‚¨çš„精确值(15位有效数字)
}
🗄️ ç¬¬äºŒæ­¥ï¼šåœ¨æ•°æ®åº“中如何存储
数据库字段切忌使用 FLOAT æˆ– DOUBLE,因为它们同样存在二进制浮点误差,会导致与Excel结果的偏差。
推荐方案:使用 DECIMAL ç±»åž‹
DECIMAL(或 NUMERIC)是定点精确类型,它按整数位和小数位存储,能精确表示十进制小数。
数据库    æŽ¨èç±»åž‹    è¯´æ˜Ž
MySQL    DECIMAL(p, s)    p ä¸ºæ€»ç²¾åº¦ï¼Œs ä¸ºå°æ•°ä½ã€‚Excel æœ‰æ•ˆä½15,建议 p è‡³å°‘为 15,再根据小数位需求设定。例如 DECIMAL(18, 6)。
PostgreSQL    NUMERIC(p, s)    åŒä¸Šï¼Œæ ‡å‡†SQL类型。
SQL Server    DECIMAL(p, s)    åŒä¸Šã€‚
Oracle    NUMBER(p, s)    NUMBER ç±»åž‹å¯å­˜å‚¨å®šç‚¹æ•°ï¼Œæ•ˆæžœç­‰åŒã€‚
JDBC æ˜ å°„:使用 JDBC è¯»å†™æ—¶ï¼ŒDECIMAL å­—段会直接映射为 Java çš„ BigDecimal,保证了精度在传递过程中不丢失。
🧮 ç¬¬ä¸‰æ­¥ï¼šJava中的计算与舍入规则
即使存储精确,计算逻辑也必须模拟 Excel çš„行为。
1. å§‹ç»ˆä½¿ç”¨ BigDecimal è¿›è¡Œè®¡ç®—
BigDecimal çš„加减乘除(add, subtract, multiply, divide)都是精确计算,不会引入二进制误差。切勿将 BigDecimal è½¬å›ž double åŽå†è®¡ç®—。
2. æ¨¡æ‹Ÿ Excel çš„15位有效数字限制
Excel åœ¨æ¯æ¬¡å°†ä¸­é—´ç»“果写入单元格时,都会将结果舍入到15位有效数字。在 Java ä¸­ï¼Œéœ€è¦æ¨¡æ‹Ÿè¿™ä¸€è¡Œä¸ºã€‚
java
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
// åˆ›å»ºä¸€ä¸ªç²¾åº¦ä¸º15位有效数字,舍入模式为“四舍五入”的上下文
MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
public BigDecimal excelRound(BigDecimal value) {
    // åº”用Excel的舍入规则
    return value.round(EXCEL_MC);
}
// ç¤ºä¾‹ï¼šåœ¨æ¯ä¸€æ­¥å…³é”®è®¡ç®—后调用
BigDecimal intermediate = a.add(b);
intermediate = excelRound(intermediate); // æ¨¡æ‹Ÿå†™å…¥å•元格
BigDecimal result = intermediate.multiply(c);
result = excelRound(result);
注意:Excel çš„舍入模式近似于“四舍五入”,但存在微妙的“银行家舍入”行为。RoundingMode.HALF_UP æ˜¯æœ€æŽ¥è¿‘的近似,但在极少数边缘情况下仍有差异。对于财务系统,建议在最终结果上按业务规则(如 RoundingMode.HALF_UP)显式保留固定小数位。
3. æ¯”较运算需特殊处理
Excel åœ¨æ¯”较数字时有额外的容差逻辑。例如,(0.06-0.01)=0.05 åœ¨ Excel ä¸­ä¸º TRUE,但在 Java çš„ double æ¯”较中为 false。Apache POI æä¾›äº† NumberComparer.compare(double a, double b) æ¥æ¨¡æ‹Ÿè¿™ç§æ¯”较行为。对于 BigDecimal,建议先按15位有效数字舍入后再用 compareTo() æ¯”较。
💎 æ€»ç»“
要在 Java ç”Ÿæ€ä¸­å¤çް Excel çš„计算结果,关键在于:
入口精确:用 NumberToTextConverter + BigDecimal è¯»å–,避免 double æ±¡æŸ“。
存储精确:数据库使用 DECIMAL ç±»åž‹ï¼ŒJava å­—段使用 BigDecimal。
计算精确:全程使用 BigDecimal è¿ç®—。
舍入对齐:在每次可能产生中间结果的地方,用 MathContext(15, RoundingMode.HALF_UP) æ¨¡æ‹Ÿ Excel çš„15位有效数字截断。
比较容差:使用模拟 Excel è¡Œä¸ºçš„æ¯”较逻辑,而非直接比较。
这样就能最大程度地保证 Java ä¸Ž Excel çš„计算结果一致。
docs/database.md
@@ -487,3 +487,66 @@
| /api/report/export/investFiveYearLogistics | "十五五"物流项目进展 | å›ºå®šåå•过滤,市州小节+项目行 |
| /api/report/export/investBillion | äº¿å…ƒæŠ•资项目 | æŠ•资总表 + è§„上项目表两sheet,亿元名单过滤 |
| /api/report/export/investCounty | ç»æµŽå¼ºåŽ¿æŠ•èµ„ç»Ÿè®¡ | ç»æµŽå¼ºåŽ¿åå•è¿‡æ»¤ |
## ä¹ã€ä¸“项统计-重要节假日模块
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ï¼Œå¹¶åˆå§‹åŒ– 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
### holiday_calendar èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| holiday_type | varchar(32) | æ˜¥èŠ‚ SPRING_FESTIVAL / äº”一 LABOR_DAY / åä¸€ NATIONAL_DAY |
| holiday_date | date | èŠ‚å‡æ—¥å…·ä½“æ—¥æœŸ |
| day_index | int | ç¬¬å‡ æ—¥ï¼Œä»Ž 1 å¼€å§‹ |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护。
### holiday_passenger_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| flow_date | date | æ—¥æœŸ |
| transport_mode | varchar(32) | é“è·¯ RAIL / æ°‘航 CIVIL_AVIATION / é“è·¯ ROAD / æ°´è·¯ WATER |
| passenger_count | decimal(30,15) | å‘送旅客数(万人次) |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, flow_date, transport_mode)`。
### holiday_vehicle_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“éžè¥ä¸šæ€§è½¦æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| flow_date | date | æ—¥æœŸ |
| 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) | æ™®é€šå›½çœé“环比基期(万辆次,仅当年使用) |
  | created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, flow_date, road_category, vehicle_type)`。
### holiday_passenger_coefficient å®¢è¿ç³»æ•°é…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| date_category | varchar(32) | èŠ‚å‡æ—¥ HOLIDAY / éžèŠ‚å‡æ—¥ NON_HOLIDAY |
| coefficient | decimal(30,15) | å®¢è¿ç³»æ•° |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, date_category)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护。
### è®¡ç®—与导入导出口径
  - éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ =(高速车流 + æ™®é€šå›½çœé“车流)נ当年节假日客运系数。
  - 2026 å¹´æ™®é€šå›½çœé“如填写环比基期,则只影响普通国省道车流量自身环比:填写时按“当日 / çŽ¯æ¯”åŸºæœŸ - 1”,未填写时按“当日 / å‰ä¸€æ—¥ - 1”;折算人员流动量、总人员流动量等继续按 Excel åŽŸå…¬å¼è®¡ç®—ã€‚2024、2025 åŽ†å²æ•°æ®ä¸ä½¿ç”¨è¯¥å­—æ®µã€‚
  - å…¬è·¯äººå‘˜æµåŠ¨é‡ = é“路营业性客运量 + éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ã€‚
- æ€»äººå‘˜æµåŠ¨é‡ = å…¬è·¯ + é“è·¯ + æ°‘航 + æ°´è·¯ã€‚
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;日常只读取所选年份的当年列,历史年份仅首次初始化。
- å¯¼å…¥å‰æ£€æŸ¥æ¯”本次最新导入日更早的历史日期是否发生变化;如有变化先不写库,返回差异清单,用户确认后才允许覆盖。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word å¹¶æ›¿æ¢æ¨¡æ¿ä¸­çš„当日、累计、环比和同比内容。
docs/init.sql
@@ -619,3 +619,69 @@
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_type_period (report_type, report_period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表期审核通过标记';
-- ============================================
-- åä¸€ã€ä¸“项统计-重要节假日
-- ============================================
-- èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
DROP TABLE IF EXISTS holiday_calendar;
CREATE TABLE holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  holiday_type VARCHAR(32) NOT NULL COMMENT '节假日分类:SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
  holiday_date DATE NOT NULL COMMENT '节假日具体日期',
  day_index INT NOT NULL COMMENT '第几日,从 1 å¼€å§‹',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='重要节假日日期配置';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
DROP TABLE IF EXISTS holiday_passenger_flow;
CREATE TABLE holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:RAIL/CIVIL_AVIATION/ROAD/WATER',
  passenger_count DECIMAL(30,15) 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_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输营业性客流情况';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“éžè¥ä¸šæ€§è½¦æµ
DROP TABLE IF EXISTS holiday_vehicle_flow;
CREATE TABLE holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  road_category VARCHAR(32) NOT NULL COMMENT '道路分类:EXPRESSWAY/NATIONAL_PROVINCIAL',
  vehicle_type VARCHAR(32) NOT NULL COMMENT '车型分类:SMALL_PASSENGER/MEDIUM_SMALL_PASSENGER',
  vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)',
  comparison_base DECIMAL(30,15) 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 (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输非营业性车流情况';
-- å®¢è¿ç³»æ•°é…ç½®
DROP TABLE IF EXISTS holiday_passenger_coefficient;
CREATE TABLE holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  date_category VARCHAR(32) NOT NULL COMMENT '日期分类:HOLIDAY/NON_HOLIDAY',
  coefficient DECIMAL(30,15) 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_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客运系数配置';
docs/sql_holiday.sql
New file
@@ -0,0 +1,53 @@
-- Holiday statistics module. Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  holiday_type VARCHAR(32) NOT NULL,
  holiday_date DATE NOT NULL,
  day_index INT 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_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  transport_mode VARCHAR(32) NOT NULL,
  passenger_count DECIMAL(30,15) 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_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  road_category VARCHAR(32) NOT NULL,
  vehicle_type VARCHAR(32) NOT NULL,
  vehicle_count DECIMAL(30,15) NOT NULL,
  comparison_base DECIMAL(30,15) 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 (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_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,
  date_category VARCHAR(32) NOT NULL,
  coefficient DECIMAL(30,15) 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_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
docs/sql_holiday_comparison_base_migration.sql
New file
@@ -0,0 +1,6 @@
-- Add the optional ordinary national/provincial road comparison base.
-- The field is used only for the 2026 holiday data.
ALTER TABLE holiday_vehicle_flow
  ADD COLUMN comparison_base DECIMAL(30,15) NULL
  COMMENT '普通国省道环比基期(万辆次,仅当年使用)'
  AFTER vehicle_count;
docs/sql_holiday_decimal_migration.sql
New file
@@ -0,0 +1,16 @@
-- Convert holiday statistics numeric columns from binary floating point to exact decimal storage.
-- Run once against the traffic_audit database.
ALTER TABLE holiday_passenger_flow
  MODIFY COLUMN passenger_count DECIMAL(30,15) NOT NULL COMMENT '发送旅客数(万人次)';
ALTER TABLE holiday_vehicle_flow
  MODIFY COLUMN vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)';
ALTER TABLE holiday_passenger_coefficient
  MODIFY COLUMN coefficient DECIMAL(30,15) NOT NULL COMMENT '客运系数';
-- The seeded coefficient was averaged from repeated 2.4 cells through a Java double.
-- Normalize the migrated value to the exact Excel decimal.
UPDATE holiday_passenger_coefficient
SET coefficient = 2.4
WHERE year IN (2024, 2025, 2026);
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_ÖØÒª½Ú¼ÙÈÕרÏîͳ¼Æ_2026-10-02.md
New file
@@ -0,0 +1,155 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_重要节假日专项统计_2026-10-02
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
新增“专项统计-重要节假日”模块,覆盖以下能力:
1. ç³»ç»Ÿé…ç½®èœå•新增“节假日管理”和“客运系数管理”,仅 admin å¯ç»´æŠ¤ã€‚
2. èŠ‚å‡æ—¥ç®¡ç†å­—æ®µï¼šå¹´ä»½ã€èŠ‚å‡æ—¥åˆ†ç±»ï¼ˆæ˜¥èŠ‚/五一/十一)、日期、第几日。
3. å®¢è¿ç³»æ•°ç®¡ç†å­—段:年份、日期分类(节假日/非节假日)、客运系数。
4. ä¸“项统计-节假日菜单支持按年份、节假日分类查看客流与车流综合数据。
5. æ”¯æŒå¯¼å…¥ã€Š2026年十一数据.xlsx》模板,按所选年份读取当年列(2026 å¹´ä¸ºå®¢æµ C åˆ—、普通国省道 D åˆ—),按“第几日”写入当年客流和车流;2024/2025 åŽ†å²æ•°æ®ä»…é¦–æ¬¡åˆå§‹åŒ–ï¼Œä¸è¦æ±‚æ—¥å¸¸é‡å¤å¯¼å…¥ã€‚
6. å¯¼å…¥æ—¶å…ˆæ£€æŸ¥â€œæ¯”本次最新导入日更早”的历史日期是否被改动;如有变化,先不写库并列出差异,由用户选择是否允许覆盖。
7. æ”¯æŒå¯¼å‡º Excel,模板为《2026年十一数据.xlsx》。
8. æ”¯æŒä¸‹è½½ã€Šæ¹–北省国庆假期综合运输客流快报(10月X日).docx》,默认日期为系统日期前一天,也允许用户自选日期;环比、同比按“增长/下降/持平”输出。
9. åº”用首次启动时,从模板工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµï¼Œä»¥åŠ 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
## äºŒã€æµ‹è¯•环境
| é¡¹ç›® | å†…容 |
|------|------|
| å·¥ä½œç›®å½• | `D:\Codex\MyProject\trafficAudit` |
| åŽç«¯ | Spring Boot,`http://localhost:8090` |
| å‰ç«¯ | Vue 2 + Element UI,`http://localhost:8080` |
| æ•°æ®åº“ | MySQL `127.0.0.1:3305/traffic_audit` |
| æºå·¥ä½œç°¿ | `docs/专项统计/重要节假日/2026年十一数据.xlsx` |
| å¿«æŠ¥æ¨¡æ¿ | `docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx` |
| ç¼–译 | `mvn -q -DskipTests compile -f traffic-audit-server/pom.xml` é€šè¿‡ |
| å‰ç«¯æž„建 | `npm run build` é€šè¿‡ï¼Œä»…有原项目 bundle ä½“积 warning |
## ä¸‰ã€ç”¨ä¾‹è¡¨
| ç¼–号 | ç±»åž‹ | è¾“å…¥/操作 | é¢„期 | å®žé™… | ç»“论 |
|------|------|-----------|------|------|------|
| T01 | åˆå§‹åŒ– | å¯åŠ¨åŽç«¯ | è‡ªåŠ¨å»º/初始化 4 å¼ èŠ‚å‡æ—¥è¡¨ | è¡¨å­˜åœ¨ï¼Œåˆå§‹åŒ–完成 | é€šè¿‡ |
| T02 | åˆå§‹åŒ– | æŸ¥è¯¢èŠ‚å‡æ—¥é…ç½® | 2024/2026 å¹´ 7 å¤©ï¼Œ2025 å¹´ 8 å¤© | 2024 å¹´ 7 å¤©ï¼Œ2025 å¹´ 8 å¤©ï¼Œ2026 å¹´ 7 å¤© | é€šè¿‡ |
| T03 | åˆå§‹åŒ– | æŸ¥è¯¢å®¢æµã€è½¦æµ | 2024/2025 æ•°æ®ä»Žæºå·¥ä½œç°¿è¯»å– | å®¢æµ 28/32 è¡Œï¼›è½¦æµ 14/16 è¡Œ | é€šè¿‡ |
| T04 | åˆå§‹åŒ– | æŸ¥è¯¢å®¢è¿ç³»æ•° | 2024-2026 å¹´ç³»æ•°æ¥è‡ªæºå·¥ä½œç°¿ | æ¯å¹´ HOLIDAY/NON_HOLIDAY å‡ 2.4,与源表一致 | é€šè¿‡ |
| T05 | å…¥åº“抽查 | æºå·¥ä½œç°¿ vs æ•°æ®åº“逐项比对 | æ— å·®å¼‚ | 90 ä¸ªå•元格比对,差异 0 | é€šè¿‡ |
| T06 | æƒé™ | æœªç™»å½•访问节假日接口 | æ‹¦æˆª | HTTP 401,提示未登录 | é€šè¿‡ |
| T07 | æƒé™ | éž admin ä¿å­˜èŠ‚å‡æ—¥ | æ‹’绝 | code=403,提示仅 admin å¯ç®¡ç† | é€šè¿‡ |
| T08 | æƒé™ | éž admin ä¿å­˜å®¢è¿ç³»æ•° | æ‹’绝 | code=403,提示仅 admin å¯ç®¡ç† | é€šè¿‡ |
| T09 | æ­£å‘ | admin æ–°å¢ž/编辑/删除节假日 2030 æµ‹è¯•记录 | æˆåŠŸä¸”æ— è„æ•°æ® | æ–°å¢žã€ç¼–辑成功;重复配置返回 400;删除成功 | é€šè¿‡ |
| T10 | æ­£å‘ | admin æ–°å¢ž/编辑/删除客运系数 2030 æµ‹è¯•记录 | æˆåŠŸä¸”æ— è„æ•°æ® | æ–°å¢žã€ç¼–辑成功;非法系数返回 400;删除成功 | é€šè¿‡ |
| T11 | æ­£å‘ | å¯¼å…¥æºå·¥ä½œç°¿ 2025 å¹´åä¸€æ•°æ® | è¦†ç›–写入 | å®¢æµ 32 æ¡ã€è½¦æµ 16 æ¡ï¼Œå¤±è´¥ 0 | é€šè¿‡ |
| T12 | æ­£å‘ | å¯¼å…¥ä¸´æ—¶ 2026 æµ‹è¯•工作簿(同值验证持平) | å†™å…¥å¹¶ç”Ÿæˆå¿«æŠ¥ | å®¢æµ 8 æ¡ã€è½¦æµ 4 æ¡ï¼Œå¤±è´¥ 0;测试数据已清理 | é€šè¿‡ |
| T13 | è´Ÿå‘ | é€‰æ‹©æœªé…ç½®å¹´ä»½å¯¼å…¥ | æç¤ºå…ˆç»´æŠ¤èŠ‚å‡æ—¥ | code=400,提示“请先在系统配置中维护节假日” | é€šè¿‡ |
| T14 | è´Ÿå‘ | å¯¼å…¥éž Excel æ–‡ä»¶ | è§£æžå¤±è´¥å¹¶æç¤º | code=400,提示 unsupported file type | é€šè¿‡ |
| T15 | è´Ÿå‘ | ä¸ä¼ æ–‡ä»¶å¯¼å…¥ | ä¸šåŠ¡æç¤ºï¼Œä¸è¿”å›ž 500 | code=400,提示“请选择要导入的 Excel æ–‡ä»¶â€ | é€šè¿‡ |
| T16 | å¯¼å‡º | å¯¼å‡º 2025 å¹´åä¸€ Excel | å¸¦åº“内数据,公式保留 | æ–‡ä»¶å¯è¢« openpyxl æ‰“开,2025 å®¢æµ/车流数据正确 | é€šè¿‡ |
| T17 | å¿«æŠ¥ | ä¸‹è½½ 2025-10-01 å¿«æŠ¥ | é¦–日正文不输出“环比……”;表格环比列为“—”,正文/表格完整 | æ­£æ–‡ä¸ºâ€œ10月1日(假期第1天)……万人次,比2025年同期增长6.8%。”,无“环比”短语;表格环比列为“—” | é€šè¿‡ |
| T18 | å¿«æŠ¥ | ä¸‹è½½ 2025-10-02 å¿«æŠ¥ | çŽ¯æ¯”ä¸ºä¸‹é™å¹¶è¾“å‡ºç»å¯¹å€¼ | è¾“出“环比下降11.5%” | é€šè¿‡ |
| T19 | å¿«æŠ¥ | ä¸‹è½½ 2025-10-03 å¿«æŠ¥ | çŽ¯æ¯”ä¸ºæ­£å¹¶è¾“å‡ºç»å¯¹å€¼ | è¾“出“环比增长2.4%” | é€šè¿‡ |
| T20 | å¿«æŠ¥ | ä¸´æ—¶æž„造 2026 ç¬¬ 1/2 æ—¥ç›¸åŒæ•°æ® | çŽ¯æ¯”ä¸º 0 æ—¶è¾“出“持平” | è¾“出“环比持平” | é€šè¿‡ |
| T21 | å¹¶å‘/稳定 | é‡å¯åŽè¿žç»­ç”Ÿæˆå¿«æŠ¥ | ä¸ä¸²æ–‡æ¡£ | ä¸¤ä»½ä¸åŒæ—¥æœŸå¿«æŠ¥å†…容正确 | é€šè¿‡ |
| T22 | å‰ç«¯ | admin æ‰“开“专项统计-节假日” | é¡µé¢æ¸²æŸ“、数据表显示 | æ ‡é¢˜ã€èœå•、表格正常 | é€šè¿‡ |
| T23 | å‰ç«¯ | admin æ‰“开“系统配置” | ä¸¤ä¸ª Tab æ­£å¸¸ | èŠ‚å‡æ—¥ç®¡ç†ã€å®¢è¿ç³»æ•°ç®¡ç†æ­£å¸¸ | é€šè¿‡ |
| T24 | å‰ç«¯ | éž admin è®¿é—®ç³»ç»Ÿé…ç½® | èœå•隐藏且直访被重定向 | èœå•不可见,直访回工作台 | é€šè¿‡ |
| T25 | å‰ç«¯ | ç‚¹å‡»â€œä¸‹è½½æ¨¡æ¿â€ | HTTP 200,文件可下载 | æµè§ˆå™¨å®žé™…请求返回 200 | é€šè¿‡ |
| T26 | æŽ§åˆ¶å° | é¡µé¢çƒŸæµ‹æ”¶é›† JS é”™è¯¯ | æ— é”™è¯¯ | æ—  pageerror/console error | é€šè¿‡ |
| T27 | åŽ†å²æ£€æŸ¥ | å·²æœ‰ 2026 ç¬¬1日数据后,导入第1日被修改且第2日正常的文件(不确认) | ä¸å†™åº“,返回历史变化 | `confirmationRequired=true`,列出 6 æ¡å·®å¼‚;库内第1日仍为原值 | é€šè¿‡ |
| T28 | åŽ†å²æ£€æŸ¥ | åŒä¸Šæ–‡ä»¶ï¼Œç”¨æˆ·ç¡®è®¤å…è®¸å¯¼å…¥ | è¦†ç›–历史日数据 | è¿”回成功 12 æ¡ï¼›åº“内第1日由 100/200/300 æ›´æ–°ä¸º 110/210/310 | é€šè¿‡ |
| T29 | å‰ç«¯ç¡®è®¤ | é¡µé¢å¯¼å…¥å«åŽ†å²å˜åŒ–çš„æ–‡ä»¶ | å¼¹çª—显示差异和“允许导入/不允许” | å¼¹çª—出现且包含原值/导入值;点击允许后导入成功 | é€šè¿‡ |
| T30 | é»˜è®¤æ—¥æœŸ | æ‰“开快报日期选择 | é»˜è®¤ç³»ç»Ÿæ—¥æœŸå‰ä¸€å¤©ï¼Œå¯æ‰‹å·¥ä¿®æ”¹ | é¡µé¢é»˜è®¤å– 2026-10-01,日期控件可选择 | é€šè¿‡ |
| T31 | èœå•顺序 | æŸ¥çœ‹å·¦ä¾§èœå• | ä¸“项统计位于报表生成之后 | é¡ºåºä¸º æŠ¥è¡¨ç”Ÿæˆ -> ä¸“项统计 -> è§„则管理 -> ç”¨æˆ·ç®¡ç† -> ç³»ç»Ÿé…ç½® | é€šè¿‡ |
| T32 | ç²¾åº¦ | Excel å®¢è¿ç³»æ•° 2.4 å…¥åº“后查询 | ä¸å‡ºçް 2.3999999999999995 | åº“内 2024/2025/2026 å¹´ç³»æ•°å‡ä¸º 2.4,接口返回 coefficient=2.4 | é€šè¿‡ |
| T33 | ç²¾åº¦ | ç³»ç»Ÿé…ç½®-客运系数管理列表 | æ˜¾ç¤º 2.4 | æµè§ˆå™¨å®žæµ‹ä¸¤è¡Œå‡æ˜¾ç¤º 2.4 | é€šè¿‡ |
| T34 | ç²¾åº¦ | èŠ‚å‡æ—¥ç»Ÿè®¡é¡µå®¢è¿ç³»æ•°åˆ— | æ˜¾ç¤º 2.4 | overview æŽ¥å£ä¸Žé¡µé¢å‡æ˜¾ç¤º 2.4 | é€šè¿‡ |
| T35 | ç²¾åº¦ | æ£€æŸ¥èŠ‚å‡æ—¥æ•°å€¼åˆ—ç±»åž‹ | decimal(30,15) | passenger_count/vehicle_count/coefficient/comparison_base å‡ä¸º decimal(30,15) | é€šè¿‡ |
| T36 | å¯¼å…¥å£å¾„ | å¯¼å…¥å« 2024/2025 åŽ†å²åˆ—ä¸Žå®¢è¿ç³»æ•°åˆ—çš„ 2026 å·¥ä½œç°¿ | åªè¯» 2026 å½“年列,不写 2024/2025,不读系数 | å†™å…¥ 12 æ¡ï¼ˆå®¢æµ 8 + è½¦æµ 4),2024/2025 åˆ—与系数列未入库 | é€šè¿‡ |
| T37 | åŽ†å²æ£€æŸ¥å›žå½’ | å·²æœ‰ 2026 ç¬¬1/2 æ—¥æ•°æ®åŽï¼Œå¯¼å…¥ç¬¬1日被改动的文件(不确认) | ä¸å†™åº“并列出差异 | confirmationRequired=true,列出 6 æ¡å·®å¼‚,库内第1日仍为 100/100/100/100/200/300 | é€šè¿‡ |
| T38 | åŽ†å²æ£€æŸ¥å›žå½’ | åŒä¸Šæ–‡ä»¶ï¼Œç”¨æˆ·ç¡®è®¤å…è®¸å¯¼å…¥ | è¦†ç›–历史日数据 | è¿”回成功 12 æ¡ï¼Œåº“内第1日更新为 110/110/110/110/210/310,第2日不变 | é€šè¿‡ |
| T39 | é¡µé¢è¡¨å¤´ | èŠ‚å‡æ—¥æ•°æ®ä¸€è§ˆè¡¨å¤´åŠ å•ä½ | åˆ—头带单位 | å®žæµ‹ï¼šé“è·¯/民航/道路/水路(万人次),高速车流/国省道车流(万辆次),非营业性人员流动量/总人员流动量(万人次),客运系数无单位 | é€šè¿‡ |
| T40 | é…ç½®å›žå½’ | admin åˆ é™¤ 2026-10-08 åŽé‡å¯åŽç«¯ | åˆ é™¤ç»“果保持,不再自动补回 | é‡å¯åŽåº“内、接口和页面均为 2026-10-01 è‡³ 10-07 å…± 7 å¤© | é€šè¿‡ |
| T41 | çŽ¯æ¯”åŸºæœŸå¯¼å…¥ | 2026 å¹´æ™®é€šå›½çœé“ C åˆ—å¡« 320、D åˆ—å¡« 360 | C åˆ—作为环比基期保存,D åˆ—为当日车流 | åº“内 comparison_base=320,vehicle_count=360;接口均返回 | é€šè¿‡ |
| T42 | çŽ¯æ¯”åŸºæœŸå¯¼å‡º | å¯¼å‡ºå·²å¯¼å…¥åŸºæœŸçš„ 2026 å¹´æ•°æ® | æ™®é€šå›½çœé“ C åˆ—回填基期,D åˆ—回填当日车流 | å¯¼å‡ºæ–‡ä»¶ C4=320、D4=360 | é€šè¿‡ |
| T43 | å›½çœé“车流环比 | 2026-10-02 å›½çœé“当日360、基期320 | ä»…国省道直接环比使用基期 | å›½çœé“车流环比为12.50%;快报总人员流动量仍为17.00%,不受基期影响 | é€šè¿‡ |
| T44 | æ˜ å°„导出 | æœ‰åŸºæœŸæ—¶å¯¼å‡ºæ™®é€šå›½çœé“ E åˆ—公式 | E ä½¿ç”¨å½“日与环比基期计算 | å¯¼å‡º E4 å…¬å¼ä¸º `=(D4-C4)/C4` | é€šè¿‡ |
| T45 | åŽ†å²åŸºæœŸå˜æ›´ | å·²æœ‰ 2026 ç¬¬1日基期后,导入新的第1日基期值 | ä¸ç¡®è®¤æ—¶é›¶å†™å…¥å¹¶åˆ—出差异 | confirmationRequired=true,提示原值300/导入值310,库内仍为300 | é€šè¿‡ |
| T46 | åŽ†å²å¹´ä»½éš”ç¦» | å¯¼å‡º 2024/2025 å¹´æ•°æ® | åŽ†å²å¹´ä»½ä¸ä½¿ç”¨çŽ¯æ¯”åŸºæœŸ | ä¸¤ä»½å¯¼å‡ºæ–‡ä»¶æ™®é€šå›½çœé“ C3:C10 å‡ä¸ºç©º | é€šè¿‡ |
| T47 | å¿«æŠ¥é¦–日口径 | ä¸‹è½½ 2025-10-01 å¿«æŠ¥å¹¶æ£€æŸ¥æ­£æ–‡ | ç¬¬1天不生成“环比增长/下降/持平”短语 | æ­£æ–‡ä¸ºâ€œ10月1日(假期第1天),全省全社会跨区域人员流动量1714.41万人次,比2025年同期增长6.8%。”;第二天仍输出“环比下降11.5%” | é€šè¿‡ |
| T48 | å¿«æŠ¥ç™¾åˆ†æ¯”精度 | ä¸‹è½½ 2025-10-01/02 å¿«æŠ¥æ£€æŸ¥æ­£æ–‡å’Œè¡¨æ ¼ | åŒæ¯”/环比百分比保留1位小数,人数/车流保留2位小数 | 6.79%显示为6.8%,11.46%显示为11.5%;人数1714.41、1518.02仍为2位小数 | é€šè¿‡ |
## å››ã€å‘现的问题与处置
1. æ¨¡æ¿ä¸‹è½½æ•´æ®µè·¯å¾„使用 `encodeURIComponent`,把 `/` ç¼–码为 `%2F`,Tomcat è¿”回 400。
   - å¤„置:前端改为按路径段编码,保留斜杠,复测返回 200。
2. å¯¼å…¥åŽŸå…ˆæ²¡æœ‰åŽ†å²æ•°æ®å˜æ›´ç¡®è®¤ï¼Œå¯èƒ½é™é»˜è¦†ç›–å·²å‘å¸ƒåŽ†å²æ—¥ã€‚
   - å¤„置:新增两阶段导入;未确认时零写入,确认后才允许覆盖历史日。
3. å¿«æŠ¥ç”ŸæˆæœåŠ¡åŽŸå…ˆæŠŠå½“å‰ DOM æ–‡æ¡£æ”¾åœ¨å®žä¾‹å­—段,存在并发串扰风险。
   - å¤„置:改为 `ThreadLocal` ä¿å­˜å½“前文档,并在请求结束时清理;重启后复测连续生成通过。
4. å¯¼å…¥æŽ¥å£ä¸ä¼ æ–‡ä»¶æ—¶ç”±æ¡†æž¶æŠ›å‡º 500。
5. èŠ‚å‡æ—¥åˆå§‹åŒ–æ¯æ¬¡å¯åŠ¨éƒ½ä¼šè¡¥é½ç¼ºå¤±çš„ç¬¬ N æ—¥ï¼Œå¯¼è‡´ admin åˆ é™¤ 2026-10-08 åŽï¼Œé‡å¯åˆè¢«è‡ªåŠ¨æ’å›žã€‚
   - å¤„置:文件参数改为可选,统一返回业务 400 æç¤ºã€‚
   - å¤„置:`seedNationalDayCalendar()` æ”¹ä¸ºä»…在 `holiday_calendar` æ•´è¡¨ä¸ºç©ºæ—¶æ‰§è¡Œé¦–次初始化;非空配置完全由 admin ç»´æŠ¤ã€‚复测重启后 2026 å¹´åä¸€ä¿æŒ 7 å¤©ã€‚
## äº”、数据清理
- æµ‹è¯•中产生的 2026 å®¢æµ/车流临时数据已删除。
- æµ‹è¯•产生的 `import_batch` èŠ‚å‡æ—¥å¯¼å…¥è®°å½•å·²åˆ é™¤ã€‚
- 2024/2025 åˆå§‹åŒ–数据和 2024-2026 å®¢è¿ç³»æ•°ä¿ç•™ï¼Œç¬¦åˆéœ€æ±‚。
## å…­ã€ç»“论
重要节假日专项统计模块的正向流程、权限控制、数据初始化、导入导出、快报生成和前端页面烟测均通过;源工作簿 90 ä¸ªæ•°æ®å•元格入库抽查无差异。发现的问题已修复并完成回归,当前后端 8090、前端 8080 æ­£å¸¸è¿è¡Œã€‚
## ä¸ƒã€è¡¥å……回归(2026-10-02,Excel æµ®ç‚¹ç²¾åº¦ä¸Žèœå•顺序)
背景:Excel å†…部按 IEEE 754 åŒç²¾åº¦æµ®ç‚¹å­˜å‚¨æ•°å€¼ï¼ˆæœ‰æ•ˆ 15 ä½ï¼‰ï¼Œåº“内客运系数原为 2.3999999999999995,页面显示异常。参考 `docs/Excel浮点数存储.txt` å…¨é¢æ•´æ”¹ã€‚
修改内容:
1. æ–°å¢žæ•°å€¼å·¥å…· `HolidayNumberUtils`:Excel å•元格数值统一用 `NumberToTextConverter.toText(double)` è½¬æ–‡æœ¬å†å…¥ `BigDecimal`,计算使用 `MathContext(15, HALF_UP)`,与 Excel 15 ä½æœ‰æ•ˆæ•°å­—保持一致。
2. èŠ‚å‡æ—¥æ¨¡å—å®žä½“ã€æœåŠ¡ã€å¯¼å…¥å¯¼å‡ºã€å¿«æŠ¥ç»Ÿä¸€ç”± `Double` æ”¹ä¸º `BigDecimal`。
3. ä¸‰å¼ è¡¨æ•°å€¼åˆ—改为 `DECIMAL(30,15)`:`holiday_passenger_flow.passenger_count`、`holiday_vehicle_flow.vehicle_count`、`holiday_passenger_coefficient.coefficient`;迁移脚本 `docs/sql_holiday_decimal_migration.sql`。
4. åº“内 6 æ¡å®¢è¿ç³»æ•°ï¼ˆ2024-2026 å¹´ã€èŠ‚å‡æ—¥/非节假日)由 2.3999999999999995 æ ¡æ­£ä¸º 2.4。
5. â€œä¸“项统计”菜单移动到“报表生成”之后;前端客运系数展示统一去除浮点尾差。
验证结果:
- `mvn -q -DskipTests compile` é€šè¿‡ï¼›`npm run build` é€šè¿‡ï¼ˆä»…原有 bundle ä½“积警告)。
- èœå•顺序实测:报表生成 -> ä¸“项统计 -> è§„则管理 -> ç”¨æˆ·ç®¡ç† -> ç³»ç»Ÿé…ç½®ã€‚
- ç³»ç»Ÿé…ç½®-客运系数管理列表浏览器实测显示 2.4;接口 `coefficient=2.4`。
- ä¸‰å¼ è¡¨æ•°å€¼åˆ—均为 `decimal(30,15)`。
- åŽ†å²å˜æ›´ä¸¤é˜¶æ®µå¯¼å…¥å›žå½’ï¼šä¸ç¡®è®¤æ—¶é›¶å†™å…¥å¹¶åˆ—å‡º 6 æ¡å·®å¼‚,确认后第1日由 100/100/100/100/200/300 è¦†ç›–为 110/110/110/110/210/310,第2日不变。
- å¯¼å…¥å£å¾„复核:仅写入所选年份当年列(本用例 12 æ¡ï¼‰ï¼Œ2024/2025 åŽ†å²åˆ—ä¸Žå®¢è¿ç³»æ•°åˆ—ä¸è¯»ä¸å…¥åº“ã€‚
- æµ‹è¯•产生的 2026 å®¢æµ/车流及 `import_batch` è®°å½•已清理;2024/2025 åŽ†å²æ•°æ®ä¸Ž 2024-2026 å®¢è¿ç³»æ•°ä¿ç•™ã€‚
- é¡µé¢è¡¨å¤´è¡¥å•位(浏览器实测):铁路/民航/道路/水路(万人次)、高速车流/国省道车流(万辆次)、非营业性人员流动量/总人员流动量(万人次);客运系数保留无单位。
## å…«ã€è¡¥å……回归(2026-10-02,节假日删除后重启复活)
现象:admin å·²åœ¨ç³»ç»Ÿé…ç½®ä¸­åˆ é™¤ 2026-10-08,但后端重启后该行再次出现。
根因:`HolidayReferenceDataInitializer` åœ¨æ¯æ¬¡åº”用启动时调用 `seedNationalDayCalendar()`,原逻辑按固定天数遍历并补齐所有缺失的第 N æ—¥ï¼Œå› æ­¤æŠŠ admin å·²åˆ é™¤çš„æ—¥æœŸé‡æ–°æ’入。
修复与验证:
- `HolidayWorkbookService.seedNationalDayCalendar()` å¢žåŠ éžç©ºä¿æŠ¤ï¼šèŠ‚å‡æ—¥è¡¨åªè¦å·²æœ‰ä»»ä¸€é…ç½®ï¼Œå°±ä¸å†è¿›è¡Œè‡ªåŠ¨è¡¥ç§ã€‚
- åŽç«¯ç¼–译通过,重启后数据库 2026 å¹´åä¸€ä¸º `2026-10-01` è‡³ `2026-10-07` å…± 7 å¤©ã€‚
- æŽ¥å£éªŒè¯ï¼šèŠ‚å‡æ—¥é…ç½® 7 æ¡ï¼Œç»Ÿè®¡ä¸€è§ˆ 7 è¡Œï¼Œæœ€åŽä¸€æ—¥ `dayIndex=7`、`date=2026-10-07`。
## ä¹ã€è¡¥å……回归(2026-10-02,普通国省道环比基期)
背景:普通国省道 `C` åˆ—为“环比基期”。当普通国省道流量趋势与高速公路不一致时,以高速为准,手工填写基期,环比按 `当日 / çŽ¯æ¯”åŸºæœŸ - 1`;未填写时仍按 `当日 / å‰ä¸€æ—¥ - 1`。
实现口径:
- `holiday_vehicle_flow` æ–°å¢žå¯ç©ºå­—段 `comparison_base DECIMAL(30,15)`,迁移脚本 `docs/sql_holiday_comparison_base_migration.sql`。
- ä»… 2026 å¹´å¯ç”¨ï¼š2024、2025 åŽ†å²æ•°æ®ä¸å¯¼å…¥ã€ä¸å¯¼å‡ºã€ä¸å‚ä¸ŽåŸºæœŸè®¡ç®—ã€‚
- 2026 å¹´æ™®é€šå›½çœé“ C åˆ—导入为 `comparison_base`,D åˆ—导入为 `vehicle_count`;导出时 C/D åˆ—分别回填。
- åŸºæœŸåªå½±å“æ™®é€šå›½çœé“车流量自身的环比:有基期时 `当日 / åŸºæœŸ - 1`,无基期时 `当日 / å‰ä¸€æ—¥ - 1`。
- æŠ˜ç®—人员流动量、公路人员流动量、非营业性人员流动量、全社会人员流动量和 Word å¿«æŠ¥ç»§ç»­æŒ‰ Excel åŽŸæœ‰å…¬å¼è®¡ç®—ï¼Œä¸å—åŸºæœŸå½±å“ã€‚
- å¯¼å‡ºæ—¶å¦‚某日填写基期,普通国省道 E åˆ—公式写为 `=(D-C)/C`。
- åŸºæœŸæ”¹å˜åŒæ ·çº³å…¥åŽ†å²æ—¥æœŸå˜æ›´æ£€æŸ¥ï¼Œç”¨æˆ·ç¡®è®¤å‰ä¸å†™åº“ã€‚
- å¿«æŠ¥ç¬¬1天没有前一日可比基数,正文不输出“环比增长/下降/持平”短语;表格当日环比列为“—”。第2天起仍按实际前一日计算并输出环比。
- å¿«æŠ¥ä¸­çš„同比/环比百分比统一保留1位小数(四舍五入);人次、车流等业务数值仍保留2位小数。
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-10-02.md
New file
@@ -0,0 +1,73 @@
# å·¥ä½œæ—¥ç»“_2026-10-02
## ä¸€ã€ä»Šæ—¥å®Œæˆ
1. æ–°å¢žâ€œä¸“项统计-重要节假日”完整模块。
   - åŽç«¯æ–°å¢ž 4 å¼ è¡¨ï¼šèŠ‚å‡æ—¥é…ç½®ã€è¥ä¸šæ€§å®¢æµã€éžè¥ä¸šæ€§è½¦æµã€å®¢è¿ç³»æ•°ã€‚
   - åŽç«¯æ–°å¢žèŠ‚å‡æ—¥é…ç½®ã€ç»Ÿè®¡ã€å·¥ä½œç°¿å¯¼å…¥å¯¼å‡ºã€Word å¿«æŠ¥æœåŠ¡ã€‚
   - å‰ç«¯æ–°å¢žâ€œä¸“项统计-节假日”和“系统配置”菜单/页面。
2. æƒé™æŽ§åˆ¶å·²å®žçŽ°ã€‚
   - èŠ‚å‡æ—¥ç®¡ç†ã€å®¢è¿ç³»æ•°ç®¡ç†ä»… admin å¯ç»´æŠ¤ï¼Œéž admin è¿”回 403,菜单隐藏且直访重定向。
3. æ¨¡æ¿åˆå§‹åŒ–已实现。
   - ä»Žã€Š2026年十一数据.xlsx》初始化 2024/2025 å¹´åä¸€å®¢æµå’Œè½¦æµã€‚
   - ä»ŽåŒä¸€å·¥ä½œç°¿åˆå§‹åŒ– 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
4. å¯¼å…¥å¯¼å‡ºå·²å®žçŽ°ã€‚
   - å¯¼å…¥ä»…读取所选年份的当年列(2026 å¹´ä¸ºå®¢æµ C åˆ—、普通国省道 D åˆ—),历史 2024/2025 æ•°æ®åªåšåˆå§‹åŒ–,不要求日常重导。
   - å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€åŒ¹é…â€œç¬¬N日”,覆盖写入客流与车流。
   - å¯¼å…¥å‰æ£€æŸ¥æ›´æ—©çš„历史日期是否被改动;有变化时先不写库并列出差异,用户确认后才允许覆盖。
   - å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºæ¨¡æ¿ï¼Œå›žå¡«æ•°æ®åº“数据并保留公式。
5. å¿«æŠ¥å·²å®žçŽ°ã€‚
   - ä¸‹è½½æ–‡ä»¶åï¼š`湖北省国庆假期综合运输客流快报(10月X日).docx`。
   - çŽ¯æ¯”/同比文案按正数增长、负数下降、0 æŒå¹³è¾“出,百分比保留1位小数。
6. æ–‡æ¡£å·²åŒæ­¥ã€‚
   - æ›´æ–° `docs/sql_holiday.sql`、`docs/init.sql`、`docs/database.md`、`docs/打包部署说明.md`。
7. Excel æµ®ç‚¹ç²¾åº¦æ•´æ”¹ï¼ˆ2026-10-02 è¿½åŠ ï¼‰ã€‚
8. èŠ‚å‡æ—¥åˆ é™¤åŽé‡å¯å¤æ´»ä¿®å¤ï¼ˆ2026-10-02 è¿½åŠ ï¼‰ã€‚
9. æ™®é€šå›½çœé“环比基期(2026-10-02 è¿½åŠ ï¼‰ã€‚
   - æ™®é€šå›½çœé“ C åˆ—确认为“环比基期”:填写后按“当日/环比基期-1”计算环比,未填写时仍按“当日/前一日-1”。
   - `holiday_vehicle_flow` æ–°å¢ž `comparison_base` å­—段;仅 2026 å¹´å¯¼å…¥ã€å¯¼å‡ºå’Œå‚与计算,2024/2025 åŽ†å²æ•°æ®ä¸ä½¿ç”¨ã€‚
   - æŒ‰ Excel åŽŸå…¬å¼å£å¾„ï¼ŒåŸºæœŸåªå½±å“æ™®é€šå›½çœé“è½¦æµé‡è‡ªèº«çŽ¯æ¯”ï¼›æŠ˜ç®—äººå‘˜æµåŠ¨é‡ã€å…¬è·¯äººå‘˜æµåŠ¨é‡ã€æ€»äººå‘˜æµåŠ¨é‡å’Œ Word å¿«æŠ¥å‡ä¸å—影响。
   - åŸºæœŸå˜åŒ–仍纳入历史日期变更确认;导出时对应日 E åˆ—公式改为 `(D-C)/C`。
   - å¿«æŠ¥ç¬¬1天正文不输出“环比增长/下降/持平”短语,表格环比列为“—”;第2天起保留环比。
   - å¿«æŠ¥åŒæ¯”/环比百分比改为保留1位小数;人次、车流等数值仍保留2位小数。
   - åŽŸé€»è¾‘æ¯æ¬¡å¯åŠ¨éƒ½ä¼šè¡¥é½ç¼ºå¤±çš„ç¬¬ N æ—¥ï¼Œå¯¼è‡´ admin åˆ é™¤ 2026-10-08 åŽé‡å¯è¢«é‡æ–°æ’入。
   - ä¿®å¤ä¸ºä»…当 `holiday_calendar` æ•´è¡¨ä¸ºç©ºæ—¶æ‰§è¡Œé¦–次初始化;复测重启后 2026 å¹´åä¸€ä¿æŒ 7 å¤©ã€‚
   - æ–°å¢ž `HolidayNumberUtils`,Excel æ•°å€¼ç»Ÿä¸€ç”¨ `NumberToTextConverter.toText(double)` è½¬ `BigDecimal`,计算用 `MathContext(15, HALF_UP)`。
   - èŠ‚å‡æ—¥æ¨¡å—å®žä½“/服务/导入导出/快报由 `Double` æ”¹ä¸º `BigDecimal`;三张表数值列改为 `DECIMAL(30,15)`;库内 6 æ¡å®¢è¿ç³»æ•°ç”± 2.3999999999999995 æ ¡æ­£ä¸º 2.4。
   - â€œä¸“项统计”菜单移到“报表生成”之后;前端系数展示去除浮点尾差。
## äºŒã€æµ‹è¯•结果摘要
- åŽç«¯ç¼–译通过,前端生产构建通过。
- æºå·¥ä½œç°¿ä¸Žæ•°æ®åº“抽查:90 ä¸ªå•元格,差异 0。
- æƒé™ã€æ­£å‘ CRUD、负向导入、历史数据变更确认、Excel å¯¼å‡ºã€Word å¿«æŠ¥ã€é¡µé¢çƒŸæµ‹å…¨éƒ¨é€šè¿‡ã€‚
- åŽ†å²å˜æ›´ç«¯åˆ°ç«¯å®žæµ‹ï¼šæœªç¡®è®¤æ—¶æ•°æ®ä¸è½åº“ï¼Œç¡®è®¤åŽåŽ†å²æ—¥å€¼æŒ‰å¯¼å…¥æ–‡ä»¶æ›´æ–°ï¼›æµ‹è¯•æ•°æ®å·²æ¸…ç†ã€‚
- æ— å¤´æµè§ˆå™¨çƒŸæµ‹ï¼šadmin é¡µé¢æ­£å¸¸ï¼Œéž admin ç³»ç»Ÿé…ç½®éšè—å¹¶é‡å®šå‘,模板下载 HTTP 200,无 JS é”™è¯¯ã€‚
- ç²¾åº¦å›žå½’:系统配置-客运系数列表与节假日统计页均显示 2.4,接口 `coefficient=2.4`,三张表数值列为 `decimal(30,15)`。
- èœå•回归:侧边菜单顺序为 æŠ¥è¡¨ç”Ÿæˆ -> ä¸“项统计 -> è§„则管理 -> ç”¨æˆ·ç®¡ç† -> ç³»ç»Ÿé…ç½®ã€‚
- åŽ†å²å˜æ›´ä¸¤é˜¶æ®µå¯¼å…¥å›žå½’ï¼šä¸ç¡®è®¤æ—¶é›¶å†™å…¥å¹¶åˆ— 6 æ¡å·®å¼‚,确认后第1日覆盖为 110/110/110/110/210/310,第2日不变;导入口径仅读当年列。
- é¡µé¢è¡¨å¤´è¡¥å•位:节假日数据一览列头已带单位(万人次/万辆次),浏览器实测通过。
- åˆ é™¤å¤æ´»å›žå½’:admin åˆ é™¤ 2026-10-08 åŽé‡å¯ï¼Œæ•°æ®åº“ 7 å¤©ã€ç»Ÿè®¡æŽ¥å£ 7 è¡Œï¼Œæœ€åŽä¸€æ—¥ä¸º `2026-10-07`,未再自动补回。
- çŽ¯æ¯”åŸºæœŸå›žå½’ï¼šå¯¼å…¥ C=320、D=360 åŽåº“内和接口均正确;国省道车流环比为 12.50%,导出 E åˆ—公式为 `(D-C)/C`;快报总人员流动量仍为 17.00%、未受基期影响;历史基期变化返回确认提示且未写库;2024/2025 å¯¼å‡º C åˆ—保持为空。
- å¿«æŠ¥é¦–日回归:2025-10-01 æ­£æ–‡æ— â€œçŽ¯æ¯”â€çŸ­è¯­ï¼Œä»…ä¿ç•™åŒæ¯”ï¼›2025-10-02 ä»è¾“出“环比下降11.5%”。
- å¿«æŠ¥ç™¾åˆ†æ¯”精度回归:正文和表格同比/环比均为1位小数,如6.79%显示6.8%、11.46%显示11.5%;人数1714.41/1518.02仍保留2位小数。
- æµ‹è¯•报告:`docs/功能测试报告/功能测试报告_重要节假日专项统计_2026-10-02.md`。
## ä¸‰ã€é—留问题与待办
1. çœŸå®ž 2026 å¹´åä¸€æ•°æ®å°šæœªå¯¼å…¥ï¼›é¡µé¢é»˜è®¤å¿«æŠ¥æ—¥æœŸä¸ºç³»ç»Ÿæ—¥æœŸå‰ä¸€å¤©ï¼Œå½“前系统日期 2026-10-02 æ—¶é»˜è®¤å– 2026-10-01,用户也可手工选择日期。
2. æ˜¥èŠ‚ã€äº”ä¸€ç›®å‰æä¾›äº†é…ç½®åˆ†ç±»å’Œé€šç”¨è¡¨ç»“æž„ï¼Œä½†æœªæä¾›åˆå§‹åŒ–æ•°æ®ï¼Œéœ€è¦åŽç»­æŒ‰å¯¹åº”å¹´åº¦ç»´æŠ¤ã€‚
3. å¿«æŠ¥æ¨¡æ¿å›ºå®šä¸ºå›½åº†ç‰ˆæœ¬ï¼Œå…¶ä»–节假日如需快报需另提供模板。
4. æ­£å¼å‘布前仍需确认验证码开关等上线配置。
## å››ã€æ˜Žæ—¥è®¡åˆ’
1. æ ¹æ®ç”¨æˆ·æä¾›çš„ 2026 å¹´åä¸€æ¯æ—¥æ•°æ®ï¼ŒæŒ‡å¯¼æˆ–执行导入并核对生成快报。
2. å¦‚用户提供春节、五一日期和模板,继续扩展对应初始化与快报模板适配。
3. æ ¹æ®è¯•用反馈微调页面展示和导出格式。
## äº”、运行状态
- åŽç«¯ï¼š8090 å·²å¯åŠ¨ï¼Œæ—¥å¿—å‡ºçŽ° `Started TrafficAuditApplication`。
- å‰ç«¯ï¼š8080 å·²å¯åŠ¨ï¼Œè®¿é—® `http://localhost:8080`。
- æ•°æ®åº“:`127.0.0.1:3305/traffic_audit`。
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -63,7 +63,9 @@
│  â”œâ”€ init.sql                                  å»ºåº“建表 + é¢„置账号(必须执行)
│  â”œâ”€ sql_city_bus.sql                          åŸŽå¸‚客运-公交月度表(必须执行)
│  â”œâ”€ sql_city_taxi.sql                         åŸŽå¸‚客运-出租月度表/授权表(必须执行)
│  â””─ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_holiday.sql                           ä¸“项统计-重要节假日表(必须执行)
│  â””─ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥æ—§åº“å‡çº§ï¼šæ™®é€šå›½çœé“çŽ¯æ¯”åŸºæœŸå­—æ®µ
└─ README-部署说明.txt
```
@@ -71,20 +73,22 @@
### 5.1 å‡†å¤‡æ•°æ®åº“(两条路线二选一,**不要混着做**)
#### è·¯çº¿ A:全新空库(要跑 4 ä¸ªè„šæœ¬ï¼‰
#### è·¯çº¿ A:全新空库(要跑 5 ä¸ªè„šæœ¬ï¼‰
`init.sql` è‡ªå¸¦ `CREATE DATABASE` ä¸Ž `USE traffic_audit`,可独立执行;另外 3 ä¸ªè„šæœ¬æ²¡æœ‰ `USE` è¯­å¥ï¼Œå¿…须用 `-D traffic_audit`(或 `mysql ... traffic_audit < æ–‡ä»¶`)指定库:
`init.sql` è‡ªå¸¦ `CREATE DATABASE` ä¸Ž `USE traffic_audit`,可独立执行;另外 4 ä¸ªè„šæœ¬æ²¡æœ‰ `USE` è¯­å¥ï¼Œå¿…须用 `-D traffic_audit`(或 `mysql ... traffic_audit < æ–‡ä»¶`)指定库:
```bash
cd db
mysql --default-character-set=utf8mb4 -u root -p            < init.sql          # å»ºåº“ + 24 å¼ åŸºç¡€è¡¨ + é¢„置账号
mysql --default-character-set=utf8mb4 -u root -p            < init.sql          # å»ºåº“ + 28 å¼ åŸºç¡€è¡¨ + é¢„置账号
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_bus.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_taxi.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_wyc.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_holiday.sql
```
> âš ï¸ **`init.sql` æ˜¯ç ´åæ€§çš„**:里面的基础表都带 `DROP TABLE IF EXISTS`。**已有数据的库上千万不要再跑 init.sql**,否则表数据会被清空重建。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 29 å¼ è¡¨ï¼ˆinit.sql 24 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
> å·²éƒ¨ç½²è¿‡æ—©æœŸèŠ‚å‡æ—¥æ¨¡å—çš„çŽ°æœ‰åº“ï¼Œå‡çº§å‰å…ˆæ‰§è¡Œ `sql_holiday_comparison_base_migration.sql` æ·»åŠ  `comparison_base` å­—段;全新库直接使用已更新后的 `sql_holiday.sql`。
> 5 ä¸ªè„šæœ¬å…±è¦†ç›– 33 å¼ è¡¨ï¼ˆinit.sql 28 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
#### è·¯çº¿ B:把现网/开发机的数据整套搬过去(推荐,不用跑脚本)
@@ -105,11 +109,11 @@
mysql --default-character-set=utf8mb4 -u root -p < traffic_audit_full_YYYYMMDD.sql
```
- `--databases` ä¼šæŠŠ `CREATE DATABASE` + `USE` ä¸€èµ·å†™è¿›å¤‡ä»½ï¼Œæ‰€ä»¥ç›®æ ‡æœºå™¨**不用先建库、也不用跑那 4 ä¸ªè„šæœ¬**。
- `--databases` ä¼šæŠŠ `CREATE DATABASE` + `USE` ä¸€èµ·å†™è¿›å¤‡ä»½ï¼Œæ‰€ä»¥ç›®æ ‡æœºå™¨**不用先建库、也不用跑那 5 ä¸ªè„šæœ¬**。
- å¤‡ä»½é‡ŒåŒ…含 `sys_user`,账号密码与源机器完全一致(本机是 `admin / 123456`),不会被重置成 `admin/admin123`。
- âš ï¸ å¤‡ä»½é‡Œæ¯å¼ è¡¨éƒ½å¸¦ `DROP TABLE IF EXISTS`(共 29 å¼ ï¼‰ï¼š**目标库要是已有数据,会被直接覆盖**。请还原到空库,或先自己备份目标库。
- æœ¬æ¬¡å·²ç”Ÿæˆå¹¶**验证过**备份:`traffic_audit_full_20260913.sql`(45.5 MB,8.0.29 å®¢æˆ·ç«¯å¯¼å‡ºï¼‰ã€‚
  éªŒè¯æ–¹å¼ï¼šæŠŠå¤‡ä»½åº“名改成临时库导入,逐表比对 `SHOW CREATE TABLE`(表结构)与 `COUNT(*)`(行数)——**29 å¼ è¡¨ç»“构与行数均 0 å·®å¼‚**,抽查 `wyc_order_monthly` 2026-08 æ•°æ®ä¸€è‡´ï¼ŒéªŒè¯åŽå·²åˆ é™¤ä¸´æ—¶åº“。
  éªŒè¯æ–¹å¼ï¼šæŠŠå¤‡ä»½åº“名改成临时库导入,逐表比对 `SHOW CREATE TABLE`(表结构)与 `COUNT(*)`(行数)——**29 å¼ è¡¨ç»“构与行数均 0 å·®å¼‚(新增节假日模块后为 33 å¼ ï¼‰**,抽查 `wyc_order_monthly` 2026-08 æ•°æ®ä¸€è‡´ï¼ŒéªŒè¯åŽå·²åˆ é™¤ä¸´æ—¶åº“。
#### è·¯çº¿ B2:用 Navicat çš„「备份 / è¿˜åŽŸå¤‡ä»½ã€ï¼ˆ.nb3,最贴近日常习惯)
@@ -204,7 +208,7 @@
| å¯¼å‡ºæŠ¥ã€Œæœªæ‰¾åˆ°æ±‡æ€»è¡¨æ¯ç‰ˆã€ŠYYYYå¹´M月…》」 | `docs/生成汇总大表` ç¼ºè¯¥æœˆæ¯ç‰ˆ |
| ç™»å½•接口 500 | `jwt.secret` å¡«äº†éžæ³•字符(如中文);改成 32 ä½åå…­è¿›åˆ¶ä¸² |
| é¡µé¢èƒ½å¼€ä½†æŽ¥å£ 404 | èµ° nginx æ–¹æ¡ˆæ—¶æ²¡é… `/api` åä»£ï¼›æ”¹ç”¨ jar å†…嵌访问 8090 |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认 4 ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œ |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认 5 ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œ |
| ç«¯å£å ç”¨ | æ”¹ `server.port`;前端开发端口 8080 |
## åã€å¼€å‘环境日常启动
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java
New file
@@ -0,0 +1,116 @@
package com.trafficaudit.holiday;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class HolidayConstants {
    public static final int COMPARISON_BASE_YEAR = 2026;
    public static final String TYPE_SPRING_FESTIVAL = "SPRING_FESTIVAL";
    public static final String TYPE_LABOR_DAY = "LABOR_DAY";
    public static final String TYPE_NATIONAL_DAY = "NATIONAL_DAY";
    public static final String CATEGORY_HOLIDAY = "HOLIDAY";
    public static final String CATEGORY_NON_HOLIDAY = "NON_HOLIDAY";
    public static final String MODE_RAIL = "RAIL";
    public static final String MODE_CIVIL_AVIATION = "CIVIL_AVIATION";
    public static final String MODE_ROAD = "ROAD";
    public static final String MODE_WATER = "WATER";
    public static final String ROAD_EXPRESSWAY = "EXPRESSWAY";
    public static final String ROAD_NATIONAL_PROVINCIAL = "NATIONAL_PROVINCIAL";
    public static final String VEHICLE_SMALL_PASSENGER = "SMALL_PASSENGER";
    public static final String VEHICLE_MEDIUM_SMALL_PASSENGER = "MEDIUM_SMALL_PASSENGER";
    public static final Map<String, String> HOLIDAY_TYPE_LABELS;
    public static final Map<String, String> DATE_CATEGORY_LABELS;
    public static final Map<String, String> TRANSPORT_MODE_LABELS;
    public static final Map<String, String> ROAD_CATEGORY_LABELS;
    public static final Map<String, String> VEHICLE_TYPE_LABELS;
    private static final Set<String> HOLIDAY_TYPES;
    private static final Set<String> DATE_CATEGORIES;
    private static final Set<String> TRANSPORT_MODES;
    private static final Set<String> ROAD_CATEGORIES;
    private static final Set<String> VEHICLE_TYPES;
    static {
        Map<String, String> holidayTypes = new LinkedHashMap<>();
        holidayTypes.put(TYPE_SPRING_FESTIVAL, "春节");
        holidayTypes.put(TYPE_LABOR_DAY, "五一");
        holidayTypes.put(TYPE_NATIONAL_DAY, "十一");
        HOLIDAY_TYPE_LABELS = Collections.unmodifiableMap(holidayTypes);
        HOLIDAY_TYPES = new LinkedHashSet<>(holidayTypes.keySet());
        Map<String, String> dateCategories = new LinkedHashMap<>();
        dateCategories.put(CATEGORY_HOLIDAY, "节假日");
        dateCategories.put(CATEGORY_NON_HOLIDAY, "非节假日");
        DATE_CATEGORY_LABELS = Collections.unmodifiableMap(dateCategories);
        DATE_CATEGORIES = new LinkedHashSet<>(dateCategories.keySet());
        Map<String, String> transportModes = new LinkedHashMap<>();
        transportModes.put(MODE_RAIL, "铁路");
        transportModes.put(MODE_CIVIL_AVIATION, "民航");
        transportModes.put(MODE_ROAD, "道路");
        transportModes.put(MODE_WATER, "æ°´è·¯");
        TRANSPORT_MODE_LABELS = Collections.unmodifiableMap(transportModes);
        TRANSPORT_MODES = new LinkedHashSet<>(transportModes.keySet());
        Map<String, String> roadCategories = new LinkedHashMap<>();
        roadCategories.put(ROAD_EXPRESSWAY, "高速公路");
        roadCategories.put(ROAD_NATIONAL_PROVINCIAL, "普通国省道");
        ROAD_CATEGORY_LABELS = Collections.unmodifiableMap(roadCategories);
        ROAD_CATEGORIES = new LinkedHashSet<>(roadCategories.keySet());
        Map<String, String> vehicleTypes = new LinkedHashMap<>();
        vehicleTypes.put(VEHICLE_SMALL_PASSENGER, "小客车");
        vehicleTypes.put(VEHICLE_MEDIUM_SMALL_PASSENGER, "中小客车");
        VEHICLE_TYPE_LABELS = Collections.unmodifiableMap(vehicleTypes);
        VEHICLE_TYPES = new LinkedHashSet<>(vehicleTypes.keySet());
    }
    private HolidayConstants() {
    }
    public static boolean supportsComparisonBase(int year) {
        return year == COMPARISON_BASE_YEAR;
    }
    public static boolean isValidHolidayType(String value) {
        return HOLIDAY_TYPES.contains(value);
    }
    public static boolean isValidDateCategory(String value) {
        return DATE_CATEGORIES.contains(value);
    }
    public static boolean isValidTransportMode(String value) {
        return TRANSPORT_MODES.contains(value);
    }
    public static boolean isValidRoadCategory(String value) {
        return ROAD_CATEGORIES.contains(value);
    }
    public static boolean isValidVehicleType(String value) {
        return VEHICLE_TYPES.contains(value);
    }
    public static Map<String, Object> optionMap() {
        Map<String, Object> options = new LinkedHashMap<>();
        options.put("holidayTypes", HOLIDAY_TYPE_LABELS);
        options.put("dateCategories", DATE_CATEGORY_LABELS);
        options.put("transportModes", TRANSPORT_MODE_LABELS);
        options.put("roadCategories", ROAD_CATEGORY_LABELS);
        options.put("vehicleTypes", VEHICLE_TYPE_LABELS);
        options.put("years", Arrays.asList(2024, 2025, 2026));
        return options;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java
New file
@@ -0,0 +1,70 @@
package com.trafficaudit.holiday;
import org.apache.poi.ss.util.NumberToTextConverter;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
public final class HolidayNumberUtils {
    public static final MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
    public static final BigDecimal ZERO = BigDecimal.ZERO;
    private HolidayNumberUtils() {
    }
    public static BigDecimal excelValue(double value) {
        return normalize(new BigDecimal(NumberToTextConverter.toText(value)));
    }
    public static BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return normalize(new BigDecimal(value.toString().trim()));
    }
    public static BigDecimal normalize(BigDecimal value) {
        if (value == null) return null;
        BigDecimal rounded = value.round(EXCEL_MC);
        return rounded.signum() == 0 ? ZERO : rounded.stripTrailingZeros();
    }
    public static BigDecimal valueOrZero(BigDecimal value) {
        return value == null ? ZERO : value;
    }
    public static BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
        if (left == null && right == null) return null;
        return normalize(valueOrZero(left).add(valueOrZero(right)));
    }
    public static BigDecimal multiply(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return null;
        return normalize(left.multiply(right));
    }
    public static BigDecimal divide(BigDecimal dividend, BigDecimal divisor) {
        if (dividend == null || divisor == null || divisor.signum() == 0) return null;
        return normalize(dividend.divide(divisor, EXCEL_MC));
    }
    public static boolean sameValue(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return false;
        return normalize(left).compareTo(normalize(right)) == 0;
    }
    public static String changeText(BigDecimal value) {
        if (value == null) return "";
        return normalize(value).toPlainString();
    }
    public static String format1(BigDecimal value) {
        if (value == null) return "";
        return value.setScale(1, RoundingMode.HALF_UP).toPlainString();
    }
    public static String format2(BigDecimal value) {
        if (value == null) return "";
        return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java
New file
@@ -0,0 +1,95 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.service.HolidayConfigService;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday/config")
public class HolidayConfigController {
    @Resource
    private HolidayConfigService configService;
    @Resource
    private JwtUtils jwtUtils;
    @GetMapping("/options")
    public Result<Map<String, Object>> options() {
        return Result.ok(HolidayConstants.optionMap());
    }
    @GetMapping("/holidays")
    public Result<List<HolidayCalendar>> holidays(@RequestParam(value = "year", required = false) Integer year,
                                                   @RequestParam(value = "holidayType", required = false) String holidayType) {
        return Result.ok(configService.listHolidays(year, holidayType));
    }
    @PostMapping("/holidays/save")
    public Result<HolidayCalendar> saveHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
                                               @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†èŠ‚å‡æ—¥é…ç½®");
        try {
            return Result.ok(configService.saveHoliday(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/holidays/delete")
    public Result<Void> deleteHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
                                      @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†èŠ‚å‡æ—¥é…ç½®");
        try {
            configService.deleteHoliday(id);
            return Result.ok();
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/coefficients")
    public Result<List<HolidayPassengerCoefficient>> coefficients(@RequestParam(value = "year", required = false) Integer year) {
        return Result.ok(configService.listCoefficients(year));
    }
    @PostMapping("/coefficients/save")
    public Result<HolidayPassengerCoefficient> saveCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
                                                               @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†å®¢è¿ç³»æ•°");
        try {
            return Result.ok(configService.saveCoefficient(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/coefficients/delete")
    public Result<Void> deleteCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
                                          @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†å®¢è¿ç³»æ•°");
        try {
            configService.deleteCoefficient(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/controller/HolidayStatController.java
New file
@@ -0,0 +1,82 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.service.HolidayBulletinService;
import com.trafficaudit.holiday.service.HolidayStatService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday")
public class HolidayStatController {
    @Resource
    private HolidayStatService statService;
    @Resource
    private HolidayBulletinService bulletinService;
    @GetMapping("/overview")
    public Result<Map<String, Object>> overview(@RequestParam("year") int year,
                                                 @RequestParam("holidayType") String holidayType) {
        try {
            return Result.ok(statService.overview(year, holidayType));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/import")
    public Result<Map<String, Object>> importWorkbook(@RequestParam(value = "file", required = false) MultipartFile file,
                                                       @RequestParam("year") int year,
                                                       @RequestParam("holidayType") String holidayType,
                                                       @RequestParam(value = "allowHistoricalOverwrite", defaultValue = "false") boolean allowHistoricalOverwrite) {
        try {
            return Result.ok(statService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/export")
    public ResponseEntity<byte[]> exportWorkbook(@RequestParam("year") int year,
                                                  @RequestParam("holidayType") String holidayType) throws Exception {
        byte[] data = statService.exportWorkbook(year, holidayType);
        String fileName = year + "å¹´" + holidayTypeFileLabel(holidayType) + "数据.xlsx";
        String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
                .body(data);
    }
    @GetMapping("/bulletin")
    public ResponseEntity<byte[]> bulletin(@RequestParam(value = "date", required = false) String date) throws Exception {
        LocalDate targetDate = (date == null || date.trim().isEmpty())
                ? LocalDate.now().minusDays(1) : LocalDate.parse(date);
        byte[] data = bulletinService.generate(targetDate);
        String fileName = String.format("湖北省国庆假期综合运输客流快报(10月%d日).docx", targetDate.getDayOfMonth());
        String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
                .body(data);
    }
    private String holidayTypeFileLabel(String holidayType) {
        if ("SPRING_FESTIVAL".equals(holidayType)) return "春节";
        if ("LABOR_DAY".equals(holidayType)) return "五一";
        return "十一";
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java
New file
@@ -0,0 +1,22 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_calendar")
public class HolidayCalendar {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private String holidayType;
    private LocalDate holidayDate;
    private Integer dayIndex;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java
New file
@@ -0,0 +1,21 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("holiday_passenger_coefficient")
public class HolidayPassengerCoefficient {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private String dateCategory;
    private BigDecimal coefficient;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java
New file
@@ -0,0 +1,23 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_passenger_flow")
public class HolidayPassengerFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private String transportMode;
    private BigDecimal passengerCount;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java
New file
@@ -0,0 +1,28 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
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_flow")
public class HolidayVehicleFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private String roadCategory;
    private String vehicleType;
    private BigDecimal vehicleCount;
    @TableField(updateStrategy = FieldStrategy.IGNORED)
    private BigDecimal comparisonBase;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayCalendarMapper extends BaseMapper<HolidayCalendar> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayPassengerCoefficientMapper extends BaseMapper<HolidayPassengerCoefficient> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayPassengerFlowMapper extends BaseMapper<HolidayPassengerFlow> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayVehicleFlowMapper extends BaseMapper<HolidayVehicleFlow> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
New file
@@ -0,0 +1,430 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@Slf4j
@Service
public class HolidayBulletinService {
    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final BigDecimal EPSILON = new BigDecimal("0.0000001");
    @Value("${holiday.bulletin-template:docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx}")
    private String bulletinTemplatePath;
    private final HolidayStatService statService;
    private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
    public HolidayBulletinService(HolidayStatService statService) {
        this.statService = statService;
    }
    public byte[] generate(LocalDate date) {
        DailyMetrics current = statService.findMetricsByDate(date);
        if (current == null) {
            throw new IllegalArgumentException("所选日期不在已配置的节假日范围内");
        }
        DailyMetrics previousDay = current.getDayIndex() > 1
                ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : null;
        DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex());
        DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        if (current.getTotalPersonFlow() == null) {
            throw new IllegalArgumentException("所选日期的全社会跨区域人员流动量没有足够数据,无法生成快报");
        }
        String currentTotal = formatNumber(current.getTotalPersonFlow());
        String cumulativeTotalYi = formatNumber(currentCumulative.getTotalPersonFlow() == null
                ? null : HolidayNumberUtils.divide(currentCumulative.getTotalPersonFlow(), BigDecimal.valueOf(10000)));
        String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase(
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow());
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s。",
                date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
                ringRatioClause,
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()));
        String paragraph2 = String.format("假期以来,全省全社会跨区域人员流动量累计%s亿人次,比2025年同期%s。",
                cumulativeTotalYi,
                ratioPhrase(currentCumulative.getTotalPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
            throw new IllegalArgumentException("快报模板文件不存在: " + template.getAbsolutePath());
        }
        try {
            byte[] templateBytes;
            try (InputStream in = new FileInputStream(template)) {
                templateBytes = readAll(in);
            }
            byte[] documentXml = readZipEntry(templateBytes, "word/document.xml");
            Document document = parseDocument(documentXml);
            CURRENT_DOCUMENT.set(document);
            try {
                replaceParagraph(document, text -> text.startsWith("10月") && text.contains("假期第"), paragraph1);
                replaceParagraph(document, text -> text.startsWith("假期以来"), paragraph2);
                fillTable(document, current, previousDay, previousYear, currentCumulative, previousYearCumulative);
                byte[] replacedXml = serializeDocument(document);
                return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
            } finally {
                CURRENT_DOCUMENT.remove();
            }
        } catch (Exception e) {
            throw new IllegalArgumentException("快报生成失败: " + e.getMessage(), e);
        }
    }
    private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
                           DailyMetrics previousYear, DailyMetrics currentCumulative,
                           DailyMetrics previousYearCumulative) {
        Element table = firstElement(document, "tbl");
        if (table == null) {
            throw new IllegalArgumentException("快报模板中找不到数据表");
        }
        writeTableRow(table, 2, new String[]{
                number(current.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()),
                "—",
                number(currentCumulative.getNonBusinessPersonFlow()),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()),
                "—"
        });
        writeTableRow(table, 3, new String[]{
                number(current.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()),
                "—",
                number(currentCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()),
                "—"
        });
        writeTableRow(table, 4, new String[]{
                number(current.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()),
                "—",
                number(currentCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                "—"
        });
        writeTableRow(table, 5, transportRow(current.getRail(), previousDay == null ? null : previousDay.getRail(),
                previousYear == null ? null : previousYear.getRail(), currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail()));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(), previousDay == null ? null : previousDay.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(), currentCumulative.getCivilAviation(),
                previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation()));
        writeTableRow(table, 7, transportRow(current.getWater(), previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(), currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(), currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
    }
    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
                                  BigDecimal currentCumulative, BigDecimal previousYearCumulative) {
        return new String[]{
                number(current),
                ratio(current, previousDay),
                ratio(current, previousYear),
                "—",
                number(currentCumulative),
                ratio(currentCumulative, previousYearCumulative),
                "—"
        };
    }
    private void writeTableRow(Element table, int rowIndex, String[] values) {
        List<Element> rows = childElements(table, "tr");
        if (rowIndex >= rows.size()) return;
        Element row = rows.get(rowIndex);
        List<Element> cells = cellRefs(row);
        int logicalColumn = 0;
        for (Element cell : cells) {
            int span = gridSpan(cell);
            if (logicalColumn >= 2 && logicalColumn < 9) {
                int valueIndex = logicalColumn - 2;
                if (valueIndex < values.length) {
                    setCellText(cell, values[valueIndex]);
                }
            }
            logicalColumn += span;
        }
    }
    private List<Element> cellRefs(Element row) {
        List<Element> cells = new ArrayList<>();
        for (Element child : childElements(row, "tc")) cells.add(child);
        return cells;
    }
    private int gridSpan(Element cell) {
        Element tcPr = firstChildElement(cell, "tcPr");
        if (tcPr == null) return 1;
        Element span = firstChildElement(tcPr, "gridSpan");
        if (span == null) return 1;
        String value = span.getAttributeNS(WORD_NS, "val");
        try {
            return Integer.parseInt(value);
        } catch (Exception e) {
            return 1;
        }
    }
    private void setCellText(Element cell, String text) {
        Element tcPr = firstChildElement(cell, "tcPr");
        Element templateParagraph = firstChildElement(cell, "p");
        Element paragraph = createElement("p");
        if (templateParagraph != null) {
            Element pPr = firstChildElement(templateParagraph, "pPr");
            if (pPr != null) paragraph.appendChild(pPr.cloneNode(true));
        }
        Element run = createElement("r");
        Element t = createElement("t");
        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
        t.setTextContent(text == null ? "" : text);
        run.appendChild(t);
        paragraph.appendChild(run);
        List<Node> removals = new ArrayList<>();
        Node child = cell.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            if (child instanceof Element && "p".equals(localName(child))) {
                removals.add(child);
            }
            child = next;
        }
        for (Node node : removals) cell.removeChild(node);
        if (tcPr != null && tcPr.getNextSibling() != null) {
            cell.insertBefore(paragraph, tcPr.getNextSibling());
        } else if (tcPr != null) {
            cell.appendChild(paragraph);
        } else {
            cell.insertBefore(paragraph, cell.getFirstChild());
        }
    }
    private void replaceParagraph(Document document, ParagraphMatcher matcher, String replacement) {
        NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p");
        for (int i = 0; i < paragraphs.getLength(); i++) {
            Element paragraph = (Element) paragraphs.item(i);
            String text = paragraphText(paragraph);
            if (matcher.matches(text)) {
                setParagraphText(paragraph, replacement);
                return;
            }
        }
        throw new IllegalArgumentException("快报模板缺少可替换段落: " + replacement);
    }
    private void setParagraphText(Element paragraph, String text) {
        Element pPr = firstChildElement(paragraph, "pPr");
        Element firstRun = firstChildElement(paragraph, "r");
        Element rPr = firstRun == null ? null : firstChildElement(firstRun, "rPr");
        List<Node> removals = new ArrayList<>();
        Node child = paragraph.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            if (child != pPr) removals.add(child);
            child = next;
        }
        for (Node node : removals) paragraph.removeChild(node);
        Element run = createElement("r");
        if (rPr != null) run.appendChild(rPr.cloneNode(true));
        Element t = createElement("t");
        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
        t.setTextContent(text);
        run.appendChild(t);
        paragraph.appendChild(run);
    }
    private String paragraphText(Element paragraph) {
        StringBuilder builder = new StringBuilder();
        NodeList texts = paragraph.getElementsByTagNameNS(WORD_NS, "t");
        for (int i = 0; i < texts.getLength(); i++) {
            builder.append(texts.item(i).getTextContent());
        }
        return builder.toString();
    }
    private String ratioPhrase(BigDecimal current, BigDecimal base) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "无可比数据";
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
        if (percent == null || percent.abs().compareTo(EPSILON) < 0) return "持平";
        return (percent.signum() > 0 ? "增长" : "下降") + HolidayNumberUtils.format1(percent.abs()) + "%";
    }
    private String ratio(BigDecimal current, BigDecimal base) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "—";
        return ratioPhrase(current, base);
    }
    private String number(BigDecimal value) {
        return value == null ? "—" : formatNumber(value);
    }
    private String formatNumber(BigDecimal value) {
        if (value == null) return "";
        return HolidayNumberUtils.format2(value);
    }
    private File resolveTemplateFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(bulletinTemplatePath);
        candidates.add(configured);
        if (!configured.isAbsolute()) {
            candidates.add(new File(System.getProperty("user.dir"), bulletinTemplatePath));
            candidates.add(new File(System.getProperty("user.dir"), "../" + bulletinTemplatePath));
        }
        for (File candidate : candidates) {
            try {
                if (candidate.exists() && candidate.isFile()) {
                    return candidate.getCanonicalFile();
                }
            } catch (Exception ignored) {
                // Keep checking.
            }
        }
        return configured;
    }
    private byte[] readZipEntry(byte[] zipBytes, String entryName) throws Exception {
        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
            ZipEntry entry;
            while ((entry = zin.getNextEntry()) != null) {
                if (entryName.equals(entry.getName())) {
                    return readAll(zin);
                }
            }
        }
        throw new IllegalArgumentException("模板中缺少 " + entryName);
    }
    private byte[] replaceZipEntry(byte[] zipBytes, String entryName, byte[] replacement) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes));
             ZipOutputStream zout = new ZipOutputStream(out)) {
            ZipEntry entry;
            byte[] buffer = new byte[8192];
            while ((entry = zin.getNextEntry()) != null) {
                ZipEntry target = new ZipEntry(entry.getName());
                zout.putNextEntry(target);
                if (entryName.equals(entry.getName())) {
                    zout.write(replacement);
                } else {
                    int length;
                    while ((length = zin.read(buffer)) > 0) {
                        zout.write(buffer, 0, length);
                    }
                }
                zout.closeEntry();
            }
        }
        return out.toByteArray();
    }
    private Document parseDocument(byte[] xml) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
    }
    private byte[] serializeDocument(Document document) throws Exception {
        Transformer transformer = TransformerFactory.newInstance().newTransformer();
        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        transformer.transform(new DOMSource(document), new StreamResult(out));
        return out.toByteArray();
    }
    private Element firstElement(Document document, String localName) {
        NodeList list = document.getElementsByTagNameNS(WORD_NS, localName);
        return list.getLength() == 0 ? null : (Element) list.item(0);
    }
    private Element createElement(String localName) {
        Document document = CURRENT_DOCUMENT.get();
        if (document == null) {
            throw new IllegalStateException("Word document is not initialized");
        }
        return document.createElementNS(WORD_NS, "w:" + localName);
    }
    private List<Element> childElements(Element parent, String localName) {
        List<Element> result = new ArrayList<>();
        Node child = parent.getFirstChild();
        while (child != null) {
            if (child instanceof Element && localName.equals(localName(child))) {
                result.add((Element) child);
            }
            child = child.getNextSibling();
        }
        return result;
    }
    private Element firstChildElement(Element parent, String localName) {
        Node child = parent.getFirstChild();
        while (child != null) {
            if (child instanceof Element && localName.equals(localName(child))) {
                return (Element) child;
            }
            child = child.getNextSibling();
        }
        return null;
    }
    private String localName(Node node) {
        return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
    }
    private byte[] readAll(InputStream in) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] buffer = new byte[8192];
        int length;
        while ((length = in.read(buffer)) > 0) {
            out.write(buffer, 0, length);
        }
        return out.toByteArray();
    }
    private interface ParagraphMatcher {
        boolean matches(String text);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
New file
@@ -0,0 +1,202 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.mapper.HolidayCalendarMapper;
import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class HolidayConfigService {
    @Resource
    private HolidayCalendarMapper holidayCalendarMapper;
    @Resource
    private HolidayPassengerCoefficientMapper coefficientMapper;
    public List<HolidayCalendar> listHolidays(Integer year, String holidayType) {
        LambdaQueryWrapper<HolidayCalendar> wrapper = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(year != null, HolidayCalendar::getYear, year)
                .eq(holidayType != null && !holidayType.trim().isEmpty(), HolidayCalendar::getHolidayType, holidayType)
                .orderByAsc(HolidayCalendar::getYear)
                .orderByAsc(HolidayCalendar::getHolidayType)
                .orderByAsc(HolidayCalendar::getDayIndex);
        return holidayCalendarMapper.selectList(wrapper);
    }
    public List<HolidayCalendar> findHolidayDays(int year, String holidayType) {
        return holidayCalendarMapper.selectList(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .orderByAsc(HolidayCalendar::getDayIndex));
    }
    public HolidayCalendar findHolidayByDate(LocalDate date) {
        return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getHolidayDate, date)
                .orderByAsc(HolidayCalendar::getDayIndex)
                .last("limit 1"));
    }
    @Transactional
    public HolidayCalendar saveHoliday(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        String holidayType = stringValue(params.get("holidayType"));
        LocalDate holidayDate = dateValue(params.get("holidayDate"));
        Integer dayIndex = intValue(params.get("dayIndex"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (!HolidayConstants.isValidHolidayType(holidayType)) {
            throw new IllegalArgumentException("节假日分类不正确");
        }
        if (holidayDate == null || holidayDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (dayIndex == null || dayIndex < 1) {
            throw new IllegalArgumentException("第几日必须从 1 å¼€å§‹");
        }
        LambdaQueryWrapper<HolidayCalendar> duplicate = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .and(w -> w.eq(HolidayCalendar::getHolidayDate, holidayDate)
                        .or().eq(HolidayCalendar::getDayIndex, dayIndex));
        if (id != null) {
            duplicate.ne(HolidayCalendar::getId, id);
        }
        if (holidayCalendarMapper.selectCount(duplicate) > 0) {
            throw new IllegalArgumentException("同一节假日下的日期或第几日已存在");
        }
        HolidayCalendar entity = id == null ? new HolidayCalendar() : holidayCalendarMapper.selectById(id);
        if (entity == null) {
            throw new IllegalArgumentException("节假日记录不存在");
        }
        if (id == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setHolidayType(holidayType);
        entity.setHolidayDate(holidayDate);
        entity.setDayIndex(dayIndex);
        entity.setUpdatedAt(LocalDateTime.now());
        if (id == null) {
            holidayCalendarMapper.insert(entity);
        } else {
            holidayCalendarMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void deleteHoliday(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        holidayCalendarMapper.deleteById(id);
    }
    public List<HolidayPassengerCoefficient> listCoefficients(Integer year) {
        return coefficientMapper.selectList(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                .eq(year != null, HolidayPassengerCoefficient::getYear, year)
                .orderByAsc(HolidayPassengerCoefficient::getYear)
                .orderByAsc(HolidayPassengerCoefficient::getDateCategory));
    }
    public BigDecimal coefficientOf(int year, String dateCategory) {
        HolidayPassengerCoefficient row = coefficientMapper.selectOne(
                new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)
                        .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
                        .last("limit 1"));
        return row == null ? null : row.getCoefficient();
    }
    @Transactional
    public HolidayPassengerCoefficient saveCoefficient(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        String dateCategory = stringValue(params.get("dateCategory"));
        BigDecimal coefficient = decimalValue(params.get("coefficient"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (!HolidayConstants.isValidDateCategory(dateCategory)) {
            throw new IllegalArgumentException("日期分类不正确");
        }
        if (coefficient == null || coefficient.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("客运系数必须大于 0");
        }
        LambdaQueryWrapper<HolidayPassengerCoefficient> duplicate = new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                .eq(HolidayPassengerCoefficient::getYear, year)
                .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory);
        if (id != null) {
            duplicate.ne(HolidayPassengerCoefficient::getId, id);
        }
        if (coefficientMapper.selectCount(duplicate) > 0) {
            throw new IllegalArgumentException("该年份和日期分类的客运系数已存在");
        }
        HolidayPassengerCoefficient entity = id == null ? new HolidayPassengerCoefficient() : coefficientMapper.selectById(id);
        if (entity == null) {
            throw new IllegalArgumentException("客运系数记录不存在");
        }
        if (id == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setDateCategory(dateCategory);
        entity.setCoefficient(coefficient);
        entity.setUpdatedAt(LocalDateTime.now());
        if (id == null) {
            coefficientMapper.insert(entity);
        } else {
            coefficientMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void deleteCoefficient(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        coefficientMapper.deleteById(id);
    }
    private Long longValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Long.valueOf(value.toString());
    }
    private Integer intValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Integer.valueOf(value.toString());
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return com.trafficaudit.holiday.HolidayNumberUtils.decimalValue(value);
    }
    private String stringValue(Object value) {
        return value == null ? null : value.toString().trim();
    }
    private LocalDate dateValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return LocalDate.parse(value.toString());
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java
New file
@@ -0,0 +1,21 @@
package com.trafficaudit.holiday.service;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
@Order(100)
public class HolidayReferenceDataInitializer implements ApplicationRunner {
    @Resource
    private HolidayWorkbookService workbookService;
    @Override
    public void run(ApplicationArguments args) {
        workbookService.seedReferenceDataIfNeeded();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
New file
@@ -0,0 +1,297 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.dataimport.entity.ImportBatch;
import com.trafficaudit.dataimport.mapper.ImportBatchMapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class HolidayStatService {
    @Resource
    private HolidayConfigService configService;
    @Resource
    private HolidayWorkbookService workbookService;
    @Resource
    private HolidayPassengerFlowMapper passengerFlowMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    @Resource
    private ImportBatchMapper importBatchMapper;
    public Map<String, Object> overview(int year, String holidayType) {
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("year", year);
        result.put("holidayType", holidayType);
        result.put("holidayTypeLabel", HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType));
        result.put("days", calendar);
        result.put("rows", buildOverviewRows(year, holidayType, calendar));
        result.put("templateFileName", workbookService.referenceFileName());
        return result;
    }
    @Transactional
    public Map<String, Object> importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
        HolidayWorkbookService.ImportSummary summary = workbookService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite);
        if (summary.isConfirmationRequired()) {
            return importResult(summary);
        }
        ImportBatch batch = new ImportBatch();
        batch.setFileName(file == null ? null : file.getOriginalFilename());
        batch.setImportType("HOLIDAY_FLOW");
        batch.setReportPeriod(year + "-" + holidayType);
        batch.setTotalRows(summary.getSuccessRows() + summary.getFailRows());
        batch.setSuccessRows(summary.getSuccessRows());
        batch.setFailRows(summary.getFailRows());
        batch.setErrorDetail(joinErrors(summary.getErrors()));
        batch.setCreatedAt(LocalDateTime.now());
        importBatchMapper.insert(batch);
        return importResult(summary);
    }
    private Map<String, Object> importResult(HolidayWorkbookService.ImportSummary summary) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("passengerRows", summary.getPassengerRows());
        result.put("vehicleRows", summary.getVehicleRows());
        result.put("skippedRows", summary.getSkippedRows());
        result.put("successRows", summary.getSuccessRows());
        result.put("failRows", summary.getFailRows());
        result.put("errors", summary.getErrors());
        result.put("confirmationRequired", summary.isConfirmationRequired());
        result.put("historicalChanges", summary.getHistoricalChanges());
        return result;
    }
    public byte[] exportWorkbook(int year, String holidayType) {
        return workbookService.exportWorkbook(year, holidayType);
    }
    public List<Map<String, Object>> buildOverviewRows(int year, String holidayType, List<HolidayCalendar> calendar) {
        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);
        List<Map<String, Object>> rows = new ArrayList<>();
        BigDecimal previousNationalProvincial = null;
        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);
            }
            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("nationalProvincialRingRatio", nationalProvincialRingRatio);
            row.put("coefficient", coefficient);
            row.put("nonBusinessPersonFlow", nonBusinessPersonFlow);
            row.put("totalPersonFlow", totalPersonFlow(passenger, nonBusinessPersonFlow));
            rows.add(row);
            previousNationalProvincial = nationalProvincial;
        }
        return rows;
    }
    public DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex) {
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        HolidayCalendar target = null;
        for (HolidayCalendar day : calendar) {
            if (day.getDayIndex() != null && day.getDayIndex() == dayIndex) {
                target = day;
                break;
            }
        }
        if (target == null) return null;
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
        BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        BigDecimal comparisonBase = comparisonBaseByDate.get(target.getHolidayDate());
        BigDecimal nonBusiness = null;
        if (coefficient != null && (expressway != null || nationalProvincial != null)) {
            nonBusiness = HolidayNumberUtils.multiply(
                    HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
        }
        DailyMetrics metrics = new DailyMetrics();
        metrics.setYear(year);
        metrics.setHolidayType(holidayType);
        metrics.setDayIndex(dayIndex);
        metrics.setDate(target.getHolidayDate());
        metrics.setRail(passenger.get(HolidayConstants.MODE_RAIL));
        metrics.setCivilAviation(passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
        metrics.setRoad(passenger.get(HolidayConstants.MODE_ROAD));
        metrics.setWater(passenger.get(HolidayConstants.MODE_WATER));
        metrics.setExpressway(expressway);
        metrics.setNationalProvincial(nationalProvincial);
        metrics.setComparisonBase(comparisonBase);
        metrics.setCoefficient(coefficient);
        metrics.setNonBusinessPersonFlow(nonBusiness);
        metrics.setRoadPersonFlow(sumNullable(metrics.getRoad(), nonBusiness));
        metrics.setTotalPersonFlow(totalPersonFlow(passenger, nonBusiness));
        return metrics;
    }
    public DailyMetrics cumulativeMetrics(int year, String holidayType, int dayIndex) {
        DailyMetrics cumulative = new DailyMetrics();
        cumulative.setYear(year);
        cumulative.setHolidayType(holidayType);
        cumulative.setDayIndex(dayIndex);
        for (int day = 1; day <= dayIndex; day++) {
            DailyMetrics item = dailyMetrics(year, holidayType, day);
            if (item == null) continue;
            cumulative.setRail(sumNullable(cumulative.getRail(), item.getRail()));
            cumulative.setCivilAviation(sumNullable(cumulative.getCivilAviation(), item.getCivilAviation()));
            cumulative.setRoad(sumNullable(cumulative.getRoad(), item.getRoad()));
            cumulative.setWater(sumNullable(cumulative.getWater(), item.getWater()));
            cumulative.setExpressway(sumNullable(cumulative.getExpressway(), item.getExpressway()));
            cumulative.setNationalProvincial(sumNullable(cumulative.getNationalProvincial(), item.getNationalProvincial()));
            cumulative.setNonBusinessPersonFlow(sumNullable(cumulative.getNonBusinessPersonFlow(), item.getNonBusinessPersonFlow()));
            cumulative.setRoadPersonFlow(sumNullable(cumulative.getRoadPersonFlow(), item.getRoadPersonFlow()));
            cumulative.setTotalPersonFlow(sumNullable(cumulative.getTotalPersonFlow(), item.getTotalPersonFlow()));
        }
        return cumulative;
    }
    public DailyMetrics findMetricsByDate(LocalDate date) {
        HolidayCalendar calendar = configService.findHolidayByDate(date);
        if (calendar == null) return null;
        return dailyMetrics(calendar.getYear(), calendar.getHolidayType(), calendar.getDayIndex());
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
        for (HolidayPassengerFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getTransportMode(), row.getPassengerCount());
        }
        return result;
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
        for (HolidayVehicleFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getRoadCategory(), row.getVehicleCount());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) {
            if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
        }
        return result;
    }
    private BigDecimal totalPersonFlow(Map<String, BigDecimal> passenger, BigDecimal nonBusinessPersonFlow) {
        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));
        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);
        return quotient == null ? null : HolidayNumberUtils.normalize(quotient.subtract(BigDecimal.ONE));
    }
    private BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
        return HolidayNumberUtils.sumNullable(left, right);
    }
    private String joinErrors(List<String> errors) {
        if (errors == null || errors.isEmpty()) return null;
        StringBuilder builder = new StringBuilder();
        int limit = Math.min(errors.size(), 200);
        for (int i = 0; i < limit; i++) {
            if (i > 0) builder.append('\n');
            builder.append(errors.get(i));
        }
        if (errors.size() > limit) {
            builder.append("\n... ").append(errors.size() - limit).append(" more");
        }
        return builder.toString();
    }
    @Data
    public static class DailyMetrics {
        private int year;
        private String holidayType;
        private int dayIndex;
        private LocalDate date;
        private BigDecimal rail;
        private BigDecimal civilAviation;
        private BigDecimal road;
        private BigDecimal water;
        private BigDecimal expressway;
        private BigDecimal nationalProvincial;
        private BigDecimal comparisonBase;
        private BigDecimal coefficient;
        private BigDecimal nonBusinessPersonFlow;
        private BigDecimal roadPersonFlow;
        private BigDecimal totalPersonFlow;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
New file
@@ -0,0 +1,880 @@
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.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class HolidayWorkbookService {
    private static final Map<String, String> PASSENGER_SHEET_MODE = new LinkedHashMap<>();
    private static final int FIRST_DATA_ROW_INDEX = 2;
    private static final int[] REFERENCE_YEARS = {2024, 2025, 2026};
    static {
        PASSENGER_SHEET_MODE.put("铁路", HolidayConstants.MODE_RAIL);
        PASSENGER_SHEET_MODE.put("民航", HolidayConstants.MODE_CIVIL_AVIATION);
        PASSENGER_SHEET_MODE.put("道路", HolidayConstants.MODE_ROAD);
        PASSENGER_SHEET_MODE.put("æ°´è·¯", HolidayConstants.MODE_WATER);
    }
    @Resource
    private HolidayConfigService configService;
    @Resource
    private HolidayPassengerFlowMapper passengerFlowMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    @Resource
    private HolidayPassengerCoefficientMapper coefficientMapper;
    @Value("${holiday.reference-path:docs/专项统计/重要节假日/2026年十一数据.xlsx}")
    private String referencePath;
    public File resolveReferenceFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(referencePath);
        candidates.add(configured);
        if (!configured.isAbsolute()) {
            candidates.add(new File(System.getProperty("user.dir"), referencePath));
            candidates.add(new File(System.getProperty("user.dir"), "../" + referencePath));
        }
        for (File candidate : candidates) {
            try {
                if (candidate.exists() && candidate.isFile()) {
                    return candidate.getCanonicalFile();
                }
            } catch (Exception ignored) {
                // Keep checking the remaining candidate paths.
            }
        }
        return configured;
    }
    public String referenceFileName() {
        return resolveReferenceFile().getName();
    }
    @Transactional
    public ImportSummary importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("请选择要导入的 Excel æ–‡ä»¶");
        }
        List<HolidayCalendar> holidayDays = configService.findHolidayDays(year, holidayType);
        if (holidayDays.isEmpty()) {
            throw new IllegalArgumentException("所选年份和节假日尚未配置日期,请先在系统配置中维护节假日");
        }
        Map<Integer, LocalDate> dateByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : holidayDays) {
            dateByDay.put(day.getDayIndex(), day.getHolidayDate());
        }
        if (!allowHistoricalOverwrite) {
            ImportSummary historicalSummary = detectHistoricalChanges(file, year, dateByDay);
            if (historicalSummary.isConfirmationRequired()) {
                return historicalSummary;
            }
        }
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            importPassengerSheets(workbook, evaluator, year, dateByDay, summary);
            importVehicleSheets(workbook, evaluator, year, dateByDay, summary);
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        return summary;
    }
    private ImportSummary detectHistoricalChanges(MultipartFile file, int year, Map<Integer, LocalDate> dateByDay) {
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            int latestDay = findLatestIncomingDay(workbook, evaluator, year, dateByDay);
            if (latestDay <= 1) {
                return summary;
            }
            Map<LocalDate, Map<String, BigDecimal>> existingPassenger = loadPassengerByDate(year);
            Map<LocalDate, Map<String, BigDecimal>> existingVehicle = loadVehicleByDate(year);
            detectPassengerHistory(workbook, evaluator, year, dateByDay, latestDay, existingPassenger, summary);
            detectVehicleHistory(workbook, evaluator, year, dateByDay, latestDay, existingVehicle, summary);
            if (!summary.getHistoricalChanges().isEmpty()) {
                summary.setConfirmationRequired(true);
            }
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        return summary;
    }
    private int findLatestIncomingDay(Workbook workbook, FormulaEvaluator evaluator, int year, Map<Integer, LocalDate> dateByDay) {
        int latest = 0;
        for (Integer dayIndex : dateByDay.keySet()) {
            if (hasPassengerValue(workbook, evaluator, year, dayIndex)
                    || hasVehicleValue(workbook, evaluator, year, dayIndex)) {
                latest = Math.max(latest, dayIndex);
            }
        }
        return latest;
    }
    private boolean hasPassengerValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) return true;
        }
        return false;
    }
    private boolean hasVehicleValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
        Sheet expressway = workbook.getSheet("高速公路");
        if (expressway != null) {
            Integer yearColumn = findYearColumn(expressway, evaluator, year, 2, 7);
            if (yearColumn != null) {
                Row row = expressway.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
        Sheet nationalProvincial = workbook.getSheet("普通国省道");
        if (nationalProvincial != null) {
            Integer yearColumn = findYearColumn(nationalProvincial, evaluator, year, 3, 8);
            if (yearColumn != null) {
                Row row = nationalProvincial.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
        return false;
    }
    private void detectPassengerHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       Map<Integer, LocalDate> dateByDay, int latestDay,
                                       Map<LocalDate, Map<String, BigDecimal>> existing,
                                       ImportSummary summary) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                if (day.getKey() >= latestDay) continue;
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                if (row == null) continue;
                BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
                if (incoming == null) continue;
                Map<String, BigDecimal> existingDay = existing.get(day.getValue());
                BigDecimal oldValue = existingDay == null ? null : existingDay.get(entry.getValue());
                if (oldValue != null && !sameValue(oldValue, incoming)) {
                    summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " "
                            + entry.getKey() + ":原值 " + formatChangeValue(oldValue) + ",导入值 " + formatChangeValue(incoming));
                }
            }
        }
    }
    private void detectVehicleHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                      Map<Integer, LocalDate> dateByDay, int latestDay,
                                      Map<LocalDate, Map<String, BigDecimal>> existing,
                                      ImportSummary summary) {
        detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
                "高速公路", HolidayConstants.ROAD_EXPRESSWAY, 2, 7);
        detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
                "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8);
        detectComparisonBaseHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary);
    }
    private void detectVehicleSheetHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                           Map<Integer, LocalDate> dateByDay, int latestDay,
                                           Map<LocalDate, Map<String, BigDecimal>> existing,
                                           ImportSummary summary, String sheetName, String roadCategory,
                                           int startColumn, int endColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            if (row == null) continue;
            BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
            if (incoming == null) continue;
            Map<String, BigDecimal> existingDay = existing.get(day.getValue());
            BigDecimal oldValue = existingDay == null ? null : existingDay.get(roadCategory);
            if (oldValue != null && !sameValue(oldValue, incoming)) {
                summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " "
                        + sheetName + ":原值 " + formatChangeValue(oldValue) + ",导入值 " + formatChangeValue(incoming));
            }
        }
    }
    private void detectComparisonBaseHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                             Map<Integer, LocalDate> dateByDay, int latestDay,
                                             Map<LocalDate, Map<String, BigDecimal>> existingVehicle,
                                             ImportSummary summary) {
        if (!HolidayConstants.supportsComparisonBase(year)) return;
        Sheet sheet = workbook.getSheet("普通国省道");
        if (sheet == null) return;
        Map<LocalDate, BigDecimal> existingBase = loadComparisonBaseByDate(year);
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Map<String, BigDecimal> existingDay = existingVehicle.get(day.getValue());
            if (existingDay == null || existingDay.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL) == null) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            if (row == null) continue;
            BigDecimal incoming;
            try {
                incoming = numberValue(row.getCell(2), evaluator);
            } catch (Exception e) {
                summary.addError("普通国省道 ç¬¬" + (row.getRowNum() + 1) + "行环比基期:" + e.getMessage());
                continue;
            }
            BigDecimal oldValue = existingBase.get(day.getValue());
            if (!sameNullableValue(oldValue, incoming)) {
                summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " æ™®é€šå›½çœé“环比基期:原值 "
                        + nullableChangeValue(oldValue) + ",导入值 " + nullableChangeValue(incoming));
            }
        }
    }
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
    private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
        for (HolidayPassengerFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getTransportMode(), row.getPassengerCount());
        }
        return result;
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
        for (HolidayVehicleFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getRoadCategory(), row.getVehicleCount());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getComparisonBase());
        return result;
    }
    private boolean sameValue(BigDecimal left, BigDecimal right) {
        return HolidayNumberUtils.sameValue(left, right);
    }
    private boolean sameNullableValue(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return left == right;
        return sameValue(left, right);
    }
    private String nullableChangeValue(BigDecimal value) {
        return value == null ? "空" : formatChangeValue(value);
    }
    private String formatChangeValue(BigDecimal value) {
        return HolidayNumberUtils.changeText(value);
    }
    @Transactional
    public void seedReferenceDataIfNeeded() {
        File reference = resolveReferenceFile();
        if (!reference.exists() || !reference.isFile()) {
            log.warn("Holiday reference workbook not found: {}", reference.getAbsolutePath());
            return;
        }
        try (InputStream in = new FileInputStream(reference); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            seedNationalDayCalendar();
            for (int year : REFERENCE_YEARS) {
                if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)) == 0) {
                    seedPassengerYear(workbook, evaluator, year);
                }
                if (vehicleFlowMapper.selectCount(new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)) == 0) {
                    seedVehicleYear(workbook, evaluator, year);
                }
                if (coefficientMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)) == 0) {
                    seedCoefficientYear(workbook, evaluator, year);
                }
            }
        } catch (Exception e) {
            log.error("Failed to seed holiday reference data", e);
        }
    }
    public byte[] exportWorkbook(int year, String holidayType) {
        File reference = resolveReferenceFile();
        if (!reference.exists() || !reference.isFile()) {
            throw new IllegalArgumentException("节假日模板文件不存在: " + reference.getAbsolutePath());
        }
        try (InputStream in = new FileInputStream(reference); XSSFWorkbook workbook = new XSSFWorkbook(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            fillPassengerSheets(workbook, evaluator, year, holidayType);
            fillVehicleSheets(workbook, evaluator, year, holidayType);
            fillCoefficients(workbook, evaluator, year, holidayType);
            workbook.setForceFormulaRecalculation(true);
            try {
                evaluator.evaluateAll();
            } catch (Exception e) {
                log.warn("Workbook formula recalculation did not complete; Excel will recalculate on open", e);
            }
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            workbook.write(out);
            return out.toByteArray();
        } catch (Exception e) {
            throw new IllegalArgumentException("节假日数据导出失败: " + e.getMessage(), e);
        }
    }
    private void importPassengerSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) {
                summary.addError(entry.getKey() + ":找不到工作表");
                continue;
            }
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) {
                summary.addError(entry.getKey() + ":找不到 " + year + " å¹´æ•°æ®åˆ—");
                continue;
            }
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                if (row == null) {
                    summary.addError(entry.getKey() + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                    continue;
                }
                Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
                if (rowDay == null || !rowDay.equals(day.getKey())) {
                    summary.addError(entry.getKey() + " ç¬¬" + (row.getRowNum() + 1) + "行:第几日与节假日配置不一致");
                    continue;
                }
                BigDecimal value;
                try {
                    value = numberValue(row.getCell(yearColumn), evaluator);
                } catch (Exception e) {
                    summary.addError(entry.getKey() + " ç¬¬" + (row.getRowNum() + 1) + "行:" + e.getMessage());
                    continue;
                }
                if (value == null) {
                    summary.incrementSkipped();
                    continue;
                }
                upsertPassenger(year, day.getValue(), entry.getValue(), value);
                summary.incrementPassenger();
            }
        }
    }
    private void importVehicleSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                     Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
        importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
                "高速公路", HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7, null);
        importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
                "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8,
                HolidayConstants.supportsComparisonBase(year) ? 2 : null);
    }
    private void importVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
                                    Map<Integer, LocalDate> dateByDay, ImportSummary summary,
                                    String sheetName, String roadCategory, String vehicleType,
                                    int startColumn, int endColumn, Integer comparisonBaseColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) {
            summary.addError(sheetName + ":找不到工作表");
            return;
        }
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) {
            summary.addError(sheetName + ":找不到 " + year + " å¹´æ•°æ®åˆ—");
            return;
        }
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            if (row == null) {
                summary.addError(sheetName + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                continue;
            }
            Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
            if (rowDay == null || !rowDay.equals(day.getKey())) {
                summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行:第几日与节假日配置不一致");
                continue;
            }
            BigDecimal value;
            try {
                value = numberValue(row.getCell(yearColumn), evaluator);
            } catch (Exception e) {
                summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行:" + e.getMessage());
                continue;
            }
            if (value == null) {
                summary.incrementSkipped();
                continue;
            }
            BigDecimal comparisonBase = null;
            if (comparisonBaseColumn != null) {
                try {
                    comparisonBase = numberValue(row.getCell(comparisonBaseColumn), evaluator);
                } catch (Exception e) {
                    summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行环比基期:" + e.getMessage());
                    continue;
                }
            }
            upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, comparisonBase);
            summary.incrementVehicle();
        }
    }
    private void fillPassengerSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            for (int year : REFERENCE_YEARS) {
                Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
                if (yearColumn == null) continue;
                Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
                Map<LocalDate, BigDecimal> values = loadPassengerValues(year, entry.getValue());
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!values.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
                }
            }
        }
    }
    private void fillVehicleSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        fillVehicleSheet(workbook, evaluator, "高速公路", HolidayConstants.ROAD_EXPRESSWAY, 2, 7, holidayType, null);
        fillVehicleSheet(workbook, evaluator, "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8, holidayType,
                HolidayConstants.supportsComparisonBase(selectedYear) ? 2 : null);
    }
    private void fillVehicleSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
                                  String roadCategory, int startColumn, int endColumn, String holidayType,
                                  Integer comparisonBaseColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        for (int year : REFERENCE_YEARS) {
            Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
            if (yearColumn == null) continue;
            Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
            Map<LocalDate, BigDecimal> values = loadVehicleValues(year, roadCategory);
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                if (!values.containsKey(day.getValue())) continue;
                setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
            }
            if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, comparisonBaseColumn, bases.get(day.getValue()));
                }
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    int rowIndex = FIRST_DATA_ROW_INDEX + day.getKey() - 1;
                    int excelRow = rowIndex + 1;
                    setFormula(sheet, rowIndex, 4, "=(D" + excelRow + "-C" + excelRow + ")/C" + excelRow);
                }
            }
        }
    }
    private void fillCoefficients(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        fillCoefficientSheet(workbook, evaluator, "高速公路", 8, 10, holidayType);
        fillCoefficientSheet(workbook, evaluator, "普通国省道", 9, 11, holidayType);
    }
    private void fillCoefficientSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
                                      int startColumn, int endColumn, String holidayType) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        for (int year : REFERENCE_YEARS) {
            Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
            if (yearColumn == null) continue;
            BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
            if (coefficient == null) continue;
            Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
            for (Integer dayIndex : dayDates.keySet()) {
                setNumeric(sheet, FIRST_DATA_ROW_INDEX + dayIndex - 1, yearColumn, coefficient);
            }
        }
    }
    private void seedNationalDayCalendar() {
        // Calendar is admin-managed after the first bootstrap. Re-adding missing day indexes on
        // every start would resurrect days the admin deleted, so only seed a fresh table.
        if (!configService.listHolidays(null, null).isEmpty()) {
            return;
        }
        int[] lengths = {7, 8, 8};
        for (int i = 0; i < REFERENCE_YEARS.length; i++) {
            int year = REFERENCE_YEARS[i];
            List<HolidayCalendar> existing = configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY);
            Map<Integer, HolidayCalendar> byDay = new LinkedHashMap<>();
            for (HolidayCalendar row : existing) {
                byDay.put(row.getDayIndex(), row);
            }
            for (int day = 1; day <= lengths[i]; day++) {
                if (byDay.containsKey(day)) continue;
                HolidayCalendar calendar = new HolidayCalendar();
                calendar.setYear(year);
                calendar.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
                calendar.setHolidayDate(LocalDate.of(year, 10, day));
                calendar.setDayIndex(day);
                calendar.setCreatedAt(LocalDateTime.now());
                calendar.setUpdatedAt(LocalDateTime.now());
                configService.saveHoliday(toMap(calendar));
            }
        }
    }
    private void seedPassengerYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
        if (dayDates.isEmpty()) return;
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                if (row == null) continue;
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null) {
                    upsertPassenger(year, day.getValue(), entry.getValue(), value);
                }
            }
        }
    }
    private void seedVehicleYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
        if (dayDates.isEmpty()) return;
        seedVehicleSheet(workbook, evaluator, year, dayDates, "高速公路",
                HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7);
        seedVehicleSheet(workbook, evaluator, year, dayDates, "普通国省道",
                HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8);
    }
    private void seedVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
                                  Map<Integer, LocalDate> dayDates, String sheetName,
                                  String roadCategory, String vehicleType, int startColumn, int endColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
                upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, null);
            }
        }
    }
    private void seedCoefficientYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        List<BigDecimal> values = new ArrayList<>();
        collectCoefficientValues(workbook, evaluator, "高速公路", year, 8, 10, values);
        collectCoefficientValues(workbook, evaluator, "普通国省道", year, 9, 11, values);
        if (values.isEmpty()) return;
        BigDecimal sum = BigDecimal.ZERO;
        for (BigDecimal value : values) {
            sum = HolidayNumberUtils.normalize(sum.add(value));
        }
        BigDecimal coefficient = HolidayNumberUtils.divide(sum, BigDecimal.valueOf(values.size()));
        upsertCoefficient(year, HolidayConstants.CATEGORY_HOLIDAY, coefficient);
        upsertCoefficient(year, HolidayConstants.CATEGORY_NON_HOLIDAY, coefficient);
    }
    private void collectCoefficientValues(Workbook workbook, FormulaEvaluator evaluator, String sheetName,
                                          int year, int startColumn, int endColumn, List<BigDecimal> values) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (int rowIndex = FIRST_DATA_ROW_INDEX; rowIndex < FIRST_DATA_ROW_INDEX + 10; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            try {
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null && value.compareTo(BigDecimal.ZERO) > 0) values.add(value);
            } catch (Exception ignored) {
                // Skip cells that are not numeric coefficients.
            }
        }
    }
    private Map<Integer, LocalDate> dayDates(int year, String holidayType) {
        Map<Integer, LocalDate> result = new LinkedHashMap<>();
        for (HolidayCalendar row : configService.findHolidayDays(year, holidayType)) {
            result.put(row.getDayIndex(), row.getHolidayDate());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadPassengerValues(int year, String transportMode) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)
                        .eq(HolidayPassengerFlow::getTransportMode, transportMode));
        for (HolidayPassengerFlow row : rows) result.put(row.getFlowDate(), row.getPassengerCount());
        return result;
    }
    private Map<LocalDate, BigDecimal> loadVehicleValues(int year, String roadCategory) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, roadCategory));
        for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getVehicleCount());
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseValues(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) {
            if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
        }
        return result;
    }
    private void upsertPassenger(int year, LocalDate date, String mode, BigDecimal value) {
        HolidayPassengerFlow existing = passengerFlowMapper.selectOne(new LambdaQueryWrapper<HolidayPassengerFlow>()
                .eq(HolidayPassengerFlow::getYear, year)
                .eq(HolidayPassengerFlow::getFlowDate, date)
                .eq(HolidayPassengerFlow::getTransportMode, mode)
                .last("limit 1"));
        if (existing == null) {
            existing = new HolidayPassengerFlow();
            existing.setYear(year);
            existing.setFlowDate(date);
            existing.setTransportMode(mode);
            existing.setPassengerCount(value);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            passengerFlowMapper.insert(existing);
        } else {
            existing.setPassengerCount(value);
            existing.setUpdatedAt(LocalDateTime.now());
            passengerFlowMapper.updateById(existing);
        }
    }
    private void upsertVehicle(int year, LocalDate date, String roadCategory, String vehicleType,
                               BigDecimal value, BigDecimal comparisonBase) {
        HolidayVehicleFlow existing = vehicleFlowMapper.selectOne(new LambdaQueryWrapper<HolidayVehicleFlow>()
                .eq(HolidayVehicleFlow::getYear, year)
                .eq(HolidayVehicleFlow::getFlowDate, date)
                .eq(HolidayVehicleFlow::getRoadCategory, roadCategory)
                .eq(HolidayVehicleFlow::getVehicleType, vehicleType)
                .last("limit 1"));
        if (existing == null) {
            existing = new HolidayVehicleFlow();
            existing.setYear(year);
            existing.setFlowDate(date);
            existing.setRoadCategory(roadCategory);
            existing.setVehicleType(vehicleType);
            existing.setVehicleCount(value);
            existing.setComparisonBase(comparisonBase);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            vehicleFlowMapper.insert(existing);
        } else {
            existing.setVehicleCount(value);
            existing.setComparisonBase(comparisonBase);
            existing.setUpdatedAt(LocalDateTime.now());
            vehicleFlowMapper.updateById(existing);
        }
    }
    private void upsertCoefficient(int year, String dateCategory, BigDecimal value) {
        HolidayPassengerCoefficient existing = coefficientMapper.selectOne(
                new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)
                        .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
                        .last("limit 1"));
        if (existing == null) {
            existing = new HolidayPassengerCoefficient();
            existing.setYear(year);
            existing.setDateCategory(dateCategory);
            existing.setCoefficient(value);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            coefficientMapper.insert(existing);
        } else {
            existing.setCoefficient(value);
            existing.setUpdatedAt(LocalDateTime.now());
            coefficientMapper.updateById(existing);
        }
    }
    private Integer findYearColumn(Sheet sheet, FormulaEvaluator evaluator, int year, int startColumn, int endColumn) {
        Row header = sheet.getRow(1);
        if (header == null) return null;
        String wanted = year + "å¹´";
        DataFormatter formatter = new DataFormatter();
        for (int column = startColumn; column <= endColumn; column++) {
            Cell cell = header.getCell(column);
            if (cell == null) continue;
            String text = formatter.formatCellValue(cell, evaluator).replace("\n", "").trim();
            if (wanted.equals(text) || text.startsWith(wanted + "累计")) return column;
        }
        return null;
    }
    private Integer parseDayIndex(Cell cell, FormulaEvaluator evaluator) {
        if (cell == null) return null;
        DataFormatter formatter = new DataFormatter();
        String text = formatter.formatCellValue(cell, evaluator).trim();
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("第\\s*(\\d+)\\s*日").matcher(text);
        return matcher.find() ? Integer.valueOf(matcher.group(1)) : null;
    }
    private BigDecimal numberValue(Cell cell, FormulaEvaluator evaluator) {
        if (cell == null) return null;
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = evaluator.evaluateFormulaCell(cell);
        }
        if (type == CellType.BLANK) return null;
        if (type == CellType.NUMERIC) return HolidayNumberUtils.excelValue(cell.getNumericCellValue());
        if (type == CellType.STRING) {
            String text = cell.getStringCellValue().trim().replace(",", "");
            if (text.isEmpty() || "-".equals(text) || "—".equals(text)) return null;
            try {
                return HolidayNumberUtils.decimalValue(text);
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException("单元格不是有效数字: " + text);
            }
        }
        if (type == CellType.BOOLEAN) throw new IllegalArgumentException("单元格不能是布尔值");
        throw new IllegalArgumentException("单元格不是有效数字");
    }
    private void setNumeric(Sheet sheet, int rowIndex, int columnIndex, BigDecimal value) {
        if (value == null) return;
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        cell.setCellValue(value.doubleValue());
    }
    private void setFormula(Sheet sheet, int rowIndex, int columnIndex, String formula) {
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        cell.setCellFormula(formula);
    }
    private Map<String, Object> toMap(HolidayCalendar calendar) {
        Map<String, Object> params = new LinkedHashMap<>();
        params.put("year", calendar.getYear());
        params.put("holidayType", calendar.getHolidayType());
        params.put("holidayDate", calendar.getHolidayDate().toString());
        params.put("dayIndex", calendar.getDayIndex());
        return params;
    }
    @Data
    public static class ImportSummary {
        private int passengerRows;
        private int vehicleRows;
        private int skippedRows;
        private boolean confirmationRequired;
        private final List<String> errors = new ArrayList<>();
        private final List<String> historicalChanges = new ArrayList<>();
        public void incrementPassenger() {
            passengerRows++;
        }
        public void incrementVehicle() {
            vehicleRows++;
        }
        public void incrementSkipped() {
            skippedRows++;
        }
        public void addError(String message) {
            errors.add(message);
        }
        public void addHistoricalChange(String message) {
            if (historicalChanges.size() < 200) {
                historicalChanges.add(message);
            }
        }
        public int getSuccessRows() {
            return passengerRows + vehicleRows;
        }
        public int getFailRows() {
            return errors.size();
        }
    }
}
traffic-audit-web/src/main.js
@@ -11,6 +11,11 @@
// å…¨å±€ç™»å‡ºï¼šæ¸…除登录态并回登录页(各页面侧边栏「退出登录」菜单共用)
Vue.mixin({
  computed: {
    operatorIsAdmin() {
      return sessionStorage.getItem('username') === 'admin'
    }
  },
  methods: {
    // el-menu åŒè·¯ç”±å¯¼èˆªï¼ˆNavigationDuplicated)时 Element UI ä¼šæŠŠé«˜äº®å›žæ»šä¸ºç‚¹å‡»å‰æ—§å€¼ï¼›
    // ç‚¹å‡»åŽå»¶è¿Ÿä¸€ä¸ªå®ä»»åŠ¡å¼ºåˆ¶çº æ­£ï¼ˆå®ä»»åŠ¡æ™šäºŽ push çš„微任务回调,保证最终高亮正确)。
traffic-audit-web/src/router/index.js
@@ -14,7 +14,9 @@
    { path: '/audit', name: 'AuditResult', component: () => import('@/views/AuditResult.vue') },
    { path: '/report', name: 'ReportExport', component: () => import('@/views/ReportExport.vue') },
    { path: '/rules', name: 'RuleManage', component: () => import('@/views/RuleManage.vue') },
    { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') }
    { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') },
    { path: '/holiday-stat', name: 'HolidayStat', component: () => import('@/views/HolidayStat.vue') },
    { path: '/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfig.vue'), meta: { requireAdmin: true } }
  ]
})
@@ -22,8 +24,12 @@
  const token = sessionStorage.getItem('token')
  if (to.path === '/login') {
    next(token ? '/dashboard' : undefined)
  } else if (!token) {
    next('/login')
  } else if (to.meta && to.meta.requireAdmin && sessionStorage.getItem('username') !== 'admin') {
    next('/dashboard')
  } else {
    next(token ? undefined : '/login')
    next()
  }
})
traffic-audit-web/src/views/AuditResult.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
</el-menu>
      </el-aside>
      <el-main :class="{ 'main-focus': focusMode }" style="display:flex;flex-direction:column;min-height:0">
traffic-audit-web/src/views/Dashboard.vue
@@ -10,8 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item></el-menu>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
traffic-audit-web/src/views/DataImport.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
</el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/DataView.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
</el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/ExplainReview.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
</el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/HolidayStat.vue
New file
@@ -0,0 +1,302 @@
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - ä¸“项统计"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
          <h3>专项统计 - èŠ‚å‡æ—¥</h3>
          <div class="toolbar">
            <el-select v-model="year" style="width:120px" @change="loadOverview">
              <el-option v-for="item in years" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
            </el-select>
            <el-select v-model="holidayType" style="width:150px" @change="loadOverview">
              <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
            </el-select>
            <el-button icon="el-icon-refresh" @click="loadOverview">刷新</el-button>
            <el-button icon="el-icon-download" @click="downloadTemplate">下载模板</el-button>
          </div>
          <el-card shadow="never" class="operation-card">
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">导入数据</span>
                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
                           accept=".xlsx,.xls" :on-change="onFileChange" :on-remove="onFileRemove" :file-list="fileList">
                  <el-button type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                </el-upload>
                <el-button type="primary" icon="el-icon-upload2" :loading="importing" :disabled="!selectedFile" @click="importData">导入</el-button>
              </div>
              <div class="operation-item">
                <span class="label">导出数据</span>
                <el-button type="success" icon="el-icon-download" :loading="exporting" @click="exportData">导出 Excel</el-button>
              </div>
              <div class="operation-item">
                <span class="label">客流快报</span>
                <el-date-picker v-model="bulletinDate" type="date" value-format="yyyy-MM-dd" style="width:160px" placeholder="选择日期"></el-date-picker>
                <el-button type="warning" icon="el-icon-document" :loading="bulletinLoading" @click="downloadBulletin">下载快报</el-button>
              </div>
            </div>
          </el-card>
          <div v-if="importMessage" class="import-message">
            <pre>{{ importMessage }}</pre>
          </div>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">{{ year }}å¹´{{ holidayTypeLabel }}节假日数据</div>
            <el-table :data="rows" border stripe size="small" v-loading="loading" style="width:100%">
              <el-table-column prop="dayIndex" label="第几日" width="80" align="center"></el-table-column>
              <el-table-column prop="date" label="日期" width="120" align="center"></el-table-column>
              <el-table-column label="铁路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.rail) }}</template></el-table-column>
              <el-table-column label="民航(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.civilAviation) }}</template></el-table-column>
              <el-table-column label="道路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.road) }}</template></el-table-column>
              <el-table-column label="水路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.water) }}</template></el-table-column>
              <el-table-column label="高速车流(万辆次)" width="150" align="right"><template slot-scope="s">{{ formatNumber(s.row.expressway) }}</template></el-table-column>
              <el-table-column label="国省道车流(万辆次)" width="160" align="right"><template slot-scope="s">{{ formatNumber(s.row.nationalProvincial) }}</template></el-table-column>
              <el-table-column label="国省道环比基期(万辆次)" width="190" align="right"><template slot-scope="s">{{ formatNumber(s.row.comparisonBase) }}</template></el-table-column>
              <el-table-column label="国省道车流环比" width="140" align="right"><template slot-scope="s">{{ formatRatio(s.row.nationalProvincialRingRatio) }}</template></el-table-column>
              <el-table-column label="客运系数" width="100" align="right"><template slot-scope="s">{{ formatCoefficient(s.row.coefficient) }}</template></el-table-column>
              <el-table-column label="非营业性人员流动量(万人次)" min-width="200" align="right"><template slot-scope="s">{{ formatNumber(s.row.nonBusinessPersonFlow) }}</template></el-table-column>
              <el-table-column label="总人员流动量(万人次)" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
            </el-table>
          </el-card>
        </div>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
export default {
  components: { AppHeader },
  name: 'HolidayStat',
  data() {
    return {
      years: [2024, 2025, 2026, 2027, 2028, 2029, 2030],
      year: 2026,
      holidayType: 'NATIONAL_DAY',
      holidayTypeOptions: [],
      rows: [],
      loading: false,
      importing: false,
      exporting: false,
      bulletinLoading: false,
      selectedFile: null,
      fileList: [],
      importMessage: '',
      allowHistoricalOverwrite: false,
      bulletinDate: this.previousDateString()
    }
  },
  computed: {
    holidayTypeLabel() {
      const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
      return item ? item.label : this.holidayType
    }
  },
  mounted() {
    this.loadOptions()
    this.loadOverview()
  },
  methods: {
    previousDateString() {
      const date = new Date()
      date.setDate(date.getDate() - 1)
      const month = String(date.getMonth() + 1).padStart(2, '0')
      const day = String(date.getDate()).padStart(2, '0')
      return date.getFullYear() + '-' + month + '-' + day
    },
    loadOptions() {
      api.get('/holiday/config/options').then(res => {
        const map = (res.data && res.data.holidayTypes) || {}
        this.holidayTypeOptions = Object.keys(map).map(key => ({ value: key, label: map[key] }))
      })
    },
    loadOverview() {
      this.loading = true
      api.get('/holiday/overview', { params: { year: this.year, holidayType: this.holidayType } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.rows = (res.data && res.data.rows) || []
        })
        .catch(err => this.showError(err, '节假日数据加载失败'))
        .finally(() => { this.loading = false })
    },
    onFileChange(file) {
      this.selectedFile = file.raw
      this.fileList = [file]
      this.importMessage = ''
    },
    onFileRemove() {
      this.selectedFile = null
      this.fileList = []
    },
    importData(allowHistoricalOverwrite) {
      if (!this.selectedFile) return
      const allowOverwrite = allowHistoricalOverwrite === true || this.allowHistoricalOverwrite === true
      const form = new FormData()
      form.append('file', this.selectedFile)
      form.append('year', this.year)
      form.append('holidayType', this.holidayType)
      form.append('allowHistoricalOverwrite', allowOverwrite ? 'true' : 'false')
      this.importing = true
      this.importMessage = ''
      api.post('/holiday/import', form, { timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '导入失败')
          const data = res.data || {}
          if (data.confirmationRequired) {
            const changes = data.historicalChanges || []
            this.importMessage = ['检测到历史日期的数据发生变化,尚未导入。请确认是否允许覆盖:'].concat(changes).join('\n')
            this.$message.warning('检测到历史数据发生变化,请确认是否允许导入')
            this.confirmHistoricalImport(changes)
            return
          }
          const lines = [
            '导入完成:客流 ' + (data.passengerRows || 0) + ' æ¡ï¼Œè½¦æµ ' + (data.vehicleRows || 0) + ' æ¡ï¼Œè·³è¿‡ç©ºç™½ ' + (data.skippedRows || 0) + ' æ¡ã€‚'
          ]
          if (data.errors && data.errors.length) {
            lines.push('失败明细:')
            data.errors.forEach(item => lines.push(item))
          }
          this.importMessage = lines.join('\n')
          this.allowHistoricalOverwrite = false
          this.$message.success('导入完成')
          this.loadOverview()
        })
        .catch(err => {
          this.allowHistoricalOverwrite = false
          this.showError(err, '导入失败')
        })
        .finally(() => { this.importing = false })
    },
    confirmHistoricalImport(changes) {
      const rows = (changes && changes.length ? changes : ['历史数据发生变化']).map(this.escapeHtml)
      const html = '<div>检测到已发布日期的数据发生变化。允许导入将覆盖这些历史数据。</div>'
        + '<pre style="max-height:320px;overflow:auto;white-space:pre-wrap;margin-top:10px;background:#f7f7f7;padding:8px;border:1px solid #e4e7ed;">'
        + rows.join('\n') + '</pre>'
      this.$confirm(html, '历史数据发生变化', {
        dangerouslyUseHTMLString: true,
        confirmButtonText: '允许导入',
        cancelButtonText: '不允许',
        type: 'warning',
        distinguishCancelAndClose: true
      }).then(() => {
        this.allowHistoricalOverwrite = true
        this.importData(true)
      }).catch(() => {
        this.allowHistoricalOverwrite = false
      })
    },
    escapeHtml(value) {
      return String(value === null || value === undefined ? '' : value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;')
    },
    exportData() {
      this.exporting = true
      api.get('/holiday/export', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 120000 })
        .then(res => { this.downloadBlob(res, this.year + 'å¹´' + this.holidayTypeLabel + '数据.xlsx'); this.$message.success('导出成功') })
        .catch(err => this.handleBlobError(err, '导出失败'))
        .finally(() => { this.exporting = false })
    },
    downloadTemplate() {
      const templatePath = '专项统计/重要节假日/2026年十一数据.xlsx'
      const encodedPath = templatePath.split('/').map(encodeURIComponent).join('/')
      api.get('/templates/' + encodedPath, { responseType: 'blob' })
        .then(res => { this.downloadBlob(res, '2026年十一数据.xlsx'); this.$message.success('模板下载成功') })
        .catch(err => this.handleBlobError(err, '模板下载失败'))
    },
    downloadBulletin() {
      this.bulletinLoading = true
      api.get('/holiday/bulletin', { params: { date: this.bulletinDate }, responseType: 'blob', timeout: 120000 })
        .then(res => {
          const day = Number((this.bulletinDate || '').slice(-2))
          this.downloadBlob(res, '湖北省国庆假期综合运输客流快报(10月' + day + '日).docx')
          this.$message.success('快报下载成功')
        })
        .catch(err => this.handleBlobError(err, '快报生成失败'))
        .finally(() => { this.bulletinLoading = false })
    },
    downloadBlob(data, fileName) {
      const blob = data instanceof Blob ? data : new Blob([data])
      const url = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = fileName
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(url)
    },
    handleBlobError(err, fallback) {
      if (err && err.response && err.response.data instanceof Blob) {
        err.response.data.text().then(text => {
          try {
            const parsed = JSON.parse(text)
            this.$message.error(parsed.message || fallback)
          } catch (e) {
            this.$message.error(fallback)
          }
        })
        return
      }
      this.showError(err, fallback)
    },
    showError(err, fallback) {
      if (err && err._toast) return
      const data = err && err.response && err.response.data
      this.$message.error((data && data.message) || (err && err.message) || fallback)
    },
    formatNumber(value) {
      if (value === null || value === undefined || value === '') return '—'
      return Number(value).toFixed(2)
    },
    formatCoefficient(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
    },
    formatRatio(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return (number * 100).toFixed(2) + '%'
    }
  }
}
</script>
<style scoped>
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.operation-card { margin-top: 12px; }
.operation-row { display: flex; gap: 36px; align-items: flex-start; flex-wrap: wrap; }
.operation-item { display: flex; align-items: center; gap: 8px; }
.operation-item .label { color: #606266; font-size: 14px; }
.import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
.import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
</style>
traffic-audit-web/src/views/ReportExport.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/RuleManage.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/SystemConfig.vue
New file
@@ -0,0 +1,295 @@
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - ç³»ç»Ÿé…ç½®"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
          <h3>系统配置</h3>
          <el-tabs v-model="activeTab" @tab-click="loadActiveTab">
            <el-tab-pane label="节假日管理" name="holidays">
              <div class="toolbar">
                <el-select v-model="holidayQuery.year" clearable placeholder="年份" style="width:130px" @change="loadHolidays">
                  <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
                </el-select>
                <el-select v-model="holidayQuery.holidayType" clearable placeholder="节假日分类" style="width:150px" @change="loadHolidays">
                  <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
                </el-select>
                <el-button type="primary" icon="el-icon-search" @click="loadHolidays">查询</el-button>
                <el-button type="success" icon="el-icon-plus" @click="openHolidayDialog()">新增节假日</el-button>
              </div>
              <el-table :data="holidays" border stripe size="small" v-loading="loadingHolidays" style="width:100%">
                <el-table-column prop="year" label="年份" width="100" align="center"></el-table-column>
                <el-table-column label="节假日分类" width="140">
                  <template slot-scope="scope">{{ labelOf(holidayTypeOptions, scope.row.holidayType) }}</template>
                </el-table-column>
                <el-table-column prop="holidayDate" label="日期" width="150" align="center"></el-table-column>
                <el-table-column prop="dayIndex" label="第几日" width="100" align="center"></el-table-column>
                <el-table-column label="操作" width="150" align="center">
                  <template slot-scope="scope">
                    <el-button type="text" size="mini" @click="openHolidayDialog(scope.row)">编辑</el-button>
                    <el-button type="text" size="mini" class="danger-text" @click="removeHoliday(scope.row)">删除</el-button>
                  </template>
                </el-table-column>
              </el-table>
            </el-tab-pane>
            <el-tab-pane label="客运系数管理" name="coefficients">
              <div class="toolbar">
                <el-select v-model="coefficientQuery.year" clearable placeholder="年份" style="width:130px" @change="loadCoefficients">
                  <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
                </el-select>
                <el-button type="primary" icon="el-icon-search" @click="loadCoefficients">查询</el-button>
                <el-button type="success" icon="el-icon-plus" @click="openCoefficientDialog()">新增客运系数</el-button>
              </div>
              <el-table :data="coefficients" border stripe size="small" v-loading="loadingCoefficients" style="width:100%">
                <el-table-column prop="year" label="年份" width="120" align="center"></el-table-column>
                <el-table-column label="日期分类" width="160">
                  <template slot-scope="scope">{{ labelOf(dateCategoryOptions, scope.row.dateCategory) }}</template>
                </el-table-column>
                <el-table-column label="客运系数" width="160" align="center">
                  <template slot-scope="scope">{{ formatCoefficient(scope.row.coefficient) }}</template>
                </el-table-column>
                <el-table-column label="操作" width="150" align="center">
                  <template slot-scope="scope">
                    <el-button type="text" size="mini" @click="openCoefficientDialog(scope.row)">编辑</el-button>
                    <el-button type="text" size="mini" class="danger-text" @click="removeCoefficient(scope.row)">删除</el-button>
                  </template>
                </el-table-column>
              </el-table>
            </el-tab-pane>
          </el-tabs>
        </div>
        <el-dialog :title="holidayForm.id ? '编辑节假日' : '新增节假日'" :visible.sync="holidayDialog" width="520px" v-dialog-drag>
          <el-form ref="holidayForm" :model="holidayForm" :rules="holidayRules" label-width="110px">
            <el-form-item label="年份" prop="year">
              <el-select v-model="holidayForm.year" style="width:100%" @change="onHolidayYearChange">
                <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="节假日分类" prop="holidayType">
              <el-select v-model="holidayForm.holidayType" style="width:100%">
                <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="日期" prop="holidayDate">
              <el-date-picker v-model="holidayForm.holidayDate" type="date" value-format="yyyy-MM-dd"
                              style="width:100%" placeholder="选择日期"></el-date-picker>
            </el-form-item>
            <el-form-item label="第几日" prop="dayIndex">
              <el-input-number v-model="holidayForm.dayIndex" :min="1" :max="31" style="width:100%"></el-input-number>
            </el-form-item>
          </el-form>
          <span slot="footer">
            <el-button @click="holidayDialog = false">取消</el-button>
            <el-button type="primary" :loading="saving" @click="saveHoliday">保存</el-button>
          </span>
        </el-dialog>
        <el-dialog :title="coefficientForm.id ? '编辑客运系数' : '新增客运系数'" :visible.sync="coefficientDialog" width="520px" v-dialog-drag>
          <el-form ref="coefficientForm" :model="coefficientForm" :rules="coefficientRules" label-width="110px">
            <el-form-item label="年份" prop="year">
              <el-select v-model="coefficientForm.year" style="width:100%">
                <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="日期分类" prop="dateCategory">
              <el-select v-model="coefficientForm.dateCategory" style="width:100%">
                <el-option v-for="item in dateCategoryOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="客运系数" prop="coefficient">
              <el-input-number v-model="coefficientForm.coefficient" :min="0.01" :precision="4" :step="0.1" style="width:100%"></el-input-number>
            </el-form-item>
          </el-form>
          <span slot="footer">
            <el-button @click="coefficientDialog = false">取消</el-button>
            <el-button type="primary" :loading="saving" @click="saveCoefficient">保存</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
export default {
  components: { AppHeader },
  name: 'SystemConfig',
  data() {
    return {
      activeTab: 'holidays',
      years: [2024, 2025, 2026, 2027, 2028, 2029, 2030],
      holidayTypeOptions: [],
      dateCategoryOptions: [],
      holidayQuery: { year: 2026, holidayType: 'NATIONAL_DAY' },
      coefficientQuery: { year: 2026 },
      holidays: [],
      coefficients: [],
      loadingHolidays: false,
      loadingCoefficients: false,
      saving: false,
      holidayDialog: false,
      coefficientDialog: false,
      holidayForm: { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 },
      coefficientForm: { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 },
      holidayRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        holidayType: [{ required: true, message: '请选择节假日分类', trigger: 'change' }],
        holidayDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
        dayIndex: [{ required: true, message: '请输入第几日', trigger: 'change' }]
      },
      coefficientRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        dateCategory: [{ required: true, message: '请选择日期分类', trigger: 'change' }],
        coefficient: [{ required: true, message: '请输入客运系数', trigger: 'change' }]
      }
    }
  },
  mounted() {
    this.loadOptions()
    this.loadHolidays()
    this.loadCoefficients()
  },
  methods: {
    loadOptions() {
      api.get('/holiday/config/options').then(res => {
        const data = res.data || {}
        this.holidayTypeOptions = this.mapOptions(data.holidayTypes)
        this.dateCategoryOptions = this.mapOptions(data.dateCategories)
      })
    },
    mapOptions(map) {
      if (!map) return []
      return Object.keys(map).map(key => ({ value: key, label: map[key] }))
    },
    labelOf(options, value) {
      const item = (options || []).find(x => x.value === value)
      return item ? item.label : value
    },
    loadActiveTab() {
      if (this.activeTab === 'holidays') this.loadHolidays()
      else this.loadCoefficients()
    },
    loadHolidays() {
      this.loadingHolidays = true
      api.get('/holiday/config/holidays', { params: this.holidayQuery })
        .then(res => { this.holidays = res.data || [] })
        .catch(e => { if (!e || !e._toast) this.$message.error('节假日加载失败') })
        .finally(() => { this.loadingHolidays = false })
    },
    loadCoefficients() {
      this.loadingCoefficients = true
      api.get('/holiday/config/coefficients', { params: this.coefficientQuery })
        .then(res => { this.coefficients = res.data || [] })
        .catch(e => { if (!e || !e._toast) this.$message.error('客运系数加载失败') })
        .finally(() => { this.loadingCoefficients = false })
    },
    openHolidayDialog(row) {
      this.holidayDialog = true
      this.$nextTick(() => {
        this.holidayForm = row ? Object.assign({}, row) : { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 }
        if (this.$refs.holidayForm) this.$refs.holidayForm.clearValidate()
      })
    },
    onHolidayYearChange(year) {
      if (this.holidayForm.holidayDate && Number(this.holidayForm.holidayDate.slice(0, 4)) !== year) {
        this.holidayForm.holidayDate = ''
      }
    },
    saveHoliday() {
      this.$refs.holidayForm.validate(valid => {
        if (!valid) return
        this.saving = true
        api.post('/holiday/config/holidays/save', this.holidayForm)
          .then(res => {
            if (res.code !== 200) throw new Error(res.message || '保存失败')
            this.$message.success('保存成功')
            this.holidayDialog = false
            this.loadHolidays()
          })
          .catch(err => this.showError(err, '保存失败'))
          .finally(() => { this.saving = false })
      })
    },
    removeHoliday(row) {
      this.$confirm('确定删除该节假日日期吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/config/holidays/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadHolidays()
        })
        .catch(err => { if (err !== 'cancel') this.showError(err, '删除失败') })
    },
    openCoefficientDialog(row) {
      this.coefficientDialog = true
      this.$nextTick(() => {
        this.coefficientForm = row ? Object.assign({}, row) : { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 }
        if (this.$refs.coefficientForm) this.$refs.coefficientForm.clearValidate()
      })
    },
    saveCoefficient() {
      this.$refs.coefficientForm.validate(valid => {
        if (!valid) return
        this.saving = true
        api.post('/holiday/config/coefficients/save', this.coefficientForm)
          .then(res => {
            if (res.code !== 200) throw new Error(res.message || '保存失败')
            this.$message.success('保存成功')
            this.coefficientDialog = false
            this.loadCoefficients()
          })
          .catch(err => this.showError(err, '保存失败'))
          .finally(() => { this.saving = false })
      })
    },
    removeCoefficient(row) {
      this.$confirm('确定删除该客运系数吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/config/coefficients/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadCoefficients()
        })
        .catch(err => { if (err !== 'cancel') this.showError(err, '删除失败') })
    },
    formatCoefficient(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
    },
    showError(err, fallback) {
      if (err && err._toast) return
      const data = err && err.response && err.response.data
      this.$message.error((data && data.message) || (err && err.message) || fallback)
    }
  }
}
</script>
<style scoped>
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
.danger-text { color: #f56c6c; }
</style>
traffic-audit-web/src/views/UserManage.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>