a267825e23a5389713e42fde9d308df5f621e949..259db33b22095891f436b9ed5a81a21496b11c4c
5 天以前 zhizhijie
feat(汇总大表): 缺数清单加导出前弹窗并在生成件附《数据缺失清单》页(待验收)
259db3 对比 | 目录
5 天以前 zhizhijie
docs(交接): 记录10-12汇总大表缺数/母版脏格修复的下班关机交接与下一步待办
468c46 对比 | 目录
5 天以前 zhizhijie
Merge remote-tracking branch 'origin/main'
f2056a 对比 | 目录
5 天以前 zhizhijie
docs: 汇总大表生成数据验证方法成文(补提交)
7f958f 对比 | 目录
5 天以前 zhizhijie
feat(汇总大表): 缺数清单可见且缺数期不沿用上一期旧值(排名页按源侧留空)(待验收)
ae4775 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 扩列表头行改按基准月份列判定并清理9月母版被写入日期的13格(待验收)
fc00eb 对比 | 目录
5 天以前 zhizhijie
docs(交接): 记录 9 月导入核验与出表抽查结果、仍缺数据清单
89a0e7 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 扩列器整本重算写缓存(修公式无缓存导致的取数差异)待验收
6f3dd2 对比 | 目录
5 天以前 zhizhijie
feat(汇总大表): 收敛为单一母版(归档历史/停用版式参照件)并按用户版套用母版格式 待验收
790f92 对比 | 目录
5 天以前 zhizhijie
docs(交接): 记录用户决定暂不处理的三项遗留(#DIV/0!、网约车缺数、格式同步)
400b80 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 城市分市州当月标题行号差一行(45/67→44/66)并恢复被覆盖的单位行 待验收
64f646 对比 | 目录
5 天以前 zhizhijie
fix(城市客运): 公交/出租导入改按表头名定位列,兼容同事版少列模板 待验收
797f03 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 扩列公式克隆修正(值列相对右移、同比列重写)并落盘 9 月母版 待验收
3b3652 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 扩列改以当期产物为底(修 9 月丢 8 月列/累计漏月)并修体检误报 待验收
fb269f 对比 | 目录
5 天以前 zhizhijie
feat(汇总大表): 货运基数体检 B4 六条规则(倒减为负/缺报/重复导入/同比异常)待验收
91f658 对比 | 目录
5 天以前 zhizhijie
fix(汇总大表): 扩列时按母版期次重写 7 个标题页标题,并修正 8 月母版标题(待验收)
208c8f 对比 | 目录
5 天以前 xyc
docs(交接): 更新2026-10-12会话状态与地名核查结论
a2dfe6 对比 | 目录
8 天以前 xyc
docs(交接): 记录十一人员流动量情况表第2版(日均行、公路前置、合计同比按2025年8天)
3997fc 对比 | 目录
8 天以前 xyc
docs(交接): 记录十一人员流动量情况表 A4 横向单页打印适配
42cec2 对比 | 目录
8 天以前 xyc
docs(交接): 记录 2026 十一假期人员流动量情况 Excel 制作与口径
63e67d 对比 | 目录
9 天以前 xyc
fix(holiday): 铁路客运量走势图10月3日标签上移避免遮挡
adee58 对比 | 目录
9 天以前 xyc
docs(交接): 记录观测站速度/拥挤度走势图天数修正验收通过
09ed92 对比 | 目录
9 天以前 xyc
fix(holiday): 观测站速度/拥挤度走势图横轴改用系统配置假期天数
e95858 对比 | 目录
9 天以前 xyc
docs(交接): 记录走势图 14 与验收版 12 比对
490432 对比 | 目录
10 天以前 xyc
docs(交接): 添加 10-08 分析报告趋势核对快照
c7b655 对比 | 目录
10 天以前 xyc
docs(交接): 添加 10-07 关机快照
ca0bbe 对比 | 目录
2026-10-07 xyc
Revert "fix(holiday): 走势图按年度配置截取假期天数"
179544 对比 | 目录
2026-10-07 xyc
fix(holiday): 走势图按年度配置截取假期天数
8ad75d 对比 | 目录
2026-10-07 xyc
fix(holiday): 统一每日客流构成外置标签布局
c21fbd 对比 | 目录
2026-10-07 xyc
docs(交接): 补充 10-07 上午国省道调整更正与新会话接手说明
23fab3 对比 | 目录
2026-10-07 xyc
docs(交接): 添加 10-07 关机快照
9b5633 对比 | 目录
6个文件已添加
3 文件已重命名
9个文件已修改
1个文件已删除
2089 ■■■■■ 已修改文件
HANDOFF.md 277 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/汇总大表生成数据验证方法_2026-10-12.md 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/2026年8月道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/2026年9月道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/归档/2026年7月道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/归档/2026年8月道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/归档/2026年8月道路运输量汇总表_版式原型.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/归档/2026年9月道路运输量汇总表_试做_20260929.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/归档/生成_道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java 257 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java 453 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/layout/SummaryLayoutExpander.java 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,173 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
### 10-12(14):汇总大表「缺数清单弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页」+ ä½“检假阳性修复 + å…¬äº¤ 9月缺行核查【待验收】
- **动因**:用户要求把缺数摆到台面上(P0-3 å‰©ä½™ï¼‰ï¼Œå¹¶åœ¨ç”Ÿæˆä»¶é‡Œç»™ä¸€é¡µã€Šæ•°æ®ç¼ºå¤±æ¸…单》。
- **改动(3 å¤„)**:
  1. åŽç«¯ `ReportExportService.appendMissingDataSheet(...)`:生成收尾在产物末尾追加《数据缺失清单》页(第 20 é¡µï¼‰ï¼Œåˆ†ã€Œä¸€ã€ç¼ºæ•°æ¸…单 / äºŒã€æ•°æ®ä½“检 / ä¸‰ã€å…¶å®ƒæç¤ºã€ï¼Œå†™æŠ¥è¡¨æœŸ+生成时间+条数;只读提示页、不参与计算;母版若由产物派生已带同名页会先移除再建。
  2. å‰ç«¯ `ReportExport.vue`:点「导出/生成」先调 `GET /api/report/summaryWorkbook/missing` + `freightCheck`,命中则弹窗(分两段、可滚动)「仍要生成 / å–消」;两项皆空、接口异常或年报模式 â†’ ç›´æŽ¥ç”Ÿæˆã€‚
  3. `FreightReadinessCheck` ä¿®å‡é˜³æ€§ï¼šâ‘¡å…¨å£å¾„整期未导时不再按 `0−规上` æŠ¥ã€Œè§„下为负」(上面已报「缺②全口径」)→ 2026-09 å‘½ä¸­ 68→34,2026-08 ä» 0。
- **实测(dev 8090/8080,仅生成/只读)**:missing(2026-09)=4 æ¡ï¼ˆè´§è¿â‘¡â‘¢ã€ç½‘约车订单、网约车 pin);freightCheck(2026-09)=34、2026-08=0;异步生成 2026-09(refresh=true)→ 1,303,490 B / 20 é¡µ / æ¸…单 39 æ¡ï¼ˆ4+34+1),落盘缓存同步刷新为 `_cache/summaryWorkbook_2026-09_month__vc0e2afa47b51bc04.xlsx`;前端 `Compiled successfully`、bundle `/js/6.js` å·²å«æ–°ä»£ç ï¼›å¹´æŠ¥æ¨¡å¼ä¸¤æŽ¥å£è¿”回空。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表缺数清单弹窗与生成件页_2026-10-12.md`。状态:**【待验收】**。
- **【待验收 / å¾…定夺 / å¾…处理】三项**:
  1. ã€å¾…验收】导出前弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页(产物 `docs/生成汇总大表/输出/生成_道路运输量汇总表_2026-09.xlsx` æœ«é¡µï¼‰ï¼›
  2. ã€å¾…用户定夺】排名页缺数期留空:数值列留空符合预期,但《货运周转量排名》`R` åˆ—仍显示 **10.00**(母版 `=10-Q` åœ¨ `Q` æ¸…空后算出;源侧空表无此公式),是否一并清空本次**未改**;
  3. ã€å¾…处理】公交 2026-09 å°‘ 1 è¡Œå±ž**源文件缺行**——`随州市·42138100005·广水市安顺公交有限公司` åœ¨ 6/7 æœˆæºè¡¨ç¬¬ 119 è¡Œå­˜åœ¨ã€9 æœˆæºè¡¨æ²¡æœ‰ï¼ˆ`import_batch#551` è®°å½• 141/141/0,非导入跳过)→ éœ€å‘数据来源补件或确认企业停报;**未导入任何数据**。
- **顺带复核**:09 æœˆæ¯ç‰ˆ 9 æœˆåˆ—数值无误(`班线包车!S9=20647.5363`、`公交!S8=61618.09`、`出租!S6=32787.72`、`中口径明细!S5=3517.9161`;全簿 `46266` åªå‰© 3 ä¸ªåˆæ³•表头行)。
- **环境提示**:本机残留 1 ä¸ªæ— çª—口 `EXCEL.EXE`(PID 23540,`taskkill` æ‹’绝访问);已确认**母版未被锁**(读写测试通过),下次用 Excel COM å‰å…ˆå¤æŸ¥è¯¥è¿›ç¨‹ã€‚
### 10-12(13):下班关机交接 â€” æ±‡æ€»å¤§è¡¨ç¼ºæ•°/母版脏格修复已提交推送,下一步待办与新会话起步
**接手入口(新会话先读这三处)**:① æ ¹ç›®å½• `AGENTS.md`;② æœ¬æ–‡ä»¶é¡¶éƒ¨ï¼ˆæœ¬èŠ‚ + 10-12(11)(12));③ ä¸Šä¸€ä¼šè¯æ ‡é¢˜ã€Œ10.8日比对汇总大表和专项统计的生成逻辑」(因上下文超限报错中断,其工作已由本节这条线做完并补提交)。
- **今天这条线已完成并推送(origin main,本地与远端一致)**:
  - `fc00eb7` æ‰©åˆ—表头行改按基准月份列判定 + æ¸…理 9 æœˆæ¯ç‰ˆè¢«å†™æˆæ—¥æœŸ `46266` çš„ 13 æ ¼ï¼›
  - `ae47753` ç¼ºæ•°æ¸…单代码入库 + ç¼ºæ•°æœŸä¸æ²¿ç”¨ä¸Šä¸€æœŸæ—§å€¼ï¼ˆæŽ’名页按源侧留空/整块留空);
  - `7f958f5` æ±‡æ€»å¤§è¡¨ç”Ÿæˆæ•°æ®éªŒè¯æ–¹æ³•成文(补提交);`f2056aa` åˆå¹¶åŒäº‹ 10-08 é“è·¯èµ°åŠ¿å›¾æäº¤ï¼ˆHANDOFF é¡¶éƒ¨å†²çªå·²æŒ‰"双方都留"解决,顺序:zyj çº¿å„节 â†’ åŒäº‹ 10-08/10-07 å„节)。
- **当前状态**:本机 dev å‰åŽç«¯åœ¨è·‘(8090/8080);库内 2026-09 æœ‰ â‘ H2032、公交、出租、班线包车,**缺**②全口径、③拆分表、网约车(用户 10-12 è¯´æ˜Ž"其他数据暂时还没有");9 æœˆæ¯ç‰ˆ `docs\生成汇总大表\2026å¹´9月道路运输量汇总表.xlsx` å·²ä¿®å¥½ï¼ˆå¤‡ä»½ `_bak_2026å¹´9月母版_清理脏格前_20261012.xlsx`);最新产物在 `docs\生成汇总大表\输出\生成_道路运输量汇总表_2026-09.xlsx`(未跟踪、不入库)。
- **【待验收】两项(请用户看页面重新生成的 2026-09)**:① æŽ’名页在**缺数期整体留空**(不再显示上一期旧值)是否符合预期,若要"保留上一期值"可回退 `copyRankValues` çš„两条留空逻辑;② 9 æœˆæ¯ç‰ˆ 13 æ ¼æ¸…理后的 9 æœˆåˆ—数值(班线包车 20647.5363、公交 61618.09、出租 32787.72、中口径明细 3517.9161)。
- **下一步待办(按优先级)**:① ç¼ºæ•°æ¸…单的"导出前弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页"(P0-3 å‰©ä½™ï¼Œä¸Ž B4 ä½“检合并);② å…¬äº¤ 9 æœˆ 141 è¡Œ vs 1-8 æœˆ 142 è¡Œä»å¾…核(用户说先这样);③ ç”¨æˆ·è‹¥è¦å›žåˆ°"**专项统计 / èŠ‚å‡æ—¥å¿«æŠ¥**"那条线,先读 `docs/专项统计/重要节假日/` ä¸Ž AGENTS.md çš„"重要节假日快报与分析报告口径";④ åŸŽå¸‚分市州/道路运输周转量 34 ä¸ª `#DIV/0!` æŒ‰ç”¨æˆ· 10-12(7)决定**不动**。
- **环境/坑(下次直接用)**:`mvn`、`start-dev.ps1`/`stop-dev.ps1`、Excel COM、`git` æäº¤æŽ¨é€éƒ½éœ€ `require_escalated`(`.git` æ²™ç®±å†…只读;node è®¿é—®ç”¨æˆ·ç›®å½•被拒);**Excel COM å‰å…ˆç¡®è®¤æ²¡æœ‰æ— çª—口 EXCEL æ®‹ç•™è¿›ç¨‹**(会把母版锁成只读、Save é™é»˜å¤±æ•ˆï¼Œä»Šå¤©è¸©è¿‡ï¼Œå¤„理见 10-12(11));`_tmp\_fix\` ä¸‹ç•™äº†å¯å¤ç”¨è„šæœ¬ï¼ˆ`expand.ps1` æ‰©åˆ—、`gen.ps1` ç”Ÿæˆã€`repair_master.ps1` æ¯ç‰ˆç²¾ä¿®ã€`verify_*.py` æ ¡éªŒï¼‰ï¼Œæœªè·Ÿè¸ªã€æŒ‰çº¦å®šä¸åˆ ã€‚
- **数据边界**:本轮**未导入、未覆盖、未重导任何业务数据**(只做生成与只读核对)。
### 10-12(12):汇总大表「缺数不留上一期旧值」(排名页按源侧留空)+ ç¼ºæ•°æ¸…单代码入库【待验收】
- **背景**:用户问"生成表里很多数据都是 8 æœˆçš„吧";同时库里的缺数清单代码一直未提交(本机沙箱故障期间写的,沙箱恢复后补提交)。
- **口径改动(3 å¤„,同一件事:缺数不留上一期旧值)**:
  1. `loadCumulativeMap` / `getProvinceCumulative`:③规上规下拆分表**只认目标期**(原实现允许"目标期及之前最近一期"→ ä¼šé™é»˜æ‹¿ä¸Šä¸€æœŸçš„æ•°å½“本期);
  2. `copyRankValues`:源侧(独立排名表)缺数时**目标格留空**,不再沿用母版上一报表期的旧值(源侧文字标签不清);
  3. æŽ’名块**整块缺数**(源侧一个数都没有,如 9 æœˆç¼ºâ‘¡â‘¢æ—¶çš„《货运周转量排名》)→ æ•´å—只留标题与市州名,避免母版自带 RANK/占比公式在空块上算出"排名全 1 + å æ¯” #DIV/0!"。
- **效果(2026-09 å®žæµ‹ï¼Œäº§ç‰© `docs\生成汇总大表\输出\生成_道路运输量汇总表_2026-09.xlsx`)**:
  - ã€Šè´§è¿å‘¨è½¬é‡æŽ’名》整块留空 â€”— ä¸Žç”¨æˆ·æ—¥å¸¸æ ¸å¯¹çš„《生成_周转量排名》空态一致 âœ“ï¼›
  - ã€Šè´§è¿é‡æŽ’名》9 æœˆå—:规上=①实数(全省 **3575.1211**)、规下/合计留空(缺②);占比列沿用源侧公式(其中 72 ä¸ª #DIV/0! æ˜¯ç‹¬ç«‹æŽ’名表本身就有的);
  - æ— å›žå½’:公交 9 æœˆå®¢è¿é‡ **13476.35**、城市内 12021.00、出租客运量 5626.81 ä¸Žåº“内 Î£17 å¸‚州逐位一致 âœ“(其余页与上一版产物差异仅来自母版脏格清理与缓存刷新)。
- **同时补提交**(沙箱故障期间遗留,已按 10-12(4)报告验证过):`reportexport/calc/SummaryMissingDataCheck.java`(只读缺数清单)、`GET /api/report/summaryWorkbook/missing`、导出日志 `dbProblems` æŽ¥å…¥ï¼ˆæœ¬æ¬¡å¯¼å‡ºæ—¥å¿—已可见"缺数提示=[…]")。
- **遗留/待确认**:① åŸŽå¸‚分市州、道路运输周转量的 34 ä¸ª #DIV/0! æŒ‰ 10-12(7)用户决定不动;② **排名页在缺数期整体留空是否符合预期,请验收时确认**(若要"保留上一期值"的口径,可回退第 2/3 æ¡ï¼‰ã€‚
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表缺数不留上一期旧值_2026-10-12.md`。状态:**【待验收】**。
### 10-12(11):扩列表头行二次修正 + 9 æœˆæ¯ç‰ˆè¢«æ‰©åˆ—写成日期的 13 æ ¼æ¸…理【待验收】
- **现象(用户报告)**:生成表里"很多数据是 8 æœˆçš„"。排查发现 **9 æœˆæ¯ç‰ˆé‡Œæœ‰ 13 ä¸ªæ•°æ®æ ¼è¢«å†™æˆäº† 9 æœˆæœˆä»½æ—¥æœŸåºåˆ— `46266`(= 2026-09-01)**:` è´§è¿!S11/T11`、`班线包车!S9/T9`、`公交!S8/T8`、`出租!S6/T6`、`中口径明细!S5/T5`;下游缓存被带偏(`班线包车!S5`、`中口径明细!S99/S100/S104/S105` ç­‰ä¹Ÿæ˜¯ 46266)。
- **根因**:扩列器 `headerRowsOf` é¦–版只要求"整行 â‰¥2 ä¸ªæœˆä»½æ ¼",而出租第 6 è¡Œã€ç­çº¿åŒ…车第 3 è¡Œè¿™ç±»**数据行**里恰好有 2 ä¸ªåƒæ—¥æœŸåºåˆ—的数值 â†’ è¢«è¯¯åˆ¤æˆè¡¨å¤´è¡Œ â†’ æ‰©åˆ—时把"月份日期"写进了数据行(`cloneHeaderCell` å¯¹æ•°å€¼æ ¼å†™ `targetMonth` çš„æ—¥æœŸï¼‰ã€‚
- **修法**:改为**先取"月份表头格最多的那一行"作基准,把它的月份列当标准列**;只有这些列上大半是月份格的行才算表头行。轨道、轮渡一页两个块的 `[1,10]` ä¿æŒ âœ“ï¼›` è´§è¿/公交/网约车/中口径明细` ä»ä¸ºå•行表头 âœ“。
- **母版修复(用 Excel COM,未整本重写)**:13 æ ¼æŒ‰æ‰©åˆ—规则精修(该留空的清空、公式按规则补回 `=S12+S13` / `=S11/AN11-1` / `=S10+S14+…`)+ `CalculateFull` æ•´æœ¬é‡ç®—刷新缓存;修复前备份 `_bak_2026å¹´9月母版_清理脏格前_20261012.xlsx`。修复后全簿只剩 3 ä¸ª `46266` çš„**合法表头月份格**(城市客运 S2、公交 S3、出租 S3)。
- **验证**:重跑 2026-08→9 æœˆæ‰©åˆ—(日志表头行 ` è´§è¿[2] ç­çº¿åŒ…车[1] åŸŽå¸‚客运[1] å…¬äº¤[2] å‡ºç§Ÿ[2] ç½‘约车[2] è½¨é“、轮渡[1,10] ä¸­å£å¾„明细[2]`),新推件在这 13 æ ¼ä¸Šå…¨éƒ¨æ­£ç¡®ï¼›é‡è·‘ 2026-09 ç”Ÿæˆï¼Œäº§ç‰© `docs\生成汇总大表\输出\生成_道路运输量汇总表_2026-09.xlsx` ä¸­ 13 æ ¼æ¢å¤ä¸ºçœŸå®ž 9 æœˆå€¼ï¼ˆ`班线包车!S9=20647.5363`、`公交!S8=61618.09`、`出租!S6=32787.72`),**无 46266 æ®‹ç•™**。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_扩列表头行基准列修正与母版脏格清理_2026-10-12.md`。状态:**【待验收】**。
### 10-12(3):修「9 æœˆäº§ç‰©ä¸¢ 8 æœˆåˆ— / ç´¯è®¡æ¼æœˆã€â€”—扩列改为**以当期产物为底**【待验收】
- **现象(用户报告)**:生成的 9 æœˆæ±‡æ€»å¤§è¡¨é‡Œ **8 æœˆåˆ—全空**,累计漏掉 8 æœˆï¼ˆè§„上货运量累计显示 31309.58,正确应为 1-7月 27734.46 + 8月 3665.36 + 9月 3575.12 = **34974.95**)。
- **根因(逐层证实)**:① æ¯ç‰ˆ**不带"当期列"的值与公式**——`fillFreightSheet` æ˜Žç¡®"只回填目标期当月,1..目标月-1 ä»¥æ¯ç‰ˆä¸ºå‡†",全省行公式由生成时按当期补;② 8 æœˆçš„æ•°æ®åªè½åœ¨**8 æœˆäº§ç‰©**里、未回写母版;③ 9 æœˆæ¯ç‰ˆæ˜¯**从 8 æœˆæ¯ç‰ˆæ‰©åˆ—**来的 â†’ ç»§æ‰¿"8 æœˆåˆ—空" â†’ ç”Ÿæˆæ—¶åˆåªè¡¥ 9 æœˆ â†’ 8 æœˆæ°¸ä¹…空缺、累计 `=C+E+…+Q(空)+S` æ¼æœˆã€‚
- **修复**:扩列器**优先以「当期产物」为底**(产物含当期列的值与公式)——取该期**最新产物**(导出落盘缓存 `_cache/summaryWorkbook_<period>_month__v*.xlsx`,多根目录定位),取不到才回退母版并打 WARN;另修体检规则误报(②全口径缺失时不再报"规下为负",改报"缺②全口径 â†’ è§„下无法计算",否则 9 æœˆåªæœ‰è§„上时会对每个市州刷屏)。
- **关键实现点**:产物缓存定位必须用 3 å€™é€‰æ ¹ç›®å½•(`summaryTemplateDir` â†’ `user.dir/…` â†’ `user.dir/../…`)——首版用单相对路径,因 `spring-boot:run` çš„工作目录是**模块目录**而找不到,静默回退到了母版(表现为"改了但还是空")。
- **验证**:`docs/生成汇总大表/输出/_扩列_2026å¹´9月_待确认_v3.xlsx`(日志确认 base=**产物** `summaryWorkbook_2026-08_month__v37…xlsx`):
  - 8 æœˆåˆ—有值:货运 å…¨å£å¾„ **16888.7615** / è§„上 **3665.3638** / è§„下 **13223.3977**;公交全省 **12315.37**;
  - ç´¯è®¡å…¬å¼ **含 8 æœˆä¸Ž 9 æœˆ**:`=C5+E5+…+Q5+S5`;累计同比分母 `SUM(X5,Z5,…,AL5,AN5)`(2025-01~09);
  - 9 æœˆåˆ—留空(无 9 æœˆæºæ•°æ®ï¼‰ï¼›7 ä¸ªæ ‡é¢˜é¡µæ ‡é¢˜ **1-9月 / 9月** æ­£ç¡®ã€‚
- **口径结论(写进设计)**:**新母版一律从"当期产物"派生**(产物带当期数据与公式),不再从旧母版派生——否则每月都会丢"当期列",累计逐月漏数。
- **遗留**:① å¾…用户确认是否把 v3 è½ç›˜ä¸ºæ­£å¼ 9 æœˆæ¯ç‰ˆï¼ˆä¼šå…ˆå¤‡ä»½æ‰‹å·¥ç‰ˆï¼‰ï¼›â‘¡ æ‰©åˆ—产物中被重写的公式暂无缓存值(只写公式 + `fullCalcOnLoad`,Excel/WPS æ‰“开自动算)——建议补"整本重算写缓存";③ `中口径明细` è¡¨å¤´è¡Œè¯†åˆ«è¿”回 `[2,4]`,需核查。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_扩列以当期产物为底_2026-10-12.md`。状态:**【待验收】**。
### 10-12(4):扩列公式克隆两处修正 + 9 æœˆæ¯ç‰ˆè½ç›˜ + ç«¯åˆ°ç«¯éªŒè¯é€šè¿‡ã€å¾…验收】
- **修前问题(同一批次的三个)**:① å…‹éš†"当月值列"时公式未相对右移(新列写成 `=Q7+Q9`,应 `=S7+S9`)→ å…¨çœè¡Œå–到上月数;② å…‹éš†"当月同比列"时分子指向旧月份(`=Q5/AN5-1`,应 `=S5/AN5-1`)→ ç®—的是"上月值÷去年同月";③ POI `setCellFormula` **不接受前导 `=`** â†’ æŽ¥å£ 500(`FormulaParseException`,§6.3 åŽ†å²å‘ï¼‰ã€‚
- **修法**:新增 `shiftRefsAll`(克隆时对"本页/已扩列页"引用**无条件 +2**,相对复制)与 `cloneYoyCell`(不克隆,直接写 `本月值/表中定位的去年同月-1`,即 C3 å£å¾„);公式串去掉前导 `=`。
- **9 æœˆæ¯ç‰ˆè½ç›˜ï¼ˆç”¨æˆ·å·²ç¡®è®¤ï¼‰**:手工版备份为 `_bak_2026å¹´9月母版_手工版_20261012.xlsx`;新母版 = "8 æœˆäº§ç‰© â†’ æ‰©åˆ—",1,083,567 B;输出目录命名理顺(最新件 `_扩列_2026å¹´9月_待确认.xlsx`,旧版加 `_bak_` å‰ç¼€ï¼‰ã€‚
- **端到端验证(用新母版生成 2026-09)**:
  - è´§è¿ 8 æœˆåˆ—有值:全口径 **16888.7615**、规上 **3665.3638**、规下 **13223.3977**;
  - 9 æœˆåˆ—:全口径 3575.1211(②表未导,只有①规上)、规上 3575.1211;
  - **累计:全口径 133887.0138(=1-8月 130311.89 + 9月 3575.12)、规上 34974.9459(=27734.46+3665.36+3575.12)、规下 98912.0679** âœ“ ä¸Žæ‰‹ç®—一致;
  - å…¬äº¤ï¼š8 æœˆ 12315.37、累计 110776.34 âœ“。
- **遗留**:① æ‰©åˆ—产物被重写的公式无缓存值(建议补"整本重算写缓存");② `中口径明细` è¡¨å¤´è¡Œè¯†åˆ«è¿”回 `[2,4]` å¾…核查;③ â‘¡è¡¨ï¼ˆå…¨å£å¾„)9 æœˆæœªå¯¼å…¥å‰ï¼Œ"全口径"行显示等于规上(公式 =规上+规下、规下为空),体检已提示,导数据后自动正常。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_扩列公式克隆修正与9月验证_2026-10-12.md`。状态:**【待验收】**。
### 10-12(5):修「9 æœˆå…¬äº¤/出租月报导不进去」——导入改按表头名定位列【待验收】
- **现象**:用户导入 9 æœˆå…¬äº¤/出租月报反复失败;`import_batch` æ— è®°å½•、`city_bus_monthly`/`city_taxi_monthly` æ—  2026-09 è¡Œ â†’ **解析阶段即抛错**(没进写库)。
- **根因(实测三份文件)**:9 æœˆæ¨¡æ¿æ¯” 7 æœˆ**少列导致整体左移**——公交 68→**66** åˆ—(少 `统一社会信用代码`、`填报单位`,第 3 åˆ—起左移 2);出租 41→**40** åˆ—(少 `填报单位`,左移 1)。导入器原是**按固定列号取值 + å›ºå®šåˆ—位校验** â†’ æ ¡éªŒç›´æŽ¥æŠ›ã€Œä¸Žæ–‡ä»¶å†…容不符」。必需列按名都在,是列位不匹配;且取值也按固定列号,不改会整体读错列。
- **改动(按 I4 å£å¾„兼容模板差异)**:新增 `colByName(hdr, fallback, name[, occurrence])`(按表头名取列,未命中回退原固定列号)与 `requireHeaderColsByName(...)`(只校验必需列按名存在、不校验列位);`importCityBus`/`importCityTaxi` æ‰€æœ‰å­—段改为按名取值;公交重复表头「其中:城市内」(2 æ¬¡ï¼‰ç”¨å‡ºçŽ°åºå·åŒºåˆ†ï¼ˆ`stopCity`=1、`passengerCity`=2)。映射取自实测表头(公交 42 å­—段、出租 37 å­—段全部命中)。
- **验证**:列位差异已定位;名称映射完备(42/42、37/37);校验放宽仍保留"真缺列"的负向拦截;编译通过、后端已重启(PID 40592,13:17:38)。
- **待办**:**请用户在页面重导这两个 9 æœˆæ–‡ä»¶**(助手不代导);导入后助手做只读抽查(行数/市州/与源文件逐列对齐,尤其"城市内/城际城乡"口径字段)。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_公交出租导入按表头名定位_2026-10-12.md`。状态:**【待验收】**。
### 10-12(6):修「城市分市州当月块标题停在 7 æœˆã€â€”—标题写入行号差一行的 bug【待验收】
- **现象**:9 æœˆäº§ç‰©ï¼ˆ8 æœˆäº§ç‰©åŒæ ·ï¼‰é‡Œ `城市分市州` æ˜¾ç¤º"城市客运客运量分市州**7月**情况",标题不随月份更新。
- **根因(代码行号差一行)**:母版里当月块的**标题在 `A45`、单位在 `A46`**(周转量块是 `A67`/`A68`),但程序写的是 0 åŸº `45`/`67` = **Excel ç¬¬ 46/68 è¡Œ** â†’ â‘  çœŸæ ‡é¢˜è¡Œ `A45/A67` ä»Žæœªæ›´æ–°ï¼ˆä¸€ç›´åœåœ¨æ¯ç‰ˆæ—§æœˆä»½ï¼‰ï¼›â‘¡ ç¨‹åºçš„æ ‡é¢˜**覆盖了"单位:万人次/万人次公里"行**。累计块 `0/22` æ­£ç¡®ï¼Œå…¶ä½™é¡µï¼ˆåŸŽå¸‚汇总 `0/11`、中口径分析 `0/9`、道路运输周转量 `0/22`、两张排名)行号均已核对无误。
- **改动**:① ä»£ç  `smSetTitle(sh,45,…)`→`44`、`smSetTitle(sh,67,…)`→`66`;② æ¯ç‰ˆè¢«è¦†ç›–çš„ `A46/A68` ç”¨ **XML ç²¾å‡†æ”¹æ ¼**恢复为"单位:万人次 / å•位:万人次公里"(复用文件内既有共享字符串索引,不整本重写)。
- **验证(生成 2026-09 å®žæµ‹ï¼‰**:`A45/A67` = `城市客运客运量/客运周转量分市州**9月**情况` âœ“ï¼›`A46/A68` = `单位:万人次 / å•位:万人次公里` âœ“;累计块 `A1/A2/A23/A24` æ­£å¸¸ âœ“。
- **遗留(待用户确认后做)**:① `城市分市州` S50:S66 ä¸Ž `道路运输周转量` I27:I43 å„ **17 ä¸ª `#DIV/0!`**(占比分母=全省行在无数据月被写成 0)→ å»ºè®®"生成收尾统一错误值留白 + æ¯ç‰ˆå æ¯”公式加 IFERROR å…œåº•";② `中口径分析` 9 æœˆ**网约车行**值 0/同比 âˆ’1(9 æœˆç½‘约车数据未导,属缺数);③ `中口径明细` D107/D108/F107/F108 ä¸ºç¡¬ç¼–码 `0` å…¬å¼ï¼ˆæ¯ç‰ˆæ—¢æœ‰ï¼‰ï¼›â‘£ **用户提出的"格式调整"尚未给出具体位置**,待提供后同步母版。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_城市分市州当月标题行号修正_2026-10-12.md`。状态:**【待验收】**。
### 10-12(7):用户决定——以下三项**暂不处理**(记录,勿再作为待办提出)
- **明确决定(2026-10-12)**:以下三项用户决定**不改**,后续会话不要再当成待办重复提出;若用户以后主动提起再议。
  1. `城市分市州` S50:S66 ä¸Ž `道路运输周转量` I27:I43 çš„ 34 ä¸ª `#DIV/0!`(占比公式分母=全省行在无数据月被写成 0)——不做"错误值留白/IFERROR å…œåº•"。
  2. `中口径分析` 9 æœˆç½‘约车行(值 0、同比 âˆ’1,因 9 æœˆç½‘约车数据未导)——不额外处理,等数据到位自然恢复。
  3. è¡¨æ ¼æ ¼å¼è°ƒæ•´åŒæ­¥æ¯ç‰ˆâ€”—用户不再提供具体改动,母版不做格式同步。
- **本日已完成并待验收的改动(汇总)**:Step 0 è¡¨å¤´åŠ¨æ€å®šä½ï¼ˆ`6d1ac61`)、货运体检 6 æ¡è§„则(`91f6583`)、扩列以当期产物为底(`fb269f6`)、扩列公式克隆修正 + 9 æœˆæ¯ç‰ˆè½ç›˜ï¼ˆ`3b3652e`)、公交/出租导入按表头名定位(`797f03b`)、城市分市州当月标题行号修正(`64f6466`)。
### 10-12(8):按用户要求收敛为「一个母版」+ æ¯ç‰ˆæ ¼å¼æŒ‰ç”¨æˆ·è°ƒæ•´ç‰ˆã€å¾…验收】
- **用户要求(2026-10-12)**:格式按用户手工调整过的《生成_道路运输量汇总表_2026-09 (3).xlsx》来;母版只要一个。
- **已完成**:
  1. **母版收敛为一本**:`docs/生成汇总大表/` ä¸‹åªç•™ `2026å¹´9月道路运输量汇总表.xlsx`;把 7 æœˆæ¯ç‰ˆã€8 æœˆæ¯ç‰ˆã€8月版式原型、9月试做件、**版式参照件《生成_道路运输量汇总表.xlsx》** å…± 5 ä»¶ç§»å…¥ `docs/生成汇总大表/归档/`(`_备份_`/`_bak_` æŒ‰ A3 ä»ç•™åŽŸåœ°ä½œå›žå½’å¯¹ç…§ï¼Œä¸ç®—æ¯ç‰ˆï¼‰ã€‚
  2. **停用参照件**:`exportSummaryWorkbook` ä¸å†è°ƒç”¨ `applySummaryReferenceLayout`(`widthApplied=0`),**版式一律以当期母版为准**(日志不再出现"套用参照件列宽")。
  3. **用户样式套入母版**:用 Excel COM ç²¾ç¡®å¥—用(109 åˆ—宽 + 3 è¡Œé«˜ + 1348 æ ¼å­—号 + 32 æ ¼å•元格格式),并去掉母版 `fullCalcOnLoad` ä»¥å… Excel æ‰“开即重算。
  4. **验证**:生成 2026-09 â†’ **产物与母版的列宽/行高/字号完全一致(差异 0)** âœ“;与上一版产物比 **结构差异 0** âœ“。
- **两处残留(均不可见,已核实)**:① åˆ—宽 226 å¤„ **±0.01**(Excel æŒ‰ 1/256 å­—符存宽度)✓;② 32 ä¸ª**空白格/合并区覆盖格**的填充与字体色(Excel ä¿å­˜å¯¹ç©ºæ ¼æ ¼å¼çš„处理差异)✓。
- **一处需用户拍板的数值变化**:本次生成件 `中口径排名` æœ‰ **46 æ ¼**(同比/排名,如 `D3` 0.0581→**0.0402**、`E4` ç¬¬10→第**9**)与上一版不同;**新值与《中口径分析》页逐位一致**(`B3=35028.0636`、`C3=0.04015856`)——即排名页与明细/分析页口径统一(原旧值是该页在母版里的旧缓存)。**保留新值 / å›žé€€æ—§å€¼å¾…用户确认**。
- **归档说明**:`归档/` å†… 5 ä»¶åŽ†å²æ¯ç‰ˆ/版式件仍可随时取回(未删除);母版目录以后只放"当期一本"。
- **状态**:**【待验收】**。
### 10-12(9):扩列器加「整本重算写缓存」+ æ˜Žç¡®ä¿ç•™ä¸­å£å¾„排名新值【待验收】
- **问题定位(用户问"为什么产生这 46 æ ¼å·®å¼‚")**:母版里**大量公式只有公式、没有缓存值**(POI å†™å…¬å¼ä¸å¸¦ç¼“存)。实证:母版经 Excel ä¿å­˜å‰åŽ **约 2.47 ä¸‡æ ¼**由"空"变"有值"(如 ` è´§è¿!U5: ç©ºâ†’130311.8927`)。`中口径排名` çš„同比/排名由程序**读母版缓存**计算(方案 A)→ ç¼“存空时算旧值 `0.0581`,缓存补齐后算 `0.0402`(**与《中口径分析》逐位一致**)。**新值才是数据完整时的正确结果**。
- **改动**:`SummaryLayoutExpander` æ–°å¢ž `recalcAndCache(wb)`(`clearAllCachedResultValues()` + `evaluateAll()` å†™å›žç¼“存,失败仅 WARN),在 `expand()` ä¸Ž `fixTitles()` ä¸­è°ƒç”¨ã€‚
- **验证**:扩列 2026-08 â†’ å…¬å¼æ ¼ **39,961** ä¸ªï¼Œ**空缓存仅 4 æ ¼**(全在 ` è´§è¿`,POI æ— æ³•求解的外部引用,Excel æ‰“开会算);修复前同类母版约 2.47 ä¸‡æ ¼æ— ç¼“存。
- **用户决定**:`中口径排名` é‚£ 46 æ ¼**保留新值**(与中口径分析一致,口径统一)。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_扩列整本重算写缓存_2026-10-12.md`。状态:**【待验收】**。
### 10-12(10):9 æœˆå¯¼å…¥æ ¸éªŒ + å‡ºè¡¨æŠ½æŸ¥é€šè¿‡ï¼›9 æœˆä»ç¼ºæ•°æ®æ¸…单【待验收】
- **9 æœˆå¯¼å…¥æ ¸éªŒï¼ˆåªè¯»æŸ¥åº“)**:城市公交 **141 è¡Œ**、巡游出租 **17 è¡Œ**、公路旅客 H2031 **767 è¡Œ**、货运企业 H2032 **636 è¡Œ** â†’ å…¨éƒ¨æˆåŠŸï¼ˆ**公交/出租的"按表头名定位"改动实测有效** âœ“,`import_batch` 551/552 å¯è§ï¼‰ã€‚仍缺 9 æœˆï¼š**②《货运量周转量表》(全口径)0 è¡Œã€â‘¢è§„上规下拆分表 0 è¡Œã€ç½‘约车订单/全省 pin 0/0、个体客运 0 è¡Œ**(用户 2026-10-12 è¯´æ˜Žï¼šå…¶ä»–数据暂时还没有)。
- **出表抽查(产物落 `docs/生成汇总大表/输出/生成_道路运输量汇总表_2026-09.xlsx`)**:公交 9 æœˆå®¢è¿é‡ **13476.35**、城市内 **12021.00**、出租客运量 **5626.81**、城市内 **5139.61** â€”— ä¸Žåº“内 Î£17 å¸‚å·ž**逐位一致** âœ“;累计已含 9 æœˆï¼ˆå…¬äº¤ç´¯è®¡ **124252.69** = 1-8月 110776.34 + 9月 13476.35)✓。因②未导,9 æœˆã€Š è´§è¿ã€‹é¡µ"全口径"暂等于规上(3575.1211)、"规下"为 0 â€”— å±žç¼ºæ•°ï¼Œéžç®—错。
- **小待办**:公交 9 æœˆ **141 è¡Œ** vs 1-8 æœˆå„期 **142 è¡Œ**(少 1 è¡Œï¼‰ï¼Œå¾…核是源文件少行还是导入跳过(可能是空行/汇总行)——用户说先这样,留待以后。
- **本机限制**:助手沙箱不能写 `D:\下载`,生成件只能落在仓库内(`docs/生成汇总大表/输出/`);正式下载请在页面点生成/下载。
- **状态**:**【待验收】**。
## æœ€æ–°ï¼ˆ2026-10-12,zyj çº¿ï¼‰
### 10-12:扩列器补「标题按母版期次重写」+ ä¿®æ­£ 8 æœˆæ¯ç‰ˆæ ‡é¢˜ã€å¾…验收】
- **用户反馈**:9 æœˆç‰ˆå¼ä»¶ä¸Ž 8 æœˆæ¯ç‰ˆé‡Œï¼Œ7 ä¸ªé¡µç­¾çš„æ ‡é¢˜ä»æ˜¯ `1-7月 / 7月`(货运量排名、货运周转量排名、城市汇总、城市分市州、中口径排名、中口径分析、道路运输周转量)。
- **根因(两条)**:① æŒ‰å·²ç¡®è®¤å£å¾„è¿™ 7 é¡µæ˜¯ C ç±»é¡µâ€”—**标题由生成程序在出表时现写**(`ReportExportService :969 / :1011 / :2707 / :2891 / :4446 / :4498 / :4499 / :4604` å‡æŒ‰ period å†™ï¼‰ï¼Œæ¯ç‰ˆé‡Œçš„æ–‡å­—不参与出表,所以扩列器原本没动它们;② **我的实现确实漏了"标题 +1"这一步**——《扩列配置·核对版》动作 7 å†™äº†"标题:页面标题月份+1(逐页核对)",我只做了 8 ä¸ªå°è´¦é¡µçš„表头克隆。
- **修复**:扩列器新增 `applyTitles(...)`——按**母版期次**重写标题(累计=`1-M月`、当月=`M月`),只处理 7 ä¸ªæ ‡é¢˜é¡µã€**命中标题标记才改**(避免误伤 ` è´§è¿!A1` è¿™ç±»å¤‡æ³¨ï¼‰ï¼Œ4 ä½å¹´ä»½ä¸€å¹¶æŒ‰ç›®æ ‡å¹´é‡å†™ï¼›æ–°å¢žåªè¯»æŽ¥å£å‚æ•° `?titlesOnly=true`(只改标题不扩列)。
- **8 æœˆæ¯ç‰ˆå·²ä¿®æ­£**:7 é¡µæ ‡é¢˜ `1-7月/7月` â†’ **`1-8月/8月`**(修正前备份 `docs/生成汇总大表/_bak_2026å¹´8月母版_标题修正前_20261012.xlsx`);19 é¡µç­¾ä¸Žå…¬å¼æŠ½æŸ¥æ— æŸï¼ˆ`中口径明细!C5`、`公交!C5` å…¨çœè¡Œå…¬å¼ã€` è´§è¿!A1` å¤‡æ³¨å‡æ­£å¸¸ï¼‰ã€‚
- **9 æœˆç‰ˆå¼é‡è·‘**:7 é¡µæ ‡é¢˜ â†’ **`1-9月/9月`**;产出 `docs/生成汇总大表/输出/_扩列_2026å¹´9月_待确认_v2.xlsx`(1,037,071 B)。
- **与手工 9 æœˆæ¯ç‰ˆå¤æ¯”**:851 æ¡å·®å¼‚ = 8 ä¸ªå°è´¦é¡µ 836 æ¡ï¼ˆæ²¿ç”¨ 10-10 åˆ†ç±»ï¼šç´¯è®¡/分母是否扩到 9 æœˆ 548、分隔符 153、表名引号 130、浮点尾差 1)+ **标题页 15 æ¡**(手工件标题为滞后的 `1-8月/7月`)。
- **踩坑**:重跑时目标文件被 Excel/WPS å ç”¨ï¼ˆ`being used by another process`)→ å…ˆè½ `_v2` å‘½åï¼›ç”¨æˆ·å…³é—­åŽå¯ç”¨åŽŸåé‡å†™ã€‚
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表扩列器标题修正_2026-10-12.md`;日结 `docs/工作日结/工作日结_2026-10-12.md`。
- **待用户验收/确认**:① æ ‡é¢˜å£å¾„按"**标题期次=母版期次**"(8月→1-8月/8月、9月→1-9月/9月)是否正确;② `_扩列_2026å¹´9月_待确认_v2.xlsx` æ˜¯å¦æ”¹åä¸ºæ­£å¼åï¼ˆåŽŸåé‚£ä»½æ˜¯æ—§å†…å®¹ã€è¢« WPS å­˜è¿‡ï¼Œæœªç»åŒæ„ä¸åŠ¨ï¼‰ï¼›â‘¢ ç´¯è®¡/累计同比"是否扩到 9 æœˆ"(产出=已扩、手工版未扩)。
- **状态**:**【待验收】**。
### 10-12(2):货运口径「导出前基数体检」(B4)6 æ¡è§„则落地【待验收】
- **背景**:规下 = â‘¡ã€Šè´§è¿é‡å‘¨è½¬é‡è¡¨ã€‹å…¨å£å¾„ âˆ’ â‘ H203-2 è§„上÷10000,是**倒减**——①漏报会让规下虚高、极端还会算出负数。用户 2026-10-12 åŒæ„æŠŠè¿™ç»„规则加进 B4。
- **实现**:新增 `reportexport/calc/FreightReadinessCheck.java`(只读);接入 `exportSummaryWorkbook` çš„缺数收集 `dbProblems`(随导出日志输出);新增只读接口 `GET /api/report/summaryWorkbook/freightCheck?period=YYYY-MM`。
- **规则**:① æŸå¸‚州规下为负;② â‘¡æœ‰å…¨å£å¾„、①无规上记录(疑漏报→规下=全口径);③ â‘¡å…¨å£å¾„ < â‘ è§„上;④ Î£17 å¸‚å·ž â‰  â‘¡"湖北省"行(容差 0.01);⑤ **本月与上月完全相同**(疑重复导入,两侧 â‰¥50 ä¸‡å¨/万吨公里);⑥ **同比异常**(|去年同月→本月| > 50%,两侧 â‰¥50);⑥' ç´¯è®¡å‹¾ç¨½ï¼ˆâ‘ å‘¨è½¬é‡ 1..M æ±‚å’Œ Ã·10000 vs â‘¢æ‹†åˆ†è¡¨ CUMULATIVE)。
- **阈值修正(实测驱动,重要)**:首版用"规下环比 > 30%" â†’ 2026-02 å‘½ä¸­ 22 æ¡ã€2026-03 å‘½ä¸­ 32 æ¡ï¼Œå¤æ ¸ç¡®è®¤**全是春节季节性**(各市州 2 æœˆ âˆ’30~−40%、3 æœˆ +30~+60%),非数据错误 â†’ æ”¹ä¸º"重复导入特征 + åŒæ¯”>50%";并给判据加**绝对门槛 50 ä¸‡å¨/万吨公里**,避免神农架林区(20~46 ä¸‡å¨ï¼‰è¿™ç±»å°åŸºæ•°å™ªå£°é•¿æœŸæŠ¥è­¦ã€‚
- **实测**:2026-01~08 **全期 0 å‘½ä¸­**(无误报);②表 `last_freight_m08`/`last_turnover_m08` **18/18 è¡Œæœ‰å€¼** â†’ åŒæ¯”规则确实参与计算(非空跑)。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_货运基数体检B4六条规则_2026-10-12.md`;日结同日两条。
- **待用户验收/确认**:① é˜ˆå€¼ï¼ˆç»å¯¹é—¨æ§› 50、同比 50%)是否合适;② æ˜¯å¦çŽ°åœ¨åš B4 å‰©ä½™éƒ¨åˆ†â€”—导出前弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页(P0-3 å‰©ä½™ï¼‰ã€‚
- **状态**:**【待验收】**。只读功能,未改动业务数据。
### 10-12(3):生成数据「验证方法」成文(五段链条 + ååŒæºç›²åŒºï¼‰
- **成文**:`docs/汇总大表生成数据验证方法_2026-10-12.md`(回答"如何验证程序生成的数据是否正确")。
- **口径**:把问题拆成 4 é—®ï¼ˆæºæ•°æ® / ç®—法 / ç‰ˆå¼ / ä¸Žå®šç¨¿ä¸€è‡´ï¼‰ï¼Œæ¯æ®µé…ä¸€ä¸ª**独立于该段的参照**;链条五段 L0 æºæ–‡ä»¶â†’库、L1 åº“→口径值、L2 å£å¾„值→单元格、L3 å•元格→整本、L4 è·¨é¡µåˆæˆé¡µå‹¾ç¨½ï¼›**通过标准 L0~L4 å·²å†™æ­»**(任一层未覆盖,结论不得写"已验证")。
- **对既有"四层验证法"(10-08/10-12 ä¼šè¯ï¼‰çš„三处补强**:① è¡¥ L0 æºæ•°æ®å±‚与**反同源盲区**——"生成件 vs å®šç¨¿"在母版与定稿同源时查不出源数据问题(今日 2025-11 ä¸¤å¤„即例);② **体检通过 â‰  æ•°æ®é½å…¨**,异常清单与缺数清单分开输出(今日 2026-08 ä½“检 0 å‘½ä¸­ï¼Œä½† 9 æœˆä»¶ 8 æœˆåˆ—仍空);③ å¿…须专门做一次「库内值 vs æ¯ç‰ˆç¼“存」全量 diff(10-09 å¾…办)——**出表口径不切库**与**验证必须比一次**并不矛盾。
- **佐证**:2026-08 vs 9/13 å®šç¨¿ï¼ˆ1020 æ¡ â†’ 11 æ¡å…³æ³¨ï¼Œç®—法层通过);库内 2025 vs æ¯ç‰ˆ 2025(公交/出租/轨道轮渡 100% ä¸€è‡´ï¼Œ3 å¤„需业务确认,报告见 `docs/功能测试报告/功能测试报告_库内2025与母版2025逐格核对_2026-10-12.md`)。
- **状态**:文档类,无需验收。
- **B4 å…³è”(已核)**:10-12 ä¸Šåˆçš„货运基数体检 B4 å·²ç”±å¦ä¸€ä¼šè¯æäº¤ï¼ˆ`91f6583`,报告 `docs/功能测试报告/功能测试报告_货运基数体检B4六条规则_2026-10-12.md`);本次 2026-08 æ ¸å¯¹å®žé™…调用了它的 `GET /api/report/summaryWorkbook/freightCheck`(2026-01~08 å…¨æœŸ 0 å‘½ä¸­ï¼‰ã€‚
### 10-12(4):汇总大表「缺数清单」落地(只读接口 + æŽ¥å…¥å¯¼å‡ºæ—¥å¿—)【待验收】
- **动因**:用户要求把「缺数」显式摆到台面上——当日两个真实问题(9 æœˆç”Ÿæˆä»¶ 8 æœˆåˆ—为空、9 æœˆå‘¨è½¬é‡å…¨ 0)都属"该有的数没有",而 `freightCheck` ä½“检当时 **0 å‘½ä¸­**,两者必须分开。
- **实现**:新增 `reportexport/calc/SummaryMissingDataCheck.java`(只读);接口 `GET /api/report/summaryWorkbook/missing?period=YYYY-MM`;接入 `exportSummaryWorkbook` çš„ `dbProblems`(随导出日志输出)。
- **口径**:① é€æœˆå›žå¡«é¡µï¼ˆç­çº¿åŒ…车/公交/出租/轨道轮渡)查 1..目标月 æ¥æºæ˜¯å¦æœ‰æ•°ã€17 å¸‚州是否齐全;② è´§è¿/网约车只回填目标月,查目标月各项来源(货运①+②+③、网约车订单+全省 pin+出租平均运距);③ è¿™ä¸¤é¡µçš„历史月由母版承担——**母版与同期备份母版(donor)都没有数**才算缺口(否则会误报 7 æœˆé‚£ç§"骨架母版 + donor è¿˜åŽŸ")。
- **实测**(独立 8099 å®žä¾‹ï¼Œæœªæ‰“扰 8090,测完已关;`mvn -o compile` exit=0):2026-08 **0 æ¡**、2026-07 **0 æ¡**、2026-09 **11 æ¡**(班线包车/公交/轨道轮渡/出租 æ— æº 4 æ¡ + è´§è¿â‘¡â‘¢ 2 æ¡ + ç½‘约车 3 æ¡ + æ¯ç‰ˆ 8 æœˆåˆ—空 2 æ¡ï¼‰â€”—命中的正是当日两个真实问题。
- **修正**:首版只比母版 â†’ è¯¯æŠ¥ 2026-07 ä¸¤æ¡ï¼›å·²æŒ‰ç”Ÿæˆé“¾å®žçŽ°è¡¥ä¸Š donor æ¯”对,复测 0 æ¡ã€‚
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表缺数清单_2026-10-12.md`。
- **待办**:导出前弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页(与 B4 çš„ P0-3 åˆå¹¶ï¼‰ã€‚
- **状态**:**【待验收】**(只读功能;未落盘、未改业务数据)。
## æœ€æ–°ï¼ˆ2026-10-10,zyj çº¿ï¼‰
### 10-10:Step 1 æ‰©åˆ—器落地 + 8→9 æœˆç‰ˆå¼é¦–轮产出【待验收】
@@ -100,6 +267,106 @@
- **卡点未变**:`resolveSummaryMother`(`ReportExportService.java:4576`)只精确匹配《{year}å¹´{month}月道路运输量汇总表.xlsx》,目标月无母版即报错;9 æœˆæ¯ç‰ˆä»ä¸ºç”¨æˆ· 09-29 æ‰‹å·¥æ‰©åˆ—产物(本机未入库)。
- **待确认**:① æ‰©åˆ—蓝本用 9 æœˆæ‰‹å·¥ç‰ˆè¿˜æ˜¯ 8 æœˆç‰ˆï¼›â‘¡ æ˜¯å¦ä¸€æ¬¡å»ºæˆ 12 æœˆæ»¡ç‰ˆå¼ï¼ˆæ–¹æ¡ˆ v1 å»ºè®®æ»¡ç‰ˆå¼ï¼‰ã€‚
- **状态**:本段与 M0 è§„格均为【待验收】;本轮未改动任何代码。工作区未跟踪的 `docs/生成汇总大表/2026å¹´9月道路运输量汇总表*.xlsx` ä¸Ž `docs/确认.docx` æŒ‰çº¦å®šä¿ç•™ã€‚
## æœ€æ–°ï¼ˆ2026-10-08 é“è·¯å®¢è¿é‡èµ°åŠ¿å›¾10月3日标签避让)
### 10-08:铁路客运量走势图 10月3日 `72.4` æ ‡ç­¾ä¸Šç§»ï¼Œé¿å…è¢« `70.6` é®æŒ¡
- **需求(用户)**:走势图其余部分一律不动,只把 `“十一”假期铁路客运量情况` å›¾ä¸­ 10月3日 `72.4` çš„æ ‡ç­¾ä»Žç‚¹çš„右边移到点正上方;该位置原本空白,放右边会被 2024 å¹´ç»¿è‰² `70.6` æ ‡ç­¾åŽ‹ä½ä¸€éƒ¨åˆ†å¯¼è‡´çœ‹ä¸å…¨ã€‚
- **数据背景(2026 åä¸€é“è·¯å®¢è¿é‡ï¼Œä¸‡äººæ¬¡ï¼‰**:2026 å¹´ 10月3日 = 72.4,2025 å¹´åŒæ—¥ = 72.3,2024 å¹´åŒæ—¥ = 70.6。三条线同日数值非常接近,自动布局把 `72.4` æ”¾åˆ°äº†ç‚¹å³ä¾§ã€‚
- **根因**:标签自动布局(`HolidayTrendChartService.drawXYItemLabels` åŠ¨æ€è§„åˆ’ï¼‰åªå¯¹â€œæŒ‰æ•°å€¼æŽ’åºåŽç›¸é‚»çš„ä¸¤ä¸ªæ ‡ç­¾â€åšé‡å æƒ©ç½šï¼›`72.4` ä¸Ž `70.6` ä¹‹é—´éš”着 `72.3`,二者不相邻,重叠未被惩罚,因此允许互相覆盖。
- **改动**:`HolidayTrendChartService.lineChart` æ–°å¢žå¯é€‰ `List<LineLabelOverride> labelOverrides` å…¥å‚(配合 `LineLabelAnchor{ABOVE,BELOW,LEFT,RIGHT}`),可把指定 `(seriesIndex,itemIndex)` çš„æ ‡ç­¾å›ºå®šåˆ°æŸä¸ªæ–¹ä½ï¼›ä¸ä¼ å‚时完全走原自动布局,渲染逐字节不变。`HolidayTrendService` ä»…对铁路图传一条 override:`seriesIndex=0`(2026 å¹´ï¼‰ã€`itemIndex=2`(10月3日)、`ABOVE`。
- **验证**:`mvn -o test` 17/17 é€šè¿‡ï¼ˆæ–°å¢žæ ‡ç­¾é”šç‚¹å•测:空/不匹配 override ä¸æ”¹å˜æ¸²æŸ“、ABOVE é«˜äºŽ BELOW);真实库端到端重新生成走势图,15 å¼ å›¾ä¸­ä»… `image5.png`(铁路图)SHA-256 å˜åŒ–,其余 14 å¼ é€å­—节一致;铁路图前后像素差包围盒 `x 313-436, y 395-438`,正是该标签区域;本地 OCR ç¡®è®¤ `72.4` å®Œæ•´å¯è¯»ã€ä½äºŽç‚¹ï¼ˆy≈427)上方,不再与 `70.6`(y431-450)重叠。
- **边界**:只改了铁路这一张图的这一处标签;快报、数据导入、其它统计逻辑均未触碰;未导入/覆盖/重导任何业务数据。
- **重启**:为让按钮下载用上新代码,已用 `start-dev.ps1` é‡å¯åŽç«¯ï¼ˆå‰ç«¯æœªåŠ¨ï¼‰ï¼›å¯ç›´æŽ¥åœ¨ç³»ç»Ÿé‡Œé‡æ–°ä¸‹è½½èµ°åŠ¿å›¾éªŒæ”¶ã€‚
- **待办/风险**:这条 override ç»‘定 `seriesIndex=0`(2026 å¹´ï¼‰ä¸Ž `itemIndex=2`(10月3日);若后续铁路图系列顺序或天数口径调整,需要同步核对。其余年份/日期如再出现标签遮挡,可复用该 override æœºåˆ¶ï¼Œä½†éœ€é€å›¾äººå·¥ç¡®è®¤åŽå†åŠ ã€‚
- **验收**:用户 2026-10-08 å·²çœ‹å›¾ç¡®è®¤æ•ˆæžœå¯ä»¥ã€‚
## åŽ†å²ï¼ˆ2026-10-08 èµ°åŠ¿å›¾é™„å½•äºŒè§‚æµ‹ç«™å›¾å¤©æ•°ä¿®æ­£ï¼‰
### 10-08:观测站速度/拥挤度走势图改用系统配置假期天数(已验收,commit e958585)
- **需求(用户)**:走势图最后两张图“普通国省道观测站平均机动车速度”“…平均拥挤度”只含今年数据,横轴天数不应是固定 8 å¤©ï¼Œè€Œåº”为系统配置的假期天数。
- **改动**:`HolidayTrendChartService.lineChart` æ–°å¢žå¯ä¼  `dayLabels` çš„重载(默认仍为 `DAY_LABELS` 8 å¤©ï¼Œå…¶ä½™ 13 å¼ å›¾é›¶æ”¹åŠ¨ï¼‰ï¼›`HolidayTrendService.observationData` æ”¹ä¸ºæŒ‰ `configService.findHolidayDays(year, holidayType)`(`day_index>=1`)的天数与日期生成数据点和横轴标签,最后两张图传入该标签。
- **配置依据**:`holiday_calendar` 2026 åä¸€ `day_index 1..7`(7 å¤©ï¼Œ`day_index 0` = 09-30 çŽ¯æ¯”åŸºæœŸï¼‰ï¼›2025 = 8 å¤©ã€2024 = 7 å¤©ã€‚
- **验证**:`mvn -o test` 16/16 é€šè¿‡ï¼ˆæ–°å¢žè‡ªå®šä¹‰æ¨ªè½´æ ‡ç­¾å•元测试);真实云只读生成走势图,与用户已下载版逐图对比:image1~13 çš„ SHA-256 å®Œå…¨ä¸€è‡´ï¼Œä»… image14(速度)、image15(拥挤度)变化;OCR ç¡®è®¤æ–°å›¾æ¨ªè½´ä¸º 10月1日~10月7日、无 10月8日。
- **边界**:其余图仍固定 8 å¤©å£å¾„,未改;未导入/覆盖/重导任何业务数据。
- **重启**:为让按钮下载用上新代码,已用 `start-dev.ps1` é‡å¯åŽç«¯ï¼ˆå‰ç«¯æœªåŠ¨ï¼‰ï¼Œé‡å¯æ—¶é—´ 2026-10-08 10:38:53;可直接在系统里重新下载走势图验收。
- **验收**:用户 2026-10-08 åœ¨ç³»ç»Ÿé‡Œé‡æ–°ä¸‹è½½èµ°åŠ¿å›¾åŽå®¡æ ¸ï¼ŒéªŒæ”¶é€šè¿‡ã€‚
## åŽ†å²ï¼ˆ2026-10-08 ä¸Šç­å‰å…³æœºå¿«ç…§ï¼‰
### 10-08:走势图 `(14)` ä¸Žå·²éªŒæ”¶ `(12)` æ¯”对(内容一致)
- **用户任务**:比对系统重新下载的 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图 (14).docx` ä¸Žæ­¤å‰éªŒæ”¶é€šè¿‡çš„ `(12)`。
- **比对方法**:按 DOCX åŒ…逐项比对。`(12)`、`(13)`、`(14)` çš„ 31 ä¸ªå†…部文件(含正文 XML、样式、关系文件和全部图表图片)逐个 SHA-256 å®Œå…¨ç›¸åŒã€‚
- **比对结果**:三个文件整体 SHA-256 ä¸åŒï¼Œä»…å›  ZIP å®¹å™¨æ—¶é—´æˆ³ç­‰æ‰“包元数据不同;文件长度相同,差异为 234 ä¸ªå­—节,集中在 3 å­—节时间戳段,文档内容没有任何差异。
- **结论**:`(14)` å°±æ˜¯å·²éªŒæ”¶çš„ `(12)` åŒä¸€ç‰ˆæœ¬ï¼Œå›¾è¡¨æ•°å€¼ã€æ ‡ç­¾ã€ç‰ˆå¼å‡æœªå˜åŒ–,不需要重新验收。
- **边界**:只读比对,未修改下载文件、代码、系统数据或业务库。
- **当前运行**:2026-10-08 é‡å¯åŽï¼Œå·²ç”¨ `start-dev.ps1` æ¢å¤ dev çŽ¯å¢ƒï¼›å‰ç«¯ `8080`、后端 `8090` å‡åœ¨ç›‘听并访问正常。Git åœ¨æœ¬æ¬¡äº¤æŽ¥æäº¤å‰ä¸º `HEAD = origin/main = c7b6553`;工作区无已跟踪改动,只剩约定不入库的 `_tmp_*`、`_tmp_pack_smoke_trend.docx` å’Œè§‚测站原始 XLS。
- **数据与下一步**:当前业务库仍只覆盖到 `2026-10-06`,`2026-10-07` æœªå¯¼å…¥ã€‚新对话先读 `AGENTS.md` + `HANDOFF.md`;等 10-07 æ•°æ®é½å…¨åŽï¼Œå…ˆåªè¯»æ ¸å¯¹æº Excel å’Œå¼‚常项,得到用户明确指示后再导入或改动,再生成最终走势图和分析报告。折线图横轴仍按已确认的固定 8 å¤©å£å¾„,不能改成按年度截断。
### 10-08:同事分析报告趋势核对(只读,未改动文档)
- **任务**:核对 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一.docx` çš„走势结论是否有数据支撑。用户明确先只看趋势,不纠结假期未结束时的具体数值;文中去年的图不作为判断依据。
- **当前数据边界**:只读查询确认 `holiday_passenger_flow`、`holiday_vehicle_flow`、`observation_station_flow` å‡åªè¦†ç›–到 `2026-10-06`;`2026-10-07` å°šæœªå¯¼å…¥ã€‚本轮未执行任何导入、覆盖或重导。
- **总体结论**:报告主线基本成立。10 æœˆ 1 æ—¥è‡³ 6 æ—¥è·¨åŒºåŸŸæ€»é‡ä¸ºé¦–日高开、2 æ—¥è‡³ 3 æ—¥ä¸‹é™ã€4 æ—¥è‡³ 5 æ—¥å›žåˆ°é«˜ä½ã€6 æ—¥å°å¹…回落;道路客运稳定、铁路按同日期比较唯一增长且潮汐性明显、水路同比下降且三年峰值均在 10 æœˆ 5 æ—¥å‰åŽã€è‡ªé©¾ä¸»ä½“地位和国省道日流量高于 300 ä¸‡è¾†æ¬¡ï¼Œå‡æœ‰æ•°æ®æ”¯æ’‘。
- **需要收紧的结论**:阴雨导致下降目前只能说明时间吻合,不能单独证明因果;“前期积压需求释放”证据不足;道路车流总量只有 10 æœˆ 4 æ—¥æ˜Žæ˜¾å›žå‡ï¼Œ5 æ—¥åŸºæœ¬æŒå¹³ã€6 æ—¥åˆå›žè½ï¼Œä¸èƒ½å†™æˆ 4 æ—¥è‡³ 7 æ—¥æŒç»­é«˜ä½ï¼›â€œçŸ­é€”游、周边游、乡村微度假”只能证明短途通行活跃,需文旅、路线或 OD æ•°æ®ï¼›â€œåŽ¿åŸŸåŠåŸŽä¹¡çŸ­é€”å®¢è¿éŸ§æ€§/兜底”需结构数据;2025 ä¸Ž 2026 åªèƒ½è¯´æ€»ä½“形态相近,不是日间节奏一致。
- **待 10-07 æ•°æ®é½å…¨åŽå®šç¨¿**:7 å¤©æ€»é‡ã€æ—¥å‡ã€å„方式占比、峰值谷值、返程高于出行、铁路和民航尾日、全周期同比。重要口径:2025 å¹´ä¸º 8 å¤©ã€2026 å¹´ç›®å‰ä¸º 7 å¤©ï¼ŒåŒæ¯”必须按相同日期范围比较,不能直接用 7 å¤©å¯¹ 8 å¤©ã€‚
- **边界**:未修改同事的 Word,未改动系统数据,未生成新报告或走势图。发现问题先报告,得到用户明确指示后再改。
- **Git/运行**:`HEAD = origin/main = ca0bbe1`;无已跟踪文件未提交改动。dev åŽç«¯ 8090、前端 8080 åœ¨å…³æœºå‰ä¿æŒè¿è¡Œï¼Œéšå…³æœºè‡ªåŠ¨åœæ­¢ï¼›é‡å¯åŽæŒ‰ `AGENTS.md` ç”¨ `start-dev.ps1` å¯åŠ¨ã€‚
## åŽ†å²ï¼ˆ2026-10-07 ä¸Šåˆï¼Œæ–°å¼€ä¼šè¯äº¤æŽ¥ï¼‰
### 10-07 å…³æœºå¿«ç…§ï¼ˆå½“前交接)
- **Git çŠ¶æ€**:`HEAD` ä¸Ž `origin/main` å‡ä¸º `179544a`;已跟踪文件无未提交改动。工作区仅保留按约定不入库的 `_tmp_*` ä¸´æ—¶ç›®å½•、`_tmp_pack_smoke_trend.docx` å’Œä¸€ä»½è§‚测站原始 XLS。
- **本轮更正**:我误将折线图固定 8 å¤©åˆ¤æ–­ä¸ºç¼ºé™·ï¼Œæäº¤ `8ad75d5` æ“…自改成了按年度配置截取;用户明确纠正后,已用 `179544a` æ•´ä½“回退并推送。当前代码状态等同错误修改前的 `c21fbdd`。
- **已确认口径(不得再改)**:折线图横轴固定 8 å¤©ï¼›2025 å¹´æœ‰ 10 æœˆ 8 æ—¥çš„æ•°æ®ç‚¹å’Œæ¨ªè½´ä½ç½®ï¼Œ2024、2026 å¹´è¯¥æ—¥æ— ç‚¹ä½†æ¨ªè½´ä¿ç•™ 10 æœˆ 8 æ—¥ï¼›ç´¯è®¡æ›²çº¿åªè¿žæŽ¥å®žé™…有数据的日期,缺日不延续;观测站附录查询覆盖 10 æœˆ 1 æ—¥è‡³ 10 æœˆ 8 æ—¥ã€‚
- **错误产物提醒**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(7天版-待验收).docx` æ˜¯é”™è¯¯æ”¹åŠ¨æ—¶ç”Ÿæˆçš„æ–‡ä»¶ï¼Œä¸å¾—ä½¿ç”¨ï¼›æœªç»ç”¨æˆ·æŒ‡ç¤ºä¸åˆ é™¤ã€‚
- **运行状态**:关机前后端 `8090`、前端 `8080` ä»ç›‘听,随关机自动停止;下机后按 `AGENTS.md` ç”¨ `start-dev.ps1` å¯åŠ¨ã€‚
- **数据边界**:本轮未导入、覆盖或重导任何业务数据。
- **下次接手**:先读根目录 `AGENTS.md` + æœ¬æ–‡ä»¶ï¼›è‹¥ç”¨æˆ·æä¾›äº† 10 æœˆ 7 æ—¥æ•°æ®ï¼Œå…ˆåªè¯»æ ¸éªŒï¼Œå‘现异常先报告,得到明确指示后再执行任何修改、导入或提交。
### 10-07:每日客流构成图右侧标签布局修复(验收通过)
- **问题**:`image13`「每日客流构成变化」右侧外置百分比标签在相邻日期之间重叠 4 å¤„;改为两列后引线交错,个别引线压住绿色块内数值。
- **最终规则**:只有色块内放不下的标签进入右侧外置区;外置标签按原始图例序号排序,即道路→铁路→民航→水路;最多 2 åˆ—,按“先行后列、每行从左到右”填充;1 ä¸ªæ ‡ç­¾æ•´ä½“居中,2 ä¸ªæ ‡ç­¾ä¸¤åˆ—各居中,3 ä¸ªæ ‡ç­¾ä¸‹æŽ’单标签跨两列居中,4 ä¸ªæ ‡ç­¾ä¸º 2×2 æ ¼å±…中;两列布局不画引线,颜色对应色块。
- **实现**:`HolidayTrendChartService` å°†å³ä¾§æ ‡ç­¾å¸¦ä»Ž 74 åŠ å®½åˆ° 140;`HorizontalStackCallout` è®°å½•原始 `seriesIndex` å¹¶æŒ‰å…¶æŽ’序;仅对每日客流构成图启用两列居中布局;公路出行结构仍走原单列带引线逻辑。
- **验证**:真实库只读生成 2026-10-07 èµ°åŠ¿å›¾ï¼›ä¸ŽåŽŸç‰ˆç›¸æ¯” 15 å¼ å›¾ä¸­ä»… `image13` å˜åŒ–,其余 14 å¼  SHA-256 å®Œå…¨ä¸€è‡´ï¼›å³ä¾§æ ‡ç­¾åŒº OCR é‡å ä»Ž 4 é™ä¸º 0;无引线。后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚
- **验收**:用户 2026-10-07 éªŒæ”¶ `(12).docx` é€šè¿‡ã€‚
- **后续**:10-07 æ•°æ®é½å…¨åŽï¼ŒæŒ‰ç³»ç»Ÿé…ç½®çš„假期天数(7 å¤©ï¼‰ç”Ÿæˆæœ€ç»ˆç‰ˆèµ°åŠ¿å›¾ï¼Œç”¨äºŽåˆ†æžæŠ¥å‘Šï¼›æœ¬è½®ä¸è°ƒæ•´å‡æ—¥å¤©æ•°æ¥æºã€‚
- **数据边界**:未导入、覆盖或重导任何业务数据。
### 10-07:普通国省道 2025-10-05 è°ƒæ•´å€¼æ›´æ­£ï¼ˆå·²æ ¸å¯¹ï¼‰
- **背景**:10-05 å‰”除 3 ä¸ªçŽ¯æ¯”é™å¹…è¶…è¿‡ 50% çš„站点(郑店、樊家庄、上津站)和 3 ä¸ª 10-05 ç¼ºå¤±ç«™ç‚¹ï¼ˆé¾™æ³‰ã€åŽé²äº¤è°ƒç«™ã€å¤§å…¸æ²³ï¼‰ã€‚
- **站点编号**:郑店 `S101L110420115`、樊家庄 `S120L105420702`、上津站 `G242L104420322`、龙泉 `S216L103420506`、华鲁交调站 `S223L117421024`、大典河 `S464L105422828`。
- **各日剔除合计(中小客流量,辆次)**:2026-10-04 `16,484`;2026-10-05 `4,398`;2025-10-05 `14,239`;2024-10-05 `11,907`。调整时缺失站点按 0 å¤„理。
- **系统更正**:`holiday_vehicle_adjustment` çš„ 2025-10-05 è°ƒæ•´å€¼å·²ç”± `322.7801` æ”¹ä¸º `332.7801`(更新时间 2026-10-07 08:45:52);2024-10-05 `289.5557`、2025-10-04 `337.5009`、2024-10-04 `322.5277` ä¸å˜ã€‚
- **工作簿更正**:`C:\Users\jcxiong\Downloads\2026年十一数据 (4).xlsx`(修改时间 08:46:52)中 `普通国省道!F7=332.7801`。联动结果已正确重算:`普通国省道!G7=-4.8%`;`人员流动量汇总` çš„ `E7=1460.97`、`F7=-0.3%`、`K7=1372.85`、`Q7=1290.04`、`R7=-1.0%`、`V7=-4.28%`。
- **公式更正已核实**:`普通国省道!E8=(D8-D7)/D7`、`G8=D8/F8-1`、`I8=D8/H8-1`、`E9=(D9-D8)/D8`。
- **10-06 è¾¹ç•Œï¼ˆé‡è¦ï¼‰**:工作簿目前只有铁路 `C9=96.2`、水路 `C8=5.4932`、高速公路 `C8=206.1206`;民航 `C9`、道路 `C8`、普通国省道 `D8` ä»ä¸ºç©ºã€‚å› æ­¤ `人员流动量汇总!C8=596.38264` åªæ˜¯éƒ¨åˆ†åˆè®¡ï¼Œ`D8=-59.06%` ä»¥åŠå„ `-100%/#DIV/0!` éƒ½ä¸å¯ç”¨äºŽå¿«æŠ¥ï¼›è¡¥é½ä¸‰é¡¹åŽé‡ç®—。
- **数据边界**:本轮只读核对,未导入、覆盖或重导任何业务数据。
### 10-07:Codex è¾“入卡顿排查与交接建议
- å½“前线程从 10-05 è¿žç»­ä½¿ç”¨è‡³ä»Šï¼Œrollout çº¦ `32.17 MB`,含 `3,751` æ¡ `response_item`、`3,469` æ¡ `event_msg`、`10` æ¬¡ compaction;用户已决定新开对话继续。
- å·²æŽ’除 `codex-plus-plus-manager --show-update`(未运行);重试风暴 guard å·²å®‰è£…,`dropped=859` ä¸”连续 10 ç§’不增长,当前没有持续重试风暴。
- è§‚测到 Codex ä¸»è¿›ç¨‹ç´¯è®¡çº¦ `4.34 h` CPU、渲染进程约 `1.28 h`;短时峰值主进程约 `116%` å•核、渲染约 `44%`,属于间歇高负载。建议新对话继续;若仍卡,完全退出并重启 Codex。
- **新对话接手**:先读根目录 `AGENTS.md` + æœ¬æ–‡ä»¶ã€‚当前 git `HEAD` ä¸Ž `origin/main` å‡ä¸º `9b56330`,已跟踪文件无改动;dev å‰ç«¯ `8080`、后端 `8090` æ­£åœ¨è¿è¡Œã€‚
## åŽ†å²ï¼ˆ2026-10-07 æ—©é—´å…³æœºå¿«ç…§ï¼‰
### 10-07 å…³æœºå¿«ç…§
- **当前状态**:本轮仅做关机交接,无代码、测试口径或业务数据变更。上一任务“折线图纵向间距加高 20%”已于 10-06 éªŒæ”¶ï¼Œæäº¤ä¸º `54e10ae fix(holiday): åŠ å¤§æŠ˜çº¿å›¾çºµå‘é—´è·å¹¶åŒæ­¥ Word å°ºå¯¸`。
- **Git**:`main` çš„ `HEAD` ä¸Ž `origin/main` å‡ä¸º `54e10aee11aa4a10926df02782ca07d41eeb9b98`;无未提交的已跟踪文件,工作区仅有按约定不入库的 `_tmp_*` ä¸´æ—¶ç›®å½•/文件、`_tmp_pack_smoke_trend.docx` å’Œä¸€ä»½è§‚测站原始 XLS。
- **运行状态**:关机前本机 dev å‰åŽç«¯ä»å¯è®¿é—®ï¼ˆå‰ç«¯ 8080、后端 8090 å‡è¿”回 200);随计算机关机会自动停止,重启后按 `AGENTS.md` ç”¨ `start-dev.ps1` å¯åŠ¨ã€‚
- **本地留存**:折线图纵向间距的测试报告和工作日结已写入 `docs/功能测试报告/`、`docs/工作日结/`,均按约定不入库。
- **下次接手**:先读根目录 `AGENTS.md` + æœ¬æ–‡ä»¶ï¼›ä»“库级待办仍见第七节,本轮无新增待办。
- **数据边界**:未导入、覆盖或重导任何业务数据。
## æœ€æ–°ï¼ˆ2026-10-06)
@@ -907,3 +1174,13 @@
- 2026-08-31(下班前):数据导入页重构验收通过(commit 791e70f å·²æŽ¨é€ï¼Œè¯¦è§ç¬¬ 7 èŠ‚ï¼‰ã€‚æ”¹é€ å…¨ç”¨ Python æ›¿æ¢å†™æ–‡ä»¶ï¼ˆapply_patch æœ¬æœºå¿…败);关键坑:Vue æ–°æ–¹æ³•误放 methods å¤– â†’ render TypeError、el-table å±•开行不渲染,[Vue warn] åªåœ¨ console ä¸åœ¨ pageerror;验证用 puppeteer æ‹¦æˆª /api/import/batches è¿”回 mock å¤±è´¥æ•°æ®ç›´æŽ¥éªŒæ˜Žç»†è¡¨ï¼ŒçœŸå®žæ— å¤±è´¥æ•°æ®éªŒã€Œæš‚无导入失败记录」空态。
- 2026-10-09:按春节版「人员流动量情况」表制作 **2026 åä¸€å‡æœŸ**版,输出 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一假期人员流动量情况.xlsx`(原春节模板备份在 `_tmp_flow_table\template_backup.xlsx`,未删用户文件)。结构:标题行 + ä¸‰çº§åˆå¹¶è¡¨å¤´ + `合计` è¡Œ + 10月1日~10月7日逐日行;7 ä¸ªæŒ‡æ ‡ï¼ˆå…¨ç¤¾ä¼šè·¨åŒºåŸŸ / é“è·¯ / å…¬è·¯äººå‘˜æµåŠ¨é‡ã€”ä¸‹åˆ†æµåŠ¨é‡åˆè®¡ã€é«˜é€Ÿå…¬è·¯åŠæ™®é€šå›½çœé“éžè¥ä¸šæ€§å°å®¢è½¦äººå‘˜å‡ºè¡Œé‡ã€å…¬è·¯è¥ä¸šæ€§å®¢è¿é‡ã€•/ æ°´è·¯ / æ°‘航)每个都给「数量 / åŒæ¯”2025å¹´ / åŒæ¯”2024年」,即把原表的「同比2019年」改为「同比2024年」并给每个指标补齐第二个同比列(A~V å…± 22 åˆ—)。数据取自系统 `GET /api/holiday/overview?holidayType=NATIONAL_DAY`(2024/2025/2026),并逐项对齐 10月7日源文件 `2026年十一数据10月7日.xlsx` çš„ `人员流动量汇总` ä¸Ž é“è·¯/æ°´è·¯/民航/道路 å„页,全部一致(7 å¤©åˆè®¡æ€»é‡ 10115.56 ä¸‡äººæ¬¡ã€åŒæ¯”2025 -4.46%、同比2024 +4.25%)。口径恒等式校验通过:公路人员流动量 = éžè¥ä¸šæ€§å°å®¢è½¦ + å…¬è·¯è¥ä¸šæ€§ï¼›å…¨ç¤¾ä¼š = å…¬è·¯ + é“è·¯ + æ°´è·¯ + æ°‘航。生成脚本 `_tmp_flow_table/nodework/build_xlsx.mjs`(artifact-tool,临时目录不入库)。
- 2026-10-09(补):上表按 A4 æ‰“印做适配——列宽收窄(日期列 7、数量列 7.5、同比列 6.8)+ å·¥ä½œè¡¨æ‰“印设置(A4、横向、缩放「1 é¡µå®½Ã—1 é¡µé«˜ã€ã€æ°´å¹³å±…中、页边距左右 0.35″/上下 0.4″)。用 Excel å®žé™…导出 PDF å®žæµ‹ **正好 1 é¡µ**(A4 æ¨ªå‘ 841.92×595.32 pt),数值/百分比无 `####`、无截断。注:artifact-tool æ— æ‰“印设置 API,pageSetup/printOptions/pageMargins æ˜¯å¯¼å‡ºåŽæ³¨å…¥ XLSX çš„ `xl/worksheets/sheet1.xml`(脚本 `_tmp_flow_table/print_setup.py`,临时不入库)。
- 2026-10-09(续):按反馈改出十一人员流动量表第 2 ç‰ˆï¼Œè¾“出 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一假期人员流动量情况2.xlsx`(单表 A1:S13)。改动:①表头顺序调整为 å…¨ç¤¾ä¼š / å…¬è·¯ / é“è·¯ / æ°´è·¯ / æ°‘航(公路提前);②去掉「公路人员流动量·流动量合计」子列,只留 é«˜é€Ÿå…¬è·¯åŠæ™®é€šå›½çœé“非营业性小客车人员出行量 ä¸Ž å…¬è·¯è¥ä¸šæ€§å®¢è¿é‡ï¼›â‘¢æ–°å¢žã€Œæ—¥å‡ã€è¡Œï¼ˆè¡Œåºï¼šåˆè®¡ã€æ—¥å‡ã€10月1日~7日);④「合计」行同比2025 æ”¹ä¸º 2026å¹´7天合计 Ã· 2025å¹´8天合计(2025年国庆假期为10月1-8日共8天),同比2024 ä¸å˜ï¼›æ—¥å‡çš„同比按各年天数折算(2026、2024为7天,2025为8天)。数值:合计同比2025 å…¨ç¤¾ä¼š -14.37%、非营业性小客车 -14.82%、公路营业性 -17.45%、铁路 -3.33%、水路 -23.76%、民航 -17.12%;日均同比2025 å…¨ç¤¾ä¼š -2.13%。标题沿用用户版本「2026年“十一”假期全社会跨区域人员流动量情况」。仍为 A4 æ¨ªå‘ 1 é¡µï¼ˆå¯¼å‡º PDF å®žæµ‹ï¼‰ã€‚数据源同前(系统 overview + 10月7日源文件核对;2025 ç¬¬8日取系统 dayIndex=8)。
- 2026-10-12(交接快照,用户准备新开对话):电脑重启后已用 `start-dev.ps1` æ‹‰èµ·å‰åŽç«¯ï¼Œè„šæœ¬è¾“å‡ºåŽç«¯ OK、前端 OK;前端访问 http://localhost:8080,后端 8090,日志分别见 `backend.log` / `traffic-audit-web/frontend.log`。当前 git åˆ†æ”¯ `main`,无已跟踪文件改动;工作区有 10-09 ç”Ÿæˆçš„æœªå…¥åº“临时目录/文件(`_tmp_doc_compare/`、`_tmp_flow_table/`、`_tmp_holiday_verify/`、`_tmp_rail_label/`、`_tmp_pack_smoke_trend.docx`)及用户数据 `docs/专项统计/重要节假日/观测站分日调查数据 (2026å¹´10月1日).xls`,不要删除或误提交。本轮未改代码、未跑测试、未提交。
- 2026-10-12(业务疑点核查,只读):企业上报的「厚坪-远安」「后坪到远安」两个地名已初核。可确认「后坪」在兴山县水月寺镇后坪一带(后坪林场、后坪磷矿附近),后坪磷矿公开资料还涉及榛子乡石柱村;「远安」指宜昌市远安县;「厚坪」在宜昌/兴山公开地名中未查到有效地点,判断大概率是「后坪」的错字或简称(另有「兴山厚坪物流有限公司」注册在峡口镇泗湘溪村,但公司名不能等同地名)。按水月寺镇后坪到远安县城的公开道路测算,单程约 106.7 km;企业均填 201 km,不能直接认定错误,需企业补充具体装货点、卸货点、单程/往返及绕行路线后再核。系统 `h2032_enterprise_monthly` çš„审核说明中未留下这两处地点原文。
docs/»ã×Ü´ó±íÉú³ÉÊý¾ÝÑéÖ¤·½·¨_2026-10-12.md
New file
@@ -0,0 +1,89 @@
# æ±‡æ€»å¤§è¡¨ã€Œç”Ÿæˆæ•°æ®æ˜¯å¦æ­£ç¡®ã€éªŒè¯æ–¹æ³•(2026-10-12)
> ç”¨é€”:回答"程序生成的数据对不对"。
> **一句话原则**:每一段都要有一个**独立于该段的参照**——两边同源,就验不出东西。
> æœ¬æ–¹æ³•是 2026-10-12 ç”¨ä¸¤ç»„真实数据跑出来并修正过的(见第七节)。
## ä¸€ã€å…ˆæŠŠé—®é¢˜æ‹†å¼€ï¼ˆå¦åˆ™"正确"没有定义)
"程序生成的数据是否正确"实际是 4 ä¸ªä¸åŒçš„问题,参照和责任人都不同:
| # | é—®é¢˜ | å‚照(必须独立于被验对象) | è°è´Ÿè´£ | ç¨‹åºèƒ½è‡ªè¯å— |
|---|---|---|---|---|
| 1 | æºæ•°æ®å¯¹ä¸å¯¹ | æºæ–‡ä»¶ / çœé‡Œä¸‹å‘表模板 | æ•°æ®ã€ä¸šåŠ¡ | ä¸èƒ½ |
| 2 | ç®—法、口径对不对 | æ‰‹å†™ SQL å¤ç®—(不看母版、不看定稿) | ç¨‹åº | èƒ½ |
| 3 | å†™å›žã€ç‰ˆå¼å¯¹ä¸å¯¹ | ä¸Šä¸€æœŸæ¯ç‰ˆï¼ˆåŒæºå¯¹æ¯”,要求"不该动的格未变") | ç¨‹åº | èƒ½ |
| 4 | ä¸Žäººå·¥å®šç¨¿ä¸€è‡´å— | æ‰‹å·¥å®šç¨¿ï¼ˆå¿…须记版本 + sha256) | åŒæ–¹å¤æ ¸ | éƒ¨åˆ† |
所以单问"数据对不对"没有答案;先问"**哪一段**"。
## äºŒã€äº”段链条与各自的验证手段(含失败模式)
### L0 æºæ–‡ä»¶ â†’ åº“
- **参照**:源文件本身。
- **手段**:行数(`import_batch` çš„ total/success/fail vs è¡¨å†… `COUNT(*)`);逐行抽查(每市州 1~2 æ¡ + è¾¹ç•Œï¼šæœ€å¤§ã€æœ€å°ã€ç¼ºæŠ¥å¸‚州);单位核对(吨 vs ä¸‡å¨ï¼‰ï¼›åˆè®¡å‹¾ç¨½ï¼ˆÎ£17 å¸‚å·ž = æºè¡¨"湖北省"行)。
- **典型失败**:某月漏导;列错位;单位差 10000;重复导入叠加("某月逐市州值与相邻月完全相同"是重复导入的指纹)。
- **工具**:`_compare_freight.py` é‚£ç±»"文件 vs åº“"脚本;`import_batch`。
### L1 åº“ â†’ å£å¾„值(**最值得投入的一层**)
- **参照**:把口径写成 SQL/脚本**自己算一遍**,不依赖母版、不依赖定稿。
- **手段**:① å¼‚常体检 `GET /api/report/summaryWorkbook/freightCheck?period=YYYY-MM`(倒减为负、①漏报、Σ市州≠省行、环比异常、累计勾稽);② **缺数清单**(目标期该有的月份/市州是否齐全);③ é”šç‚¹å¤ç®—(全省 + æŠ½ 2 ä¸ªå¸‚州;覆盖 è§„上/规下/合计/周转量/中口径四维/城市内)。
- **典型失败**:规下倒减为负或虚高;全省行与 Î£å¸‚州不闭合;来源表缺期。
- **注意**:**体检通过 â‰  æ•°æ®é½å…¨**。2026-08 ä½“检 0 å‘½ä¸­ï¼Œä½† 9 æœˆç”Ÿæˆä»¶çš„ 8 æœˆåˆ—仍空、9 æœˆå‘¨è½¬é‡ä»ä¸º 0 â†’ å¼‚常清单与缺数清单必须**分开输出**。
### L2 å£å¾„值 â†’ å•元格(写回层)
- **参照**:上一期母版 / ä¸Šä¸€æœŸç”Ÿæˆä»¶ã€‚
- **手段**:逐格 diff,**值模式与公式模式各跑一遍**;目标月列是否真的写进去了;上期及以前列是否 0 å˜åŒ–。
- **典型失败**:当月列没写(本次实证:货运、网约车只回填"目标月",母版缺上月就补不上);上期列被重算冲掉;公式断链。
### L3 å•元格 â†’ æ•´æœ¬ï¼ˆå‡ºè¡¨å±‚)
- **参照**:手工定稿(**必须记录版本 + sha256**,只在定稿未滞后时有效)。
- **手段**:`python docs\工具\汇总大表差异比对.py <生成件> <手工定稿> <输出前缀>`;只判读 **实质数值差异 / éœ€å¤„理(非数值文本)** ä¸¤ç±»ï¼›å·®å¼‚按预登记白名单分类。
- **典型失败**:把"定稿滞后"当成"程序回归"(本次:定稿是 9/13 ç‰ˆï¼Œç¼º 09-28 ä¹‹åŽçš„æ‰‹å·¥æ”¹åЍ â†’ å¤šå‡º 364 æ¡æ‰‹å·¥ä¾§é”™è¯¯å€¼ + 10 æ¡å·²ç¡®è®¤é¡¹ï¼‰ã€‚
### L4 è·¨é¡µåˆæˆé¡µå‹¾ç¨½ï¼ˆ**最容易被漏**)
- **参照**:勾稽式本身。
- **手段**:`城市汇总`、`城市分市州`、`中口径明细`、`道路运输周转量` æ¯é¡µç»™ä¸€æ¡å‹¾ç¨½å¼è‡ªæ£€ï¼Œä¾‹ï¼šé“路运输周转量 `B = G(货运页规上+规下周转量) + L/2(城市客运量) + Q/10(公路旅客周转量)`。
- **典型失败**:源页都对、合成页引用错列。本次 2025-03/04 ç½‘约车拆分的影响面,就是靠"排名页 vs æ˜Žç»†é¡µä¸¤æ¡é“¾è·¯å¯¹ä¸ä¸Š"暴露的。
## ä¸‰ã€ååŒæºç›²åŒºï¼ˆ**最重要的一条**)
今日实证:**"生成件 vs æ‰‹å·¥å®šç¨¿"这一层,在母版与定稿同源时,源数据本身的错永远查不出来。**
- 2025-11 è´§è¿å®œæ˜Œå‘¨è½¬é‡å·® 34.30(2 æ ¼ï¼‰ã€ç­çº¿åŒ…车个体周转量差(5 æ ¼ï¼‰ï¼Œåœ¨"生成件 vs å®šç¨¿"里是 **0 æ¡**,只有"**库现算 vs æ¯ç‰ˆç¼“å­˜**"才暴露。
- æ‰€ä»¥ï¼š**必须专门做一次"库内值 vs æ¯ç‰ˆç¼“存值"的全量 diff**——这也正是 2026-10-09 äº¤æŽ¥åˆ—为"需先做"的待办,是"能不能切读库"的唯一前置条件。
- æŽªè¾žè¦åˆ†æ¸…:**出表口径**上"库不能替代母版缓存"(F8/Q1);**验证方法**上必须专门比一次。两者不矛盾。
## å››ã€æ“ä½œé¡ºåºï¼ˆç…§æŠ„即可)
1. **冻结**:记 æ¯ç‰ˆ sha256、关键源表 `max(report_period)` ä¸Žè¡Œæ•°ã€åŽç«¯è¿›ç¨‹ PID + å¯åŠ¨æ—¶é—´ã€‚
   ï¼ˆ"判断是否换了新进程"必须看 `netstat -ano` çš„真实监听 PID + æ—¥å¿—启动行,不能只信启停脚本输出。)
2. å¼‚常体检 + ç¼ºæ•°æ¸…单(**分两份输出**)。
3. å£å¾„复算锚点(全省 + 2 å¸‚州;至少覆盖 è§„上/规下/合计/周转量/中口径/城市内)。
4. ç‰ˆå¼è‡ªæ£€ï¼šå½“月列已写;上期及以前列 0 å˜åŒ–ï¼›`#REF!` æ•°ä¸å¢žï¼›æ ‡é¢˜æœŸæ¬¡ = ç›®æ ‡æœŸï¼›ç´¯è®¡ = 1..M。
5. ä¸Žå®šç¨¿é€æ ¼ diff(只看两类);差异按白名单分类。
6. åˆæˆé¡µå‹¾ç¨½ï¼ˆL4)。
7. å†™ç»“论:通过项 / æœªé€šè¿‡é¡¹ / **未覆盖项**(明确说清哪几层没验)。
## äº”、通过标准(建议写死)
- **L0**:行数与勾稽一致(0 å·®å¼‚)。
- **L1**:体检 0 å‘½ä¸­ + ç¼ºæ•°æ¸…单为空(或缺数已确认为业务事实)+ é”šç‚¹å¤ç®— 0 å·®å¼‚。
- **L2**:上期及以前列 0 å˜åŒ–;当月列写入格数 = é¢„期。
- **L3**:定稿 diff çš„"实质数值差异"只含白名单项。
- **L4**:勾稽式 0 å·®å¼‚。
- **任一层未覆盖 â†’ ç»“论只能写"未覆盖",不得写"已验证"。**
## å…­ã€ä¸‰æ¡çºªå¾‹
1. **独立参照**:同源的两种产物互比等于没验。
2. **一次只换一个变量**:换代码 / æ¢æ•°æ® / æ¢æ¯ç‰ˆåˆ†å¼€éªŒï¼Œå¦åˆ™å·®å¼‚无法归因。
3. **差异先分类再结论**:结构/格式/浮点尾差只计数;只有"实质数值差异 + éœ€å¤„理"进清单。白名单(`USER_EDITS`、已确认项)预登记,不要每次重新判读。
## ä¸ƒã€æœ¬æ–¹æ³•的实证(2026-10-12)
1. **2026-08 ç”Ÿæˆä»¶ vs 9/13 æ‰‹å·¥å®šç¨¿**:1020 æ¡æ˜Žç»†é‡Œåªæœ‰ 11 æ¡è¦å…³æ³¨ï¼ˆ10 æ¡è´§è¿ 2022 ç´¯è®¡åŒæ¯”【业务已确认软件对】+ 1 æ¡æ‰‹å·¥ä¾§è„å­—符),其余全是约定差异(结构/草稿列/文本型数字 354/取整尾差/手工侧错误值 364)→ **算法层通过**。
2. **库内 2025 vs æ¯ç‰ˆ 2025**:公交、出租、轨道轮渡 **100% ä¸€è‡´**;货运、班线包车除个别格一致;查出 3 å¤„需业务确认:
   - ç½‘约车 2025-03/04「其中城市内」口径 60 æ ¼ï¼ˆå¦æœ‰ 12 æœˆ 12 æ ¼å°å·®ï¼‰â€”—2026-09-21 å·²å®šä½ï¼ˆæ¯ç‰ˆç”¨äº†å¦ä¸€ä»½æ—§çš„出租城市内输入);
   - è´§è¿ å®œæ˜Œ 2025-11 å‘¨è½¬é‡ 2 æ ¼ï¼ˆå·® 34.30);
   - ç­çº¿åŒ…车 2025-11 ä¸ªä½“旅客周转量 5 æ ¼ã€‚
   åŽä¸¤å¤„为**源数据层新发现**,此前无任何记录(因为历史核对都在同源的"生成件 vs å®šç¨¿"层)。
docs/Éú³É»ã×Ü´ó±í/2026Äê8ÔµÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/2026Äê9ÔµÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/¹éµµ/2026Äê7ÔµÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/¹éµµ/2026Äê8ÔµÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/¹éµµ/2026Äê8ÔµÀ·ÔËÊäÁ¿»ã×ܱí_°æÊ½Ô­ÐÍ.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/¹éµµ/2026Äê9ÔµÀ·ÔËÊäÁ¿»ã×ܱí_ÊÔ×ö_20260929.xlsx
Binary files differ
docs/Éú³É»ã×Ü´ó±í/¹éµµ/Éú³É_µÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java
@@ -1808,6 +1808,43 @@
        }
    }
    /** åªæ ¡éªŒã€Œå¿…需列按名存在」(不校验列位):用于兼容同事版模板的列位差异 */
    private void requireHeaderColsByName(Sheet sheet, String typeLabel, String templateHint, String[] keywords) {
        Row header = sheet == null ? null : sheet.getRow(0);
        if (header == null) {
            throw new RuntimeException("所选数据类型【" + typeLabel + "】文件表头缺失,请使用系统模板《" + templateHint + "》整理后再导入");
        }
        List<String> missing = new ArrayList<>();
        for (String keyword : keywords) {
            if (findColByKeywords(header, keyword) < 0) missing.add(keyword);
        }
        if (!missing.isEmpty()) {
            throw new RuntimeException("所选数据类型【" + typeLabel + "】与文件内容不符:表头缺少必需列【"
                + String.join("】【", missing) + "】,请确认是否选错了数据类型(模板:" + templateHint + ")");
        }
    }
    /** è¡¨å¤´å â†’ åˆ—号(首次出现);未命中时回退 fallback,保持对旧模板兼容 */
    private int colByName(Row header, int fallback, String name) {
        return colByName(header, fallback, name, 1);
    }
    /** è¡¨å¤´å â†’ åˆ—号(第 occurrence æ¬¡å‡ºçŽ°ï¼‰ï¼›æœªå‘½ä¸­æ—¶å›žé€€ fallback */
    private int colByName(Row header, int fallback, String name, int occurrence) {
        if (header == null) return fallback;
        int hit = 0;
        for (int c = 0; c <= header.getLastCellNum(); c++) {
            Cell cell = header.getCell(c);
            if (cell == null) continue;
            String v = FORMATTER.formatCellValue(cell).trim();
            if (v.contains(name)) {
                hit++;
                if (hit == occurrence) return c;
            }
        }
        return fallback;
    }
    /** æŒ‰è¡¨å¤´å…³é”®å­—定位列号:从第 1 åˆ—向右扫,命中任一关键字即返回该列;未命中返回 -1 */
    private int findColByKeywords(Row header, String... keywords) {
        if (header == null) return -1;
@@ -3071,59 +3108,59 @@
        List<CityBusRow> rows = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderLayout(sheet, "城市公交", "模板_交企统U203表_城市公共交通月度运营情况(2026å¹´7月).xls",
                CITY_BUS_HEADER_COLS, CITY_BUS_HEADER_KEYWORDS);
            Row hdr = sheet.getRow(0);
            requireHeaderColsByName(sheet, "城市公交", "模板_交企统U203表_城市公共交通月度运营情况(2026å¹´7月).xls", CITY_BUS_HEADER_KEYWORDS);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String name = getString(row, 2);
                String name = getString(row, colByName(hdr, 2, "企业名称"));
                if (name == null || name.trim().isEmpty()) continue;
                CityBusMonthly item = new CityBusMonthly();
                item.setReportPeriod(period);
                item.setRegionCode(getString(row, 0));
                item.setCity(RegionUtil.cityByCode(getString(row, 0)));
                item.setEnterpriseCode(getString(row, 1));
                item.setRegionCode(getString(row, colByName(hdr, 0, "所属地区")));
                item.setCity(RegionUtil.cityByCode(getString(row, colByName(hdr, 0, "所属地区"))));
                item.setEnterpriseCode(getString(row, colByName(hdr, 1, "企业代码")));
                item.setEnterpriseName(name.trim());
                item.setOpVehicles(getDecimal(row, 5));
                item.setStopTotal(getDecimal(row, 6));
                item.setStopCity(getDecimal(row, 7));
                item.setPassengerVolume(getDecimal(row, 8));
                item.setPassengerCity(getDecimal(row, 9));
                item.setTurnover(getDecimal(row, 10));
                item.setTurnoverCity(getDecimal(row, 11));
                item.setRailPassengerVolume(getDecimal(row, 13));
                item.setRailTurnover(getDecimal(row, 14));
                item.setFerryPassengerVolume(getDecimal(row, 17));
                item.setFerryTurnover(getDecimal(row, 18));
                item.setAvgDistance(getDecimal(row, 19));
                item.setAvgDistanceCity(getDecimal(row, 20));
                item.setContactPerson(getString(row, 24));
                item.setContactPhone(getString(row, 25));
                item.setReportDate(getDateString(row, 26));
                item.setRateOpVehiclesMom(getDecimal(row, 29));
                item.setRatePassengerMom(getDecimal(row, 30));
                item.setRatePassengerCityMom(getDecimal(row, 31));
                item.setRateChengxiangMom(getDecimal(row, 32));
                item.setRateChengxiangTurnoverMom(getDecimal(row, 33));
                item.setAvgDistanceMomChange(getDecimal(row, 34));
                item.setAvgDistanceCityMomChange(getDecimal(row, 35));
                item.setDailyPassengerPerVehicle(getDecimal(row, 36));
                item.setRateOpVehiclesYoy(getDecimal(row, 39));
                item.setRatePassengerYoy(getDecimal(row, 40));
                item.setRatePassengerCityYoy(getDecimal(row, 41));
                item.setRateChengxiangYoy(getDecimal(row, 42));
                item.setRateChengxiangTurnoverYoy(getDecimal(row, 43));
                item.setStopCityRatio(getDecimal(row, 44));
                item.setPassengerCityRatio(getDecimal(row, 45));
                item.setChengxiangRatio(getDecimal(row, 46));
                item.setRateTurnoverYoy(getDecimal(row, 47));
                item.setRateTurnoverMom(getDecimal(row, 48));
                item.setPassengerChengxiang(getDecimal(row, 49));
                item.setTurnoverChengxiang(getDecimal(row, 50));
                item.setAvgDistanceChengxiang(getDecimal(row, 51));
                item.setVerifyExplanation(getString(row, 65));
                item.setReportNote(getString(row, 66));
                item.setModifyRecord(getString(row, 67));
                item.setOpVehicles(getDecimal(row, colByName(hdr, 5, "公共汽电车_运营车数")));
                item.setStopTotal(getDecimal(row, colByName(hdr, 6, "运营站点总个数")));
                item.setStopCity(getDecimal(row, colByName(hdr, 7, "其中:城市内")));
                item.setPassengerVolume(getDecimal(row, colByName(hdr, 8, "公共汽电车_客运量")));
                item.setPassengerCity(getDecimal(row, colByName(hdr, 9, "其中:城市内", 2)));
                item.setTurnover(getDecimal(row, colByName(hdr, 10, "新增公共汽电车旅客周转量")));
                item.setTurnoverCity(getDecimal(row, colByName(hdr, 11, "新增公共汽电车旅客周转量_其中城市内")));
                item.setRailPassengerVolume(getDecimal(row, colByName(hdr, 13, "轨道交通_客运量")));
                item.setRailTurnover(getDecimal(row, colByName(hdr, 14, "轨道交通_旅客周转量")));
                item.setFerryPassengerVolume(getDecimal(row, colByName(hdr, 17, "城市客运轮渡_客运量")));
                item.setFerryTurnover(getDecimal(row, colByName(hdr, 18, "城市客运轮渡旅客周转量")));
                item.setAvgDistance(getDecimal(row, colByName(hdr, 19, "公共汽电车平均运距")));
                item.setAvgDistanceCity(getDecimal(row, colByName(hdr, 20, "公共汽电车城市内平均运距")));
                item.setContactPerson(getString(row, colByName(hdr, 24, "填表人")));
                item.setContactPhone(getString(row, colByName(hdr, 25, "联系电话")));
                item.setReportDate(getDateString(row, colByName(hdr, 26, "报出日期")));
                item.setRateOpVehiclesMom(getDecimal(row, colByName(hdr, 29, "公共汽电车_运营车数环比增速")));
                item.setRatePassengerMom(getDecimal(row, colByName(hdr, 30, "公共汽电车_客运量环比增速")));
                item.setRatePassengerCityMom(getDecimal(row, colByName(hdr, 31, "城市内客运量环比")));
                item.setRateChengxiangMom(getDecimal(row, colByName(hdr, 32, "城际城乡客运量环比增速")));
                item.setRateChengxiangTurnoverMom(getDecimal(row, colByName(hdr, 33, "城际城乡旅客周转量环比增速")));
                item.setAvgDistanceMomChange(getDecimal(row, colByName(hdr, 34, "平均运距环比增减(公里)")));
                item.setAvgDistanceCityMomChange(getDecimal(row, colByName(hdr, 35, "平均运距_城市内环比增减(公里)")));
                item.setDailyPassengerPerVehicle(getDecimal(row, colByName(hdr, 36, "公共汽电车_单车日均客运量")));
                item.setRateOpVehiclesYoy(getDecimal(row, colByName(hdr, 39, "公共汽电车_运营车数同比增速")));
                item.setRatePassengerYoy(getDecimal(row, colByName(hdr, 40, "公共汽电车_客运量同比增速")));
                item.setRatePassengerCityYoy(getDecimal(row, colByName(hdr, 41, "客运量_城市内同比增速")));
                item.setRateChengxiangYoy(getDecimal(row, colByName(hdr, 42, "城际城乡客运量同比增速")));
                item.setRateChengxiangTurnoverYoy(getDecimal(row, colByName(hdr, 43, "城际城乡旅客周转量同比增速")));
                item.setStopCityRatio(getDecimal(row, colByName(hdr, 44, "城市内运营站点占比")));
                item.setPassengerCityRatio(getDecimal(row, colByName(hdr, 45, "城市内客运量占比")));
                item.setChengxiangRatio(getDecimal(row, colByName(hdr, 46, "城际城乡客运量占比")));
                item.setRateTurnoverYoy(getDecimal(row, colByName(hdr, 47, "旅客周转量同比增速")));
                item.setRateTurnoverMom(getDecimal(row, colByName(hdr, 48, "旅客周转量环比增速")));
                item.setPassengerChengxiang(getDecimal(row, colByName(hdr, 49, "城际城乡客运量")));
                item.setTurnoverChengxiang(getDecimal(row, colByName(hdr, 50, "城际城乡旅客周转量")));
                item.setAvgDistanceChengxiang(getDecimal(row, colByName(hdr, 51, "城际城乡平均运距")));
                item.setVerifyExplanation(getString(row, colByName(hdr, 65, "核实性原因解释")));
                item.setReportNote(getString(row, colByName(hdr, 66, "上报说明")));
                item.setModifyRecord(getString(row, colByName(hdr, 67, "修改记录")));
                rows.add(new CityBusRow(r + 1, item));
            }
        } catch (Exception e) {
@@ -3186,52 +3223,52 @@
        List<TaxiRow> rows = new ArrayList<>();
        try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
            Sheet sheet = wb.getSheetAt(0);
            requireHeaderLayout(sheet, "巡游出租", "模板_交行统U402表-巡游出租汽车运营服务情况月报(2026).xls",
                CITY_TAXI_HEADER_COLS, CITY_TAXI_HEADER_KEYWORDS);
            Row hdr = sheet.getRow(0);
            requireHeaderColsByName(sheet, "巡游出租", "模板_交行统U402表-巡游出租汽车运营服务情况月报(2026).xls", CITY_TAXI_HEADER_KEYWORDS);
            for (int r = 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String name = getString(row, 2);
                String name = getString(row, colByName(hdr, 2, "企业名称"));
                if (name == null || name.trim().isEmpty()) continue;
                CityTaxiMonthly item = new CityTaxiMonthly();
                item.setReportPeriod(period);
                item.setRegionCode(getString(row, 0));
                item.setCity(cityOf(getString(row, 0), name));
                item.setTripTotal(getDecimal(row, 4));
                item.setTripCity(getDecimal(row, 5));
                item.setPassengerVolume(getDecimal(row, 6));
                item.setPassengerCity(getDecimal(row, 7));
                item.setTurnover(getDecimal(row, 8));
                item.setTurnoverCity(getDecimal(row, 9));
                item.setOpVehicles(getDecimal(row, 10));
                item.setAvgDistance(getDecimal(row, 11));
                item.setAvgDistanceCity(getDecimal(row, 12));
                item.setContactPerson(getString(row, 15));
                item.setContactPhone(getString(row, 16));
                item.setReportDate(getDateString(row, 17));
                item.setDailyTripsPerVehicle(getDecimal(row, 18));
                item.setDailyPassengerPerVehicle(getDecimal(row, 19));
                item.setPassengersPerTrip(getDecimal(row, 20));
                item.setRateOpVehiclesMom(getDecimal(row, 21));
                item.setRateOpVehiclesYoy(getDecimal(row, 22));
                item.setRateTripMom(getDecimal(row, 23));
                item.setRateTripYoy(getDecimal(row, 24));
                item.setRatePassengerMom(getDecimal(row, 25));
                item.setRatePassengerYoy(getDecimal(row, 26));
                item.setAvgDistanceMomChange(getDecimal(row, 27));
                item.setPassengersPerTripCity(getDecimal(row, 28));
                item.setRateTripCityMom(getDecimal(row, 29));
                item.setRatePassengerCityMom(getDecimal(row, 30));
                item.setAvgDistanceCityMomChange(getDecimal(row, 31));
                item.setTripCityRatio(getDecimal(row, 32));
                item.setPassengerCityRatio(getDecimal(row, 33));
                item.setPassengersPerTripChengxiang(getDecimal(row, 34));
                item.setRateTripChengxiangMom(getDecimal(row, 35));
                item.setRatePassengerChengxiangMom(getDecimal(row, 36));
                item.setAvgDistanceChengxiang(getDecimal(row, 37));
                item.setVerifyExplanation(getString(row, 38));
                item.setReportNote(getString(row, 39));
                item.setModifyRecord(getString(row, 40));
                item.setRegionCode(getString(row, colByName(hdr, 0, "所属地区")));
                item.setCity(cityOf(getString(row, colByName(hdr, 0, "所属地区")), name));
                item.setTripTotal(getDecimal(row, colByName(hdr, 4, "载客车次总数")));
                item.setTripCity(getDecimal(row, colByName(hdr, 5, "载客车次总数_城市内")));
                item.setPassengerVolume(getDecimal(row, colByName(hdr, 6, "客运量")));
                item.setPassengerCity(getDecimal(row, colByName(hdr, 7, "客运量_其中城市内")));
                item.setTurnover(getDecimal(row, colByName(hdr, 8, "旅客周转量")));
                item.setTurnoverCity(getDecimal(row, colByName(hdr, 9, "旅客周转量_城市内")));
                item.setOpVehicles(getDecimal(row, colByName(hdr, 10, "运营车辆数")));
                item.setAvgDistance(getDecimal(row, colByName(hdr, 11, "巡游出租汽车平均运距")));
                item.setAvgDistanceCity(getDecimal(row, colByName(hdr, 12, "城市内平均运距")));
                item.setContactPerson(getString(row, colByName(hdr, 15, "填表人")));
                item.setContactPhone(getString(row, colByName(hdr, 16, "联系电话")));
                item.setReportDate(getDateString(row, colByName(hdr, 17, "报出日期")));
                item.setDailyTripsPerVehicle(getDecimal(row, colByName(hdr, 18, "单车日均载客车次")));
                item.setDailyPassengerPerVehicle(getDecimal(row, colByName(hdr, 19, "单车日均客运量")));
                item.setPassengersPerTrip(getDecimal(row, colByName(hdr, 20, "单车次载客人数")));
                item.setRateOpVehiclesMom(getDecimal(row, colByName(hdr, 21, "运营车数环比增速")));
                item.setRateOpVehiclesYoy(getDecimal(row, colByName(hdr, 22, "运营车数同比增速")));
                item.setRateTripMom(getDecimal(row, colByName(hdr, 23, "载客车次总数环比增速")));
                item.setRateTripYoy(getDecimal(row, colByName(hdr, 24, "载客车次同比增速")));
                item.setRatePassengerMom(getDecimal(row, colByName(hdr, 25, "客运量环比增速")));
                item.setRatePassengerYoy(getDecimal(row, colByName(hdr, 26, "客运量同比增速")));
                item.setAvgDistanceMomChange(getDecimal(row, colByName(hdr, 27, "平均运距环比增减")));
                item.setPassengersPerTripCity(getDecimal(row, colByName(hdr, 28, "单车次载客人数_城市内")));
                item.setRateTripCityMom(getDecimal(row, colByName(hdr, 29, "载客车次总数环比增速_城市内")));
                item.setRatePassengerCityMom(getDecimal(row, colByName(hdr, 30, "客运量环比增速_城市内")));
                item.setAvgDistanceCityMomChange(getDecimal(row, colByName(hdr, 31, "平均运距_城市内环比增减")));
                item.setTripCityRatio(getDecimal(row, colByName(hdr, 32, "载客车次总数_城市内占比")));
                item.setPassengerCityRatio(getDecimal(row, colByName(hdr, 33, "客运量_城市内占比")));
                item.setPassengersPerTripChengxiang(getDecimal(row, colByName(hdr, 34, "单车次载客人数_城际城乡")));
                item.setRateTripChengxiangMom(getDecimal(row, colByName(hdr, 35, "载客车次总数_城际城乡环比增速")));
                item.setRatePassengerChengxiangMom(getDecimal(row, colByName(hdr, 36, "客运量_城际城乡环比增速")));
                item.setAvgDistanceChengxiang(getDecimal(row, colByName(hdr, 37, "平均运距_城际城乡")));
                item.setVerifyExplanation(getString(row, colByName(hdr, 38, "核实性原因解释")));
                item.setReportNote(getString(row, colByName(hdr, 39, "上报说明")));
                item.setModifyRecord(getString(row, colByName(hdr, 40, "修改记录")));
                rows.add(new TaxiRow(r + 1, item));
            }
        } catch (Exception e) {
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -74,7 +74,7 @@
    private static final Color AXIS_COLOR = Color.BLACK;
    private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102);
    private static final double UNIT_LABEL_BAND = 58.0;
    private static final double RIGHT_LABEL_BAND = 74.0;
    private static final double RIGHT_LABEL_BAND = 140.0;
    private static final double ITEM_LABEL_GAP = 10.0;
    private static final float HALO_OUTLINE_WIDTH = 4.0f;
    private static final int PERCENT_ITEM_LABEL_DECIMALS = 1;
@@ -103,6 +103,23 @@
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, int width, int height) {
        return lineChart(yAxisLabel, series, percentAxis, valueDecimals, DAY_LABELS, width, height);
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, List<String> dayLabels, int width, int height) {
        return lineChart(yAxisLabel, series, percentAxis, valueDecimals, dayLabels, width, height,
                Collections.emptyList());
    }
    /**
     * Renders a line chart and pins the listed labels to a fixed anchor. The overrides are
     * opt-in: a chart that passes an empty list keeps the automatic placement unchanged.
     */
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, List<String> dayLabels, int width, int height,
                            List<LineLabelOverride> labelOverrides) {
        List<String> axisLabels = (dayLabels == null || dayLabels.isEmpty()) ? DAY_LABELS : dayLabels;
        XYSeriesCollection dataset = new XYSeriesCollection();
        for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
            ChartSeries item = series.get(seriesIndex);
@@ -132,7 +149,7 @@
        plot.setRangeGridlinesVisible(false);
        plot.setDomainTickBandPaint(null);
        plot.setRangeTickBandPaint(null);
        SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
        SymbolAxis domainAxis = new SymbolAxis(null, axisLabels.toArray(new String[0]));
        domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
        domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
        domainAxis.setLowerMargin(0.04);
@@ -175,7 +192,7 @@
        }
        plot.setRenderer(renderer);
        return render(chart, width, scaleLineChartHeight(height),
                yAxisLabel, true, percentAxis, valueDecimals);
                yAxisLabel, true, percentAxis, valueDecimals, labelOverrides);
    }
    public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
@@ -455,6 +472,13 @@
    private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
                         boolean axisChart, boolean percentItemLabels, int valueDecimals) {
        return render(chart, width, height, unitLabel, axisChart, percentItemLabels,
                valueDecimals, Collections.emptyList());
    }
    private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
                         boolean axisChart, boolean percentItemLabels, int valueDecimals,
                         List<LineLabelOverride> labelOverrides) {
        try {
            reserveUnitLabelBand(chart, axisChart, unitLabel);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -474,7 +498,8 @@
                    drawDateLabel(graphics, info.getPlotInfo().getDataArea());
                }
                drawXYItemLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals,
                        labelOverrides);
                drawStackedBarLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels);
            } finally {
@@ -615,7 +640,7 @@
                        occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(),
                                width, metrics.getHeight()));
                    } else {
                        horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX),
                        horizontalCallouts.add(new HorizontalStackCallout(row, Math.min(startX, endX),
                                Math.max(startX, endX), centerY, text, seriesColor(row)));
                    }
                } else {
@@ -645,7 +670,8 @@
            }
        }
        if (horizontal) {
            drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied);
            drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied,
                    rows >= 3);
        } else {
            for (int column = 0; column < columns; column++) {
                drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns,
@@ -657,7 +683,8 @@
    private void drawHorizontalStackCallouts(Graphics2D graphics,
                                              List<HorizontalStackCallout> items,
                                              Rectangle2D dataArea, FontMetrics metrics,
                                              List<Rectangle2D> occupied) {
                                              List<Rectangle2D> occupied,
                                              boolean packIntoColumns) {
        if (items.isEmpty()) return;
        Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>();
        for (HorizontalStackCallout item : items) {
@@ -673,36 +700,82 @@
            Collections.sort(row, new Comparator<HorizontalStackCallout>() {
                @Override
                public int compare(HorizontalStackCallout first, HorizontalStackCallout second) {
                    return Double.compare(first.getStartX(), second.getStartX());
                    return Integer.compare(first.getSeriesIndex(), second.getSeriesIndex());
                }
            });
            int widest = 0;
            double columnX = 0.0;
            for (HorizontalStackCallout item : row) {
                widest = Math.max(widest, metrics.stringWidth(item.getText()));
                columnX = Math.max(columnX, item.getEndX() + 8.0);
            if (!packIntoColumns) {
                int widest = 0;
                double columnX = 0.0;
                for (HorizontalStackCallout item : row) {
                    widest = Math.max(widest, metrics.stringWidth(item.getText()));
                    columnX = Math.max(columnX, item.getEndX() + 8.0);
                }
                // Keep the label column inside the reserved right margin.
                columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
                int height = metrics.getHeight();
                double step = height + 3.0;
                double centerY = row.get(0).getY();
                double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
                for (int i = 0; i < row.size(); i++) {
                    HorizontalStackCallout item = row.get(i);
                    int width = metrics.stringWidth(item.getText());
                    double x = columnX;
                    double y = top + i * step;
                    graphics.setColor(item.getColor());
                    graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
                            BasicStroke.JOIN_ROUND));
                    graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
                            (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
                    drawLabelWithHalo(graphics, item.getText(),
                            new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
                    occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
                            width + 4.0, height + 4.0));
                }
                continue;
            }
            // Keep the label column inside the reserved right margin.
            columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
            // Small slices are labelled outside the bar. Packing those labels
            // into two columns keeps each day's callouts inside its own row.
            int columnCount = row.size() <= 1 ? 1 : 2;
            int rowCount = (int) Math.ceil(row.size() / (double) columnCount);
            double[] columnWidths = new double[columnCount];
            for (int i = 0; i < row.size(); i++) {
                int column = columnCount == 1 ? 0 : i % columnCount;
                columnWidths[column] = Math.max(columnWidths[column],
                        metrics.stringWidth(row.get(i).getText()));
            }
            double columnGap = 10.0;
            double blockWidth = columnGap * (columnCount - 1);
            for (double columnWidth : columnWidths) blockWidth += columnWidth;
            double blockLeft = dataArea.getMaxX() + 8.0;
            double blockRight = dataArea.getMaxX() + RIGHT_LABEL_BAND;
            blockLeft += Math.max(0.0, (blockRight - blockLeft - blockWidth) / 2.0);
            double[] columnCenters = new double[columnCount];
            double cursor = blockLeft;
            for (int column = 0; column < columnCount; column++) {
                columnCenters[column] = cursor + columnWidths[column] / 2.0;
                cursor += columnWidths[column] + columnGap;
            }
            int height = metrics.getHeight();
            double step = height + 3.0;
            double centerY = row.get(0).getY();
            double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
            double rowStep = height + 3.0;
            double totalHeight = rowCount * height + (rowCount - 1) * (rowStep - height);
            double top = row.get(0).getY() - totalHeight / 2.0;
            for (int i = 0; i < row.size(); i++) {
                HorizontalStackCallout item = row.get(i);
                int column = columnCount == 1 ? 0 : i % columnCount;
                int rowIndex = columnCount == 1 ? i : i / columnCount;
                int itemsInRow = Math.min(columnCount, row.size() - rowIndex * columnCount);
                int width = metrics.stringWidth(item.getText());
                double x = columnX;
                double y = top + i * step;
                graphics.setColor(item.getColor());
                graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
                        BasicStroke.JOIN_ROUND));
                graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
                        (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
                double centerX = itemsInRow == 1 && columnCount > 1
                        ? blockLeft + blockWidth / 2.0
                        : columnCenters[column];
                double x = centerX - width / 2.0;
                double y = top + rowIndex * rowStep;
                drawLabelWithHalo(graphics, item.getText(),
                        new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
                occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
                        width + 4.0, height + 4.0));
            }
        }
    }
@@ -796,14 +869,16 @@
    }
    private static class HorizontalStackCallout {
        private final int seriesIndex;
        private final double startX;
        private final double endX;
        private final double y;
        private final String text;
        private final Color color;
        private HorizontalStackCallout(double startX, double endX, double y,
        private HorizontalStackCallout(int seriesIndex, double startX, double endX, double y,
                                       String text, Color color) {
            this.seriesIndex = seriesIndex;
            this.startX = startX;
            this.endX = endX;
            this.y = y;
@@ -811,6 +886,7 @@
            this.color = color;
        }
        private int getSeriesIndex() { return seriesIndex; }
        private double getStartX() { return startX; }
        private double getEndX() { return endX; }
        private double getY() { return y; }
@@ -839,7 +915,8 @@
    private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                  Rectangle2D dataArea, boolean percentItemLabels,
                                  String unitLabel, int valueDecimals) {
                                  String unitLabel, int valueDecimals,
                                  List<LineLabelOverride> labelOverrides) {
        if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
@@ -881,7 +958,8 @@
            List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
            for (DayLabel label : labels) {
                List<LineLabelCandidate> candidates = lineLabelCandidates(
                        label, metrics, dataArea);
                        label, metrics, dataArea,
                        forcedAnchor(labelOverrides, label.getSeriesIndex(), itemIndex));
                if (candidates.isEmpty()) {
                    candidateSets.clear();
                    break;
@@ -1000,7 +1078,8 @@
    }
    private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
                                                         Rectangle2D dataArea) {
                                                         Rectangle2D dataArea,
                                                         LineLabelAnchor forcedAnchor) {
        List<LineLabelCandidate> result = new ArrayList<>();
        int width = metrics.stringWidth(label.getText());
        int height = metrics.getHeight();
@@ -1010,6 +1089,25 @@
        double farHorizontalOffset = horizontalOffset + 18.0;
        double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
        double farVerticalOffset = verticalOffset + height + 6.0;
        if (forcedAnchor != null) {
            switch (forcedAnchor) {
                case ABOVE:
                    addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
                    break;
                case BELOW:
                    addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
                    break;
                case LEFT:
                    addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
                    break;
                case RIGHT:
                    addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
                    break;
                default:
                    break;
            }
            return result;
        }
        addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
@@ -1053,6 +1151,43 @@
        return result;
    }
    /**
     * Fixed positions a data label can be pinned to, relative to its own point.
     */
    public enum LineLabelAnchor {
        ABOVE, BELOW, LEFT, RIGHT
    }
    /**
     * Opt-in request that pins one data label (series + item) to a fixed anchor.
     */
    public static class LineLabelOverride {
        private final int seriesIndex;
        private final int itemIndex;
        private final LineLabelAnchor anchor;
        public LineLabelOverride(int seriesIndex, int itemIndex, LineLabelAnchor anchor) {
            this.seriesIndex = seriesIndex;
            this.itemIndex = itemIndex;
            this.anchor = anchor;
        }
        public int getSeriesIndex() { return seriesIndex; }
        public int getItemIndex() { return itemIndex; }
        public LineLabelAnchor getAnchor() { return anchor; }
    }
    private static LineLabelAnchor forcedAnchor(List<LineLabelOverride> labelOverrides,
                                               int seriesIndex, int itemIndex) {
        if (labelOverrides == null) return null;
        for (LineLabelOverride override : labelOverrides) {
            if (override.getSeriesIndex() == seriesIndex && override.getItemIndex() == itemIndex) {
                return override.getAnchor();
            }
        }
        return null;
    }
    private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
                                   int width, int height, double dx, double dy,
                                   Rectangle2D dataArea) {
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
@@ -15,6 +15,7 @@
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -84,7 +85,13 @@
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期铁路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.rail), false, 1100, 445),
                        // Pin the 2026 rail label for day 3 above its point so the
                        // 2024 label (70.6) no longer covers it; other charts keep
                        // automatic placement.
                        series(years, yearData, item -> item.rail), false, 2,
                        HolidayTrendChartService.DAY_LABELS, 1100, 445,
                        Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                                0, 2, HolidayTrendChartService.LineLabelAnchor.ABOVE))),
                lineImageSize(5264785L, 2129155L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期水路客运量情况",
@@ -138,12 +145,14 @@
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均机动车速度",
                chartService.lineChart("公里/小时",
                        Collections.singletonList(series("平均速度", observation.speed)), false, 1100, 590),
                        Collections.singletonList(series("平均速度", observation.speed)), false, 2,
                        observation.labels, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均拥挤度",
                chartService.lineChart("拥挤度",
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 1100, 590),
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 2,
                        observation.labels, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        File template = resolveTemplateFile();
@@ -263,23 +272,27 @@
    private ObservationData observationData(int year, String holidayType) {
        List<HolidayCalendar> days = configService.findHolidayDays(year, holidayType);
        if (days.isEmpty()) {
            return new ObservationData(nulls(), nulls());
            return new ObservationData(nulls(), nulls(), HolidayTrendChartService.DAY_LABELS);
        }
        LocalDate start = days.get(0).getHolidayDate();
        LocalDate end = start.plusDays(HolidayTrendChartService.DAY_LABELS.size() - 1L);
        LocalDate end = days.get(days.size() - 1).getHolidayDate();
        List<ObservationDailyOperation> rows = observationStationFlowMapper.selectDailyOperations(start, end);
        Map<LocalDate, ObservationDailyOperation> byDate = new LinkedHashMap<>();
        for (ObservationDailyOperation row : rows) {
            byDate.put(row.getObsDate(), row);
        }
        DateTimeFormatter labelFormat = DateTimeFormatter.ofPattern("M月d日");
        List<BigDecimal> speed = new ArrayList<>();
        List<BigDecimal> congestion = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
            ObservationDailyOperation row = byDate.get(start.plusDays(i));
        List<String> labels = new ArrayList<>();
        for (HolidayCalendar day : days) {
            LocalDate date = day.getHolidayDate();
            ObservationDailyOperation row = byDate.get(date);
            speed.add(row == null ? null : row.getAverageSpeed());
            congestion.add(row == null ? null : row.getAverageCongestion());
            labels.add(date.format(labelFormat));
        }
        return new ObservationData(speed, congestion);
        return new ObservationData(speed, congestion, labels);
    }
    private HolidayTrendChartService.ChartSeries series(String name, List<BigDecimal> values) {
@@ -441,10 +454,12 @@
    private static class ObservationData {
        private final List<BigDecimal> speed;
        private final List<BigDecimal> congestion;
        private final List<String> labels;
        private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion) {
        private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion, List<String> labels) {
            this.speed = speed;
            this.congestion = congestion;
            this.labels = labels;
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java
New file
@@ -0,0 +1,257 @@
package com.trafficaudit.reportexport.calc;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
 * è´§è¿å£å¾„「导出前基数体检」(B4,2026-10-12 æ–°å¢ž 6 æ¡è§„则)。
 *
 * <p>背景:规下 = å…¨å£å¾„(②《货运量周转量表》)− è§„上(①H203-2 ä¼ä¸šæœˆæŠ¥Ã·10000)——是**倒减**,
 * å› æ­¤â‘ æ¼æŠ¥/缺报会让规下虚高,极端情况还会算出负数。本类把这些异常在导出前挑出来,
 * ä¾›æ—¥å¿—/弹窗/《数据缺失清单》页使用(同一套结果也供生成主链的 dbProblems å¤ç”¨ï¼‰ã€‚
 *
 * <p>规则:
 * <ol>
 *   <li>某市州规下为负(货量或周转量);</li>
 *   <li>②该市州有全口径、①无规上记录 â†’ è§„下等于全口径,明显虚高;</li>
 *   <li>②全口径小于①规上(逻辑不可能);</li>
 *   <li>Σ17 å¸‚å·ž â‰  â‘¡çš„"湖北省"行(超容差 0.01);</li>
 *   <li>某市州全口径「本月与上月完全相同」(疑重复导入,两侧 â‰¥ 50 ä¸‡å¨/万吨公里);</li>
 *   <li>某市州全口径同比异常(|去年同月→本月| &gt; 50% ä¸”两侧 â‰¥ 50 ä¸‡å¨/万吨公里);</li>
 *   <li>累计勾稽:①周转量 1..M æœˆæ±‚å’Œ Ã·10000 ä¸Žâ‘¢ã€Šæ‹†åˆ†è¡¨ã€‹CUMULATIVE.above_scale_turnover ä¸ä¸€è‡´ï¼ˆè¶…容差 0.01)。</li>
 * </ol>
 */
@Slf4j
@Service
public class FreightReadinessCheck {
    private static final double TOL = 0.01;
    private static final double YOY_RATIO = 0.50;
    /** çŽ¯æ¯”æ£€æŸ¥çš„ç»å¯¹é—¨æ§›ï¼šä¸¤ä¾§éƒ½è¦ â‰¥ è¯¥å€¼æ‰åˆ¤å¼‚常(避免神农架林区这类小基数噪声) */
    private static final double CHAIN_FLOOR = 50.0;
    @Resource
    private H2032EnterpriseMonthlyMapper h2032Mapper;
    @Resource
    private FreightTurnoverImportMapper freightTurnoverMapper;
    @Resource
    private ScaleSplitTransportMapper scaleSplitMapper;
    public List<String> check(String period) {
        List<String> issues = new ArrayList<>();
        if (period == null || period.length() < 7) return issues;
        int year = Integer.parseInt(period.substring(0, 4));
        int month = Integer.parseInt(period.substring(5, 7));
        // â‘  è§„上(吨)按市州+月份
        Map<String, Map<Integer, Double>> aboveFreight = new HashMap<>();
        Map<String, Map<Integer, Double>> aboveTurnover = new HashMap<>();
        for (H2032EnterpriseMonthly rec : h2032Mapper.selectList(
                new LambdaQueryWrapper<H2032EnterpriseMonthly>()
                        .likeRight(H2032EnterpriseMonthly::getReportPeriod, year))) {
            String rp = rec.getReportPeriod();
            if (rp == null || rp.compareTo(period) > 0) continue;
            String city = RegionUtil.cityByCode(rec.getRegionCode());
            if (city == null) continue;
            int m = Integer.parseInt(rp.substring(5, 7));
            aboveFreight.computeIfAbsent(city, k -> new HashMap<>())
                    .merge(m, rec.getFreightTotal() == null ? 0.0 : rec.getFreightTotal(), Double::sum);
            aboveTurnover.computeIfAbsent(city, k -> new HashMap<>())
                    .merge(m, rec.getTurnoverTotal() == null ? 0.0 : rec.getTurnoverTotal(), Double::sum);
        }
        // â‘¡ å…¨å£å¾„:市州行 + æ¹–北省行(当期)
        Map<String, FreightTurnoverImport> ft = new HashMap<>();
        FreightTurnoverImport province = null;
        for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
                new LambdaQueryWrapper<FreightTurnoverImport>()
                        .eq(FreightTurnoverImport::getReportPeriod, period))) {
            String city = RegionUtil.normalizeCityName(e.getRegionName());
            if (city == null) continue;
            if (FreightCalc.PROVINCE.equals(city)) province = e;
            else ft.put(city, e);
        }
        // ä¸Šæœˆå…¨å£å¾„(用于环比)
        String prevPeriod = prevPeriod(period);
        Map<String, FreightTurnoverImport> ftPrev = new HashMap<>();
        for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
                new LambdaQueryWrapper<FreightTurnoverImport>()
                        .eq(FreightTurnoverImport::getReportPeriod, prevPeriod))) {
            String city = RegionUtil.normalizeCityName(e.getRegionName());
            if (city != null && !FreightCalc.PROVINCE.equals(city)) ftPrev.put(city, e);
        }
        // â‘¢ æ‹†åˆ†è¡¨ï¼šå½“月/累计 è§„上与合计周转量
        Map<String, ScaleSplitTransport> splitCum = new HashMap<>();
        for (ScaleSplitTransport rec : scaleSplitMapper.selectList(
                new LambdaQueryWrapper<ScaleSplitTransport>()
                        .eq(ScaleSplitTransport::getReportPeriod, period))) {
            String city = RegionUtil.normalizeCityName(rec.getRegionName());
            if (city == null || FreightCalc.PROVINCE.equals(city)) continue;
            if ("CUMULATIVE".equals(rec.getPeriodType())) splitCum.put(city, rec);
        }
        double sumFreight = 0.0, sumTurnover = 0.0;
        for (String city : RegionUtil.cityList()) {
            FreightTurnoverImport row = ft.get(city);
            Double totalF = row == null ? null : monthField(row, month, true);
            Double totalT = row == null ? null : monthField(row, month, false);
            Double abF = aboveFreight.containsKey(city) ? aboveFreight.get(city).get(month) : null;
            abF = abF == null ? null : abF / 10000.0;
            Double abT = aboveTurnover.containsKey(city) && aboveTurnover.get(city).get(month) != null
                    ? aboveTurnover.get(city).get(month) / 10000.0 : null;
            if (totalF != null) sumFreight += totalF;
            if (totalT != null) sumTurnover += totalT;
            if (totalF == null && abF == null) continue;      // ä¸¤ä¾§éƒ½æ²¡æ•° â†’ äº¤ç»™ç¼ºæ•°æ¸…单(另算)
            if (totalF == null && abF != null) {
                issues.add(String.format("货运体检:%s %s ç¼ºâ‘¡å…¨å£å¾„(规上 %.4f åœ¨ã€å…¨å£å¾„缺)→ è§„下无法计算"
                        + "(需先导《货运量周转量表》当月档)", period, city, abF));
            }
            if (totalT == null && abT != null) {
                issues.add(String.format("货运体检:%s %s ç¼ºâ‘¡å…¨å£å¾„周转量(规上 %.4f åœ¨ã€å…¨å£å¾„缺)→ è§„下无法计算",
                        period, city, abT));
            }
            // 2026-10-12:②全口径缺(未导)时不再报「规下为负」——上面已单独报「缺②全口径」,
            // è‹¥è¿™é‡Œä»æŒ‰ 0 âˆ’ è§„上 ç®—,会对每个市州多刷一条假阳性(值不是负、是缺),把真问题淹没。
            if (totalF != null) {
                double belowF = totalF - (abF == null ? 0 : abF);
                if (belowF < -TOL) issues.add(String.format("货运体检:%s %s è§„下货运量为负(全口径 %.4f âˆ’ è§„上 %.4f = %.4f)", period, city, totalF, abF, belowF));
            }
            if (totalT != null) {
                double belowT = totalT - (abT == null ? 0 : abT);
                if (belowT < -TOL) issues.add(String.format("货运体检:%s %s è§„下周转量为负(全口径 %.4f âˆ’ è§„上 %.4f = %.4f)", period, city, totalT, abT, belowT));
            }
            if (totalF != null && abF == null) issues.add(String.format("货运体检:%s %s æœ‰å…¨å£å¾„但无规上记录 â†’ è§„下等于全口径(%.4f),疑①漏报", period, city, totalF));
            if (totalF != null && abF != null && totalF < abF) issues.add(String.format("货运体检:%s %s å…¨å£å¾„ %.4f å°äºŽè§„上 %.4f", period, city, totalF, abF));
            FreightTurnoverImport prev = ftPrev.get(city);
            if (prev != null) {
                int prevMonth = month == 1 ? 12 : month - 1;
                Double prevF = monthField(prev, prevMonth, true);
                Double prevT = monthField(prev, prevMonth, false);
                if (totalF != null && sameValue(prevF, totalF) && Math.abs(totalF) >= CHAIN_FLOOR) {
                    issues.add(String.format("货运体检:%s %s å…¨å£å¾„货运量本月与上月完全相同(%.4f),疑重复导入", period, city, totalF));
                }
                if (totalT != null && sameValue(prevT, totalT) && Math.abs(totalT) >= CHAIN_FLOOR) {
                    issues.add(String.format("货运体检:%s %s å…¨å£å¾„周转量本月与上月完全相同(%.4f),疑重复导入", period, city, totalT));
                }
            }
            if (row != null) {
                Double lastF = lastMonthField(row, month, true);
                Double lastT = lastMonthField(row, month, false);
                if (lastF != null && totalF != null && Math.abs(lastF) >= CHAIN_FLOOR && Math.abs(totalF) >= CHAIN_FLOOR) {
                    double ratio = totalF / lastF - 1.0;
                    if (Math.abs(ratio) > YOY_RATIO) {
                        issues.add(String.format("货运体检:%s %s å…¨å£å¾„货运量同比异常(去年同月 %.4f -> æœ¬æœˆ %.4f,%.1f%%)", period, city, lastF, totalF, ratio * 100));
                    }
                }
                if (lastT != null && totalT != null && Math.abs(lastT) >= CHAIN_FLOOR && Math.abs(totalT) >= CHAIN_FLOOR) {
                    double ratio = totalT / lastT - 1.0;
                    if (Math.abs(ratio) > YOY_RATIO) {
                        issues.add(String.format("货运体检:%s %s å…¨å£å¾„周转量同比异常(去年同月 %.4f -> æœ¬æœˆ %.4f,%.1f%%)", period, city, lastT, totalT, ratio * 100));
                    }
                }
            }
ScaleSplitTransport cum = splitCum.get(city);
            if (cum != null && cum.getAboveScaleTurnover() != null) {
                Map<Integer, Double> buckets = aboveTurnover.get(city);
                if (buckets != null) {
                    double sum = 0.0;
                    for (int m = 1; m <= month; m++) sum += buckets.getOrDefault(m, 0.0);
                    double fromH2032 = sum / 10000.0;
                    double fromSplit = cum.getAboveScaleTurnover();
                    if (Math.abs(fromH2032 - fromSplit) > TOL) {
                        issues.add(String.format("货运体检:%s %s è§„上周转量累计勾稽不一致(①逐月求和 %.4f vs â‘¢ç´¯è®¡ %.4f)", period, city, fromH2032, fromSplit));
                    }
                }
            }
        }
        if (province != null) {
            Double pf = monthField(province, month, true);
            Double pt = monthField(province, month, false);
            if (pf != null && Math.abs(pf - sumFreight) > TOL) {
                issues.add(String.format("货运体检:%s Î£17市州货运量 %.4f â‰  â‘¡è¡¨æ¹–北省行 %.4f(差 %.4f)", period, sumFreight, pf, sumFreight - pf));
            }
            if (pt != null && Math.abs(pt - sumTurnover) > TOL) {
                issues.add(String.format("货运体检:%s Î£17市州周转量 %.4f â‰  â‘¡è¡¨æ¹–北省行 %.4f(差 %.4f)", period, sumTurnover, pt, sumTurnover - pt));
            }
        }
        if (!issues.isEmpty()) {
            log.warn("货运体检(B4):{} å‘½ä¸­ {} æ¡é—®é¢˜", period, issues.size());
        }
        return issues;
    }
    /** â‘¡è¡¨æŒ‰ æœˆå· å–当月货运量/周转量(与 FreightCalc åŒä¸€å–法) */
    private Double monthField(FreightTurnoverImport e, int m, boolean freight) {
        switch (m) {
            case 1: return freight ? e.getFreightM01() : e.getTurnoverM01();
            case 2: return freight ? e.getFreightM02() : e.getTurnoverM02();
            case 3: return freight ? e.getFreightM03() : e.getTurnoverM03();
            case 4: return freight ? e.getFreightM04() : e.getTurnoverM04();
            case 5: return freight ? e.getFreightM05() : e.getTurnoverM05();
            case 6: return freight ? e.getFreightM06() : e.getTurnoverM06();
            case 7: return freight ? e.getFreightM07() : e.getTurnoverM07();
            case 8: return freight ? e.getFreightM08() : e.getTurnoverM08();
            case 9: return freight ? e.getFreightM09() : e.getTurnoverM09();
            case 10: return freight ? e.getFreightM10() : e.getTurnoverM10();
            case 11: return freight ? e.getFreightM11() : e.getTurnoverM11();
            case 12: return freight ? e.getFreightM12() : e.getTurnoverM12();
            default: return null;
        }
    }
    /** â‘¡è¡¨åŽ»å¹´åŒæœˆå€¼ */
    private Double lastMonthField(FreightTurnoverImport e, int m, boolean freight) {
        switch (m) {
            case 1: return freight ? e.getLastFreightM01() : e.getLastTurnoverM01();
            case 2: return freight ? e.getLastFreightM02() : e.getLastTurnoverM02();
            case 3: return freight ? e.getLastFreightM03() : e.getLastTurnoverM03();
            case 4: return freight ? e.getLastFreightM04() : e.getLastTurnoverM04();
            case 5: return freight ? e.getLastFreightM05() : e.getLastTurnoverM05();
            case 6: return freight ? e.getLastFreightM06() : e.getLastTurnoverM06();
            case 7: return freight ? e.getLastFreightM07() : e.getLastTurnoverM07();
            case 8: return freight ? e.getLastFreightM08() : e.getLastTurnoverM08();
            case 9: return freight ? e.getLastFreightM09() : e.getLastTurnoverM09();
            case 10: return freight ? e.getLastFreightM10() : e.getLastTurnoverM10();
            case 11: return freight ? e.getLastFreightM11() : e.getLastTurnoverM11();
            case 12: return freight ? e.getLastFreightM12() : e.getLastTurnoverM12();
            default: return null;
        }
    }
    private boolean sameValue(Double a, Double b) {
        if (a == null || b == null) return false;
        return Math.abs(a - b) < 1e-9;
    }
    private String prevPeriod(String period) {
        int y = Integer.parseInt(period.substring(0, 4));
        int m = Integer.parseInt(period.substring(5, 7));
        if (m == 1) return (y - 1) + "-12";
        return String.format("%04d-%02d", y, m - 1);
    }
    public String describe(String period) {
        List<String> issues = check(period);
        return issues.isEmpty() ? "货运体检:未发现问题" : String.join("\n", issues);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java
New file
@@ -0,0 +1,453 @@
package com.trafficaudit.reportexport.calc;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.CityBusMonthly;
import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
import com.trafficaudit.dataimport.entity.PassengerEnterpriseMonthly;
import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
import com.trafficaudit.dataimport.entity.WycOrderMonthly;
import com.trafficaudit.dataimport.entity.WycTotalMonthly;
import com.trafficaudit.dataimport.mapper.CityBusMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerEnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import com.trafficaudit.dataimport.mapper.WycOrderMonthlyMapper;
import com.trafficaudit.dataimport.mapper.WycTotalMonthlyMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
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.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * æ±‡æ€»å¤§è¡¨ã€Œç¼ºæ•°æ¸…单」(只读,2026-10-12 æ–°å¢žï¼Œç”¨æˆ·è¦æ±‚)。
 *
 * <p>与《货运基数体检》{@link FreightReadinessCheck} äº’补、必须分开:
 * é‚£ä¸ªæŸ¥ã€Œä¸å¯èƒ½çš„æ•°ã€ï¼ˆå€’减为负、Σ市州≠省行…),本类查「该有的数没有」。
 * 2026-08 ä½“检 0 å‘½ä¸­ã€ä½† 2026-09 å‡ºè¡¨æ—¶ 8 æœˆåˆ—仍为空 â€”— å°±æ˜¯åªæœ‰å‰è€…、没有后者的后果。
 *
 * <p>检查项:
 * <ol>
 *   <li>程序逐月从库回填的页(班线包车、公交、出租、轨道轮渡):1..目标月 é€æœˆæŸ¥æ¥æºæ˜¯å¦æœ‰æ•°ã€17 å¸‚州是否齐全;</li>
 *   <li>程序只回填目标月的页(货运、网约车):目标月三项来源是否齐全;</li>
 *   <li>上述两页的 1..目标月-1 ç”±æ¯ç‰ˆæ‰¿æ‹…:母版该月值列全空则出表即缺(库里有数、程序不回填)。</li>
 * </ol>
 *
 * <p>本类只读、不抛异常(异常转成清单文字),供导出日志 / å¼¹çª— / ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页使用。
 */
@Slf4j
@Service
public class SummaryMissingDataCheck {
    @Resource
    private PassengerEnterpriseMonthlyMapper h2031Mapper;      // ç­çº¿åŒ…车(公路旅客企业月报)
    @Resource
    private CityBusMonthlyMapper cityBusMapper;                // å…¬äº¤ + è½¨é“/轮渡
    @Resource
    private CityTaxiMonthlyMapper cityTaxiMapper;              // å‡ºç§Ÿï¼ˆç½‘约车拆分也用它的平均运距)
    @Resource
    private H2032EnterpriseMonthlyMapper h2032Mapper;          // è´§è¿è§„上(①)
    @Resource
    private FreightTurnoverImportMapper freightTurnoverMapper; // è´§è¿å…¨å£å¾„(②)
    @Resource
    private ScaleSplitTransportMapper scaleSplitMapper;        // è§„上规下拆分(③)
    @Resource
    private WycOrderMonthlyMapper wycOrderMapper;              // ç½‘约车订单 + å…¨çœ pin
    @Resource
    private WycTotalMonthlyMapper wycTotalMapper;              // ç½‘约车 pin ä¸“表(兜底)
    @Value("${summary.template-dir:docs/生成汇总大表}")
    private String summaryTemplateDir;
    /** ç¨‹åºåªå›žå¡«ã€Œç›®æ ‡æœˆã€çš„页 â€”— æ›´æ—©æœˆä»½ç”±æ¯ç‰ˆæ‰¿æ‹… */
    private static final String[] TARGET_ONLY_PAGES = {" è´§è¿", "网约车"};
    public List<String> check(String period) {
        List<String> issues = new ArrayList<>();
        if (period == null || period.length() < 7) return issues;
        int year;
        int month;
        try {
            year = Integer.parseInt(period.substring(0, 4));
            month = Integer.parseInt(period.substring(5, 7));
        } catch (Exception e) {
            return issues;
        }
        // 1) é€æœˆå›žå¡«é¡µï¼š1..目标月
        for (int m = 1; m <= month; m++) {
            String p = String.format("%04d-%02d", year, m);
            checkH2031Month(p, issues);
            checkCityBusMonth(p, issues);
            checkRailFerryMonth(p, issues);
            checkCityTaxiMonth(p, issues);
        }
        // 2) åªå›žå¡«ç›®æ ‡æœˆçš„两页:目标月来源
        String target = String.format("%04d-%02d", year, month);
        checkFreightTarget(target, issues);
        checkWycTarget(target, issues);
        // 3) è¿™ä¸¤é¡µçš„历史月:母版是否为空
        checkMotherHistory(target, year, month, issues);
        if (!issues.isEmpty()) log.warn("缺数清单({}):命中 {} æ¡", period, issues.size());
        return issues;
    }
    // ---------- å„页检查 ----------
    private void checkH2031Month(String p, List<String> issues) {
        try {
            List<PassengerEnterpriseMonthly> rows = h2031Mapper.selectList(
                new LambdaQueryWrapper<PassengerEnterpriseMonthly>().eq(PassengerEnterpriseMonthly::getReportPeriod, p));
            if (rows.isEmpty()) {
                issues.add(String.format("缺数:班线包车 %s æ— ã€Šå…¬è·¯æ—…客企业月报》来源 â†’ è¯¥æœˆå®¢è¿é‡/周转量、中口径「公路班线」将为空", p));
                return;
            }
            Set<String> got = new LinkedHashSet<>();
            for (PassengerEnterpriseMonthly r : rows) {
                String c = RegionUtil.cityByCode(r.getRegionCode());
                if (c != null) got.add(c);
            }
            addMissingCities("班线包车", p, got, issues);
        } catch (Exception e) {
            issues.add("缺数检查异常:班线包车 " + p + " :: " + e.getMessage());
        }
    }
    private void checkCityBusMonth(String p, List<String> issues) {
        try {
            List<CityBusMonthly> rows = cityBusMapper.selectList(
                new LambdaQueryWrapper<CityBusMonthly>().eq(CityBusMonthly::getReportPeriod, p));
            if (rows.isEmpty()) {
                issues.add(String.format("缺数:公交 %s æ— ã€ŠåŸŽå¸‚公交月报》来源 â†’ å…¬äº¤é¡µä¸ºç©ºã€åŸŽå¸‚客运页将缺公交部分", p));
                return;
            }
            addMissingCities("公交", p, citySetOf(rows), issues);
        } catch (Exception e) {
            issues.add("缺数检查异常:公交 " + p + " :: " + e.getMessage());
        }
    }
    private void checkRailFerryMonth(String p, List<String> issues) {
        try {
            List<CityBusMonthly> rows = cityBusMapper.selectList(
                new LambdaQueryWrapper<CityBusMonthly>().eq(CityBusMonthly::getReportPeriod, p));
            if (rows.isEmpty()) {
                issues.add(String.format("缺数:轨道、轮渡 %s æ— ã€ŠåŸŽå¸‚公交月报》来源(轨道/轮渡同表)→ è¯¥é¡µä¸ºç©º", p));
                return;
            }
            boolean rail = false;
            boolean ferry = false;
            for (CityBusMonthly r : rows) {
                if (r.getRailPassengerVolume() != null) rail = true;
                if (r.getFerryPassengerVolume() != null) ferry = true;
            }
            List<String> miss = new ArrayList<>();
            if (!rail) miss.add("轨道");
            if (!ferry) miss.add("轮渡");
            if (!miss.isEmpty()) {
                issues.add(String.format("缺数:轨道、轮渡 %s çš„ %s å­—段全空 â†’ è¯¥å—将为空",
                    p, String.join("、", miss)));
            }
        } catch (Exception e) {
            issues.add("缺数检查异常:轨道、轮渡 " + p + " :: " + e.getMessage());
        }
    }
    private void checkCityTaxiMonth(String p, List<String> issues) {
        try {
            List<CityTaxiMonthly> rows = cityTaxiMapper.selectList(
                new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, p));
            if (rows.isEmpty()) {
                issues.add(String.format("缺数:出租 %s æ— ã€Šå·¡æ¸¸å‡ºç§Ÿæ±½è½¦æœˆæŠ¥ã€‹æ¥æº â†’ å‡ºç§Ÿé¡µä¸ºç©ºï¼Œä¸”网约车拆分取不到平均运距", p));
                return;
            }
            Set<String> got = new LinkedHashSet<>();
            for (CityTaxiMonthly r : rows) {
                String c = RegionUtil.normalizeCityName(r.getCity());
                if (c != null && !c.isEmpty()) got.add(c);
            }
            addMissingCities("出租", p, got, issues);
        } catch (Exception e) {
            issues.add("缺数检查异常:出租 " + p + " :: " + e.getMessage());
        }
    }
    private void checkFreightTarget(String p, List<String> issues) {
        try {
            // â‘  H203-2 ä¼ä¸šæœˆæŠ¥ï¼ˆè§„上货运量/周转量)
            List<H2032EnterpriseMonthly> h = h2032Mapper.selectList(
                new LambdaQueryWrapper<H2032EnterpriseMonthly>().eq(H2032EnterpriseMonthly::getReportPeriod, p));
            if (h.isEmpty()) {
                issues.add(String.format("缺数:货运 %s æ— â‘ ã€ŠH203-2 é“路货物运输月度生产情况》→ è§„上货运量/周转量取不到", p));
            } else {
                Set<String> got = new LinkedHashSet<>();
                for (H2032EnterpriseMonthly r : h) {
                    String c = RegionUtil.cityByCode(r.getRegionCode());
                    if (c != null) got.add(c);
                }
                addMissingCities("货运①规上", p, got, issues);
            }
            // â‘¡ è´§è¿é‡å‘¨è½¬é‡è¡¨ï¼ˆåˆè®¡ = è§„上+规下)
            List<FreightTurnoverImport> f = freightTurnoverMapper.selectList(
                new LambdaQueryWrapper<FreightTurnoverImport>().eq(FreightTurnoverImport::getReportPeriod, p));
            if (f.isEmpty()) {
                issues.add(String.format("缺数:货运 %s æ— â‘¡ã€Šè´§è¿é‡å‘¨è½¬é‡è¡¨ã€‹â†’ åˆè®¡è´§è¿é‡/周转量、规下(倒减)全部取不到", p));
            } else {
                Set<String> got = new LinkedHashSet<>();
                for (FreightTurnoverImport r : f) {
                    String c = RegionUtil.normalizeCityName(r.getRegionName());
                    if (c != null && !RegionUtil.cityList().contains(c) && !"湖北省".equals(c)) continue;
                    if (c != null && RegionUtil.cityList().contains(c)) got.add(c);
                }
                addMissingCities("货运②全口径", p, got, issues);
            }
            // â‘¢ æ‹†åˆ†è¡¨ï¼ˆè§„上/合计周转量),当月 + ç´¯è®¡
            List<ScaleSplitTransport> s = scaleSplitMapper.selectList(
                new LambdaQueryWrapper<ScaleSplitTransport>().eq(ScaleSplitTransport::getReportPeriod, p));
            if (s.isEmpty()) {
                issues.add(String.format("缺数:货运 %s æ— â‘¢ã€Šè§„上规下拆分表》→ è§„上/合计周转量取不到(该月周转量将为空/0)", p));
            } else {
                Set<String> month = new LinkedHashSet<>();
                Set<String> cum = new LinkedHashSet<>();
                for (ScaleSplitTransport r : s) {
                    String c = RegionUtil.normalizeCityName(r.getRegionName());
                    if (c == null || !RegionUtil.cityList().contains(c)) continue;
                    if ("MONTH".equals(r.getPeriodType())) month.add(c);
                    else if ("CUMULATIVE".equals(r.getPeriodType())) cum.add(c);
                }
                addMissingCities("货运③拆分(当月)", p, month, issues);
                addMissingCities("货运③拆分(累计)", p, cum, issues);
            }
        } catch (Exception e) {
            issues.add("缺数检查异常:货运 " + p + " :: " + e.getMessage());
        }
    }
    private void checkWycTarget(String p, List<String> issues) {
        try {
            List<WycOrderMonthly> orders = wycOrderMapper.selectList(
                new LambdaQueryWrapper<WycOrderMonthly>().eq(WycOrderMonthly::getReportPeriod, p));
            boolean okOrders = false;
            boolean okPin = false;
            if (!orders.isEmpty()) {
                WycOrderMonthly o = orders.get(0);
                double sum = nz(o.getOrderWuhan()) + nz(o.getOrderHuangshi()) + nz(o.getOrderShiyan()) + nz(o.getOrderYichang())
                    + nz(o.getOrderXiangyang()) + nz(o.getOrderEzhou()) + nz(o.getOrderJingmen()) + nz(o.getOrderXiaogan())
                    + nz(o.getOrderJingzhou()) + nz(o.getOrderHuanggang()) + nz(o.getOrderXianning()) + nz(o.getOrderSuizhou())
                    + nz(o.getOrderEnshi()) + nz(o.getOrderXiantao()) + nz(o.getOrderQianjiang()) + nz(o.getOrderTianmen())
                    + nz(o.getOrderShennong());
                okOrders = sum > 0;
                okPin = nz(o.getPinK()) > 0 && nz(o.getPinCK()) > 0 && nz(o.getPinZ()) > 0 && nz(o.getPinCZ()) > 0;
            }
            if (!okOrders) {
                issues.add(String.format("缺数:网约车 %s æ— è®¢å•数据(wyc_order_monthly 17 å¸‚州订单)→ æ‹†åˆ†æ— æ³•计算,该页将为空", p));
            }
            if (!okPin) {
                List<WycTotalMonthly> tot = wycTotalMapper.selectList(
                    new LambdaQueryWrapper<WycTotalMonthly>().eq(WycTotalMonthly::getReportPeriod, p));
                boolean okTot = !tot.isEmpty()
                    && nz(tot.get(0).getPinK()) > 0 && nz(tot.get(0).getPinCK()) > 0
                    && nz(tot.get(0).getPinZ()) > 0 && nz(tot.get(0).getPinCZ()) > 0;
                if (!okTot) {
                    issues.add(String.format("缺数:网约车 %s ç¼ºå…¨çœ pin(order è¡¨ä¸Ž wyc_total_monthly éƒ½æ²¡æœ‰ï¼‰â†’ æ‹†åˆ†æ— æ³•计算", p));
                }
            }
            List<CityTaxiMonthly> taxi = cityTaxiMapper.selectList(
                new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, p));
            Set<String> got = new LinkedHashSet<>();
            for (CityTaxiMonthly r : taxi) {
                String c = RegionUtil.normalizeCityName(r.getCity());
                if (c != null && !c.isEmpty()) got.add(c);
            }
            addMissingCities("网约车(依赖出租平均运距)", p, got, issues);
        } catch (Exception e) {
            issues.add("缺数检查异常:网约车 " + p + " :: " + e.getMessage());
        }
    }
    /** è´§è¿/网约车的历史月(1..M-1)由母版承担(母版没有时按「同期备份母版」还原):
     *  åªæœ‰æ¯ç‰ˆä¸Žå¤‡ä»½æ¯ç‰ˆéƒ½æ²¡æœ‰æ•°ï¼Œæ‰ç®—真正的出表缺口(否则会误报 7 æœˆé‚£ç§ã€Œéª¨æž¶æ¯ç‰ˆ + donor è¿˜åŽŸã€çš„æƒ…å†µï¼‰ã€‚ */
    private void checkMotherHistory(String period, int year, int month, List<String> issues) {
        File mother = resolveMother(year, month);
        if (mother == null) {
            issues.add(String.format("缺数:未找到母版《%då¹´%d月道路运输量汇总表.xlsx》(%s)", year, month, summaryTemplateDir));
            return;
        }
        File donor = resolveDonor(year, month);
        double saved = ZipSecureFile.getMinInflateRatio();
        ZipSecureFile.setMinInflateRatio(0.0001);
        try (InputStream in = new FileInputStream(mother);
             Workbook wb = WorkbookFactory.create(in);
             InputStream in2 = (donor != null) ? new FileInputStream(donor) : null;
             Workbook dwb = (in2 != null) ? WorkbookFactory.create(in2) : null) {
            for (String pageName : TARGET_ONLY_PAGES) {
                List<String> blankMonths = new ArrayList<>();
                for (int m = 1; m < month; m++) {
                    if (monthHasData(wb, pageName, year, m)) continue;
                    if (dwb != null && monthHasData(dwb, pageName, year, m)) continue;
                    blankMonths.add(String.format("%då¹´%d月", year, m));
                }
                if (!blankMonths.isEmpty()) {
                    issues.add(String.format("缺数:%s æ¯ç‰ˆï¼ˆ%s æœŸï¼‰çš„ %s å€¼åˆ—全空%s,而程序只回填目标月 â†’ å‡ºè¡¨è¯¥æœˆå°†ä¸ºç©ºï¼ˆåº“里可能有数据)",
                        pageName.trim(), period, String.join("、", blankMonths),
                        donor == null ? "" : "(同期备份母版也没有)"));
                }
            }
        } catch (Exception e) {
            issues.add("缺数检查异常:读母版 " + mother.getName() + " :: " + e.getMessage());
        } finally {
            ZipSecureFile.setMinInflateRatio(saved);
        }
    }
    /** è¯¥å·¥ä½œç°¿è¯¥é¡µã€è¯¥å¹´è¯¥æœˆçš„「值列」是否已有数值(表头行之外) */
    private boolean monthHasData(Workbook wb, String pageName, int year, int m) {
        Sheet sheet = findSheet(wb, pageName);
        if (sheet == null) return false;
        int[] ref = detectYearMonthCols(sheet, year).get(m);
        if (ref == null) return false;
        return countNumericInColumn(sheet, ref[1], ref[0]) > 0;
    }
    private Sheet findSheet(Workbook wb, String pageName) {
        Sheet s = wb.getSheet(pageName);
        if (s == null && " è´§è¿".equals(pageName)) s = wb.getSheet("货运");
        return s;
    }
    /** åŒæœŸå¤‡ä»½æ¯ç‰ˆï¼ˆæ¸…理前的完整月母版),命名 _备份_{å¹´}å¹´{月}月…道路运输量汇总表 */
    private File resolveDonor(int year, int month) {
        String rel = summaryTemplateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {summaryTemplateDir, System.getProperty("user.dir") + "/" + rel,
            System.getProperty("user.dir") + "/../" + rel};
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File dir = new File(root);
            File[] files = dir.listFiles((d, name) -> name.startsWith("_备份_")
                && name.contains(year + "å¹´" + month + "月") && name.contains("道路运输量汇总表"));
            if (files != null && files.length > 0) return files[0];
        }
        return null;
    }
    // ---------- å·¥å…· ----------
    private Set<String> citySetOf(List<CityBusMonthly> rows) {
        Set<String> got = new LinkedHashSet<>();
        for (CityBusMonthly r : rows) {
            String c = RegionUtil.normalizeCityName(r.getCity());
            if (c != null && !c.isEmpty()) got.add(c);
        }
        return got;
    }
    private void addMissingCities(String page, String period, Set<String> got, List<String> issues) {
        List<String> miss = new ArrayList<>();
        for (String c : RegionUtil.cityList()) {
            if (!got.contains(c)) miss.add(c);
        }
        if (!miss.isEmpty()) {
            issues.add(String.format("缺数:%s %s ç¼ºå¸‚州:%s", page, period, String.join("、", miss)));
        }
    }
    /** æ¯ç‰ˆé‡Œå®šä½ç›®æ ‡å¹´å„月「值列」:返回 æœˆ -> [表头行(0基), åˆ—(0基)];兼容文本「2026å¹´M月」与日期序列号 */
    private Map<Integer, int[]> detectYearMonthCols(Sheet sheet, int year) {
        Map<Integer, int[]> out = new LinkedHashMap<>();
        int lastRow = Math.min(5, sheet.getLastRowNum());
        for (int r = 0; r <= lastRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (Cell c : row) {
                Integer m = monthOfCell(c, year);
                if (m != null && !out.containsKey(m)) {
                    out.put(m, new int[]{r, c.getColumnIndex()});
                }
            }
        }
        return out;
    }
    private Integer monthOfCell(Cell c, int year) {
        try {
            if (c == null) return null;
            if (c.getCellType() == CellType.STRING) {
                String s = c.getStringCellValue();
                if (s == null) return null;
                java.util.regex.Matcher mt = java.util.regex.Pattern
                    .compile("(20\\d\\d)\\s*å¹´\\s*(\\d{1,2})\\s*月").matcher(s);
                if (mt.find()) {
                    if (Integer.parseInt(mt.group(1)) != year) return null;
                    int m = Integer.parseInt(mt.group(2));
                    return (m >= 1 && m <= 12) ? m : null;
                }
                return null;
            }
            if (c.getCellType() == CellType.NUMERIC) {
                double v = c.getNumericCellValue();
                if (v < 20000 || v > 60000 || v != Math.floor(v)) return null;
                java.util.Date d = org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v);
                java.util.Calendar cal = java.util.Calendar.getInstance();
                cal.setTime(d);
                if (cal.get(java.util.Calendar.YEAR) != year) return null;
                return cal.get(java.util.Calendar.MONTH) + 1;
            }
        } catch (Exception ignore) {
            // éžæœˆä»½è¡¨å¤´
        }
        return null;
    }
    /** ç»Ÿè®¡æŸåˆ—(跳过表头行)非空数值格数 */
    private int countNumericInColumn(Sheet sheet, int col, int headerRow) {
        int n = 0;
        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
            if (r == headerRow) continue;
            Row row = sheet.getRow(r);
            if (row == null) continue;
            Cell c = row.getCell(col);
            if (c == null) continue;
            if (c.getCellType() == CellType.NUMERIC || c.getCellType() == CellType.FORMULA) n++;
        }
        return n;
    }
    private File resolveMother(int year, int month) {
        String rel = summaryTemplateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String name = year + "å¹´" + month + "月道路运输量汇总表.xlsx";
        String[] roots = {summaryTemplateDir, System.getProperty("user.dir") + "/" + rel,
            System.getProperty("user.dir") + "/../" + rel};
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File f = new File(root, name);
            if (f.exists() && f.isFile()) return f;
        }
        return null;
    }
    private double nz(Double v) {
        return v == null ? 0.0 : v;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java
@@ -1,6 +1,8 @@
package com.trafficaudit.reportexport.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck;
import com.trafficaudit.reportexport.service.ReportExportService;
import com.trafficaudit.reportexport.service.ReportExportTaskService;
import com.trafficaudit.system.service.OperationLogService;
@@ -26,6 +28,10 @@
    @Resource
    private ReportExportService reportService;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private SummaryMissingDataCheck missingDataCheck;
    @Resource
    private ReportExportTaskService exportTaskService;
    @Resource
@@ -89,9 +95,26 @@
    }
    /** ç”Ÿæˆ_道路运输量汇总表.xlsx(汇总工作簿 P0-P1 éª¨æž¶ï¼šæ•´æœ¬å¤åˆ¶æ¯ç‰ˆï¼Œé¡µç­¾/公式/2025 ç¼“存保留) */
    /** è´§è¿å£å¾„导出前体检(B4):只读返回命中的问题清单。 */
    @GetMapping("/summaryWorkbook/freightCheck")
    public Result<java.util.List<String>> freightCheck(@RequestParam("period") String period) {
        return Result.ok(freightReadinessCheck.check(period));
    }
    /** æ±‡æ€»å¤§è¡¨ç¼ºæ•°æ¸…单(只读):列出目标期出表会缺的月份/市州/来源(与 freightCheck äº’补)。 */
    @GetMapping("/summaryWorkbook/missing")
    public Result<java.util.List<String>> missingCheck(@RequestParam("period") String period) {
        return Result.ok(missingDataCheck.check(period));
    }
    /** æ±‡æ€»å¤§è¡¨æŒ‰æœˆæ‰©åˆ—(Step 1,只做版式、数据留空):返回下一月版式 xlsx。 */
    @GetMapping("/export/summaryWorkbook/expand")
    public ResponseEntity<byte[]> expandSummaryWorkbook(@RequestParam("period") String period) throws Exception {
    public ResponseEntity<byte[]> expandSummaryWorkbook(@RequestParam("period") String period,
                                                        @RequestParam(value = "titlesOnly", defaultValue = "false") boolean titlesOnly) throws Exception {
        if (titlesOnly) {
            byte[] titled = reportService.fixSummaryTitles(period);
            return buildResponse(titled, String.format("标题修正_道路运输量汇总表_%s.xlsx", period));
        }
        byte[] data = reportService.expandSummaryWorkbook(period);
        return buildResponse(data, String.format("扩列_道路运输量汇总表_%s_待确认.xlsx", period));
    }
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/layout/SummaryLayoutExpander.java
@@ -3,6 +3,7 @@
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.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
@@ -41,6 +42,19 @@
    private static final int HEADER_SCAN_ROWS = 15;
    /** æ ‡é¢˜ç”±ç¨‹åºåœ¨å‡ºè¡¨æ—¶çŽ°å†™çš„ 7 ä¸ªé¡µç­¾ï¼ˆæ¯ç‰ˆé‡Œçš„æ ‡é¢˜æ–‡å­—需按母版期次重写)。 */
    private static final List<String> TITLE_PAGES = Arrays.asList(
            "货运量排名", "货运周转量排名", "城市汇总", "城市分市州", "中口径排名", "中口径分析", "道路运输周转量");
    /** æ ‡é¢˜è¯†åˆ«æ ‡è®°ï¼ˆå‘½ä¸­å…¶ä¸€æ‰æ”¹å†™ï¼Œé¿å…è¯¯ä¼¤å¤‡æ³¨ç­‰æ–‡å­—)。 */
    private static final List<String> TITLE_MARKERS = Arrays.asList(
            "全省分市州累计完成公路货运量情况", "全省分市州完成公路货运量情况",
            "全省分市州累计完成公路货物运输周转量情况", "全省分市州完成公路货物运输周转量情况",
            "全省累计完成城市客运量情况", "全省完成城市客运量情况",
            "分市州1-", "全省分市州累计完成道路客运生产情况", "全省分市州完成道路客运生产情况",
            "中口径客运量及周转量",
            "全省累计完成交通周转量情况", "全省完成交通周转量情况");
    /** å…¬å¼é‡Œçš„ A1 å¼•用(可带工作表前缀)。 */
    private static final Pattern REF = Pattern.compile(
            "(?:('([^']+)'|([A-Za-z_\\u4e00-\\u9fff][A-Za-z0-9_.\\u4e00-\\u9fff\u3001]*))!)?(\\$?)([A-Z]{1,3})(\\$?)(\\d+)");
@@ -67,6 +81,11 @@
                if (sh == null || expanded.contains(sh.getSheetName())) continue;
                shiftQualifiedRefs(sh, insertColOf);
            }
            recalcAndCache(wb);
            int titleFixed = applyTitles(wb, year, targetMonth);
            if (titleFixed > 0) {
                log.info("扩列:标题按 {}å¹´{}月 é‡å†™ {} æ ¼", year, targetMonth, titleFixed);
            }
            wb.setForceFormulaRecalculation(true);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            wb.write(out);
@@ -74,6 +93,66 @@
        } catch (Exception e) {
            throw new IllegalStateException("汇总大表扩列失败: " + e.getMessage(), e);
        }
    }
    /** åªæŠŠ 7 ä¸ªæ ‡é¢˜é¡µçš„æ ‡é¢˜æŒ‰ç»™å®šæœŸæ¬¡é‡å†™ï¼ˆç”¨äºŽä¿®æ­£æ¯ç‰ˆé‡Œæ»žåŽçš„æ ‡é¢˜ï¼‰ã€‚ */
    public byte[] fixTitles(byte[] source, int year, int month) {
        try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(source))) {
            recalcAndCache(wb);
            int n = applyTitles(wb, year, month);
            wb.setForceFormulaRecalculation(true);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            wb.write(out);
            log.info("汇总大表标题修正完成:{}å¹´{}月,改写 {} æ ¼", year, month, n);
            return out.toByteArray();
        } catch (Exception e) {
            throw new IllegalStateException("汇总大表标题修正失败: " + e.getMessage(), e);
        }
    }
    /** æ•´æœ¬é‡ç®—并把结果写入缓存值:否则母版里只有公式没有值,后续读取会把空值当 0(2026-10-12 ä¿®ï¼‰ */
    private void recalcAndCache(XSSFWorkbook wb) {
        try {
            FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
            ev.clearAllCachedResultValues();
            ev.evaluateAll();
        } catch (Exception e) {
            log.warn("扩列:整本重算未完成(公式仍在,Excel æ‰“开会再算):{}", e.getMessage());
        }
    }
    /** æŠŠ 7 ä¸ªé¡µç­¾çš„æ ‡é¢˜æ–‡å­—按「累计=1-M月、当月=M月」重写;命中标记才改,避免误伤备注。 */
    private int applyTitles(XSSFWorkbook wb, int year, int month) {
        int changed = 0;
        for (String page : TITLE_PAGES) {
            Sheet sh = wb.getSheet(page);
            if (sh == null) continue;
            int lastRow = Math.min(sh.getLastRowNum(), 40);
            for (int r = 0; r <= lastRow; r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                for (int c = 0; c <= 1; c++) {
                    Cell cell = row.getCell(c);
                    if (cell == null || cell.getCellType() != CellType.STRING) continue;
                    String t = cell.getStringCellValue();
                    if (t == null || !looksLikeTitle(t)) continue;
                    String nt = t.replaceAll("(\\d{4})å¹´", year + "å¹´")
                                 .replaceAll("(\\d{1,2})月", month + "月");
                    if (!nt.equals(t)) {
                        cell.setCellValue(nt);
                        changed++;
                    }
                }
            }
        }
        return changed;
    }
    private boolean looksLikeTitle(String t) {
        for (String m : TITLE_MARKERS) {
            if (t.contains(m)) return true;
        }
        return false;
    }
    /** å¯¹å•页扩列,返回插入位置(0 åŸºåˆ—号)。 */
@@ -143,8 +222,8 @@
            if (headerRows.contains(r)) continue;
            Row row = sh.getRow(r);
            if (row == null) continue;
            cloneDataCell(row, srcVal, insertCol, shiftable, insertCol);
            cloneDataCell(row, srcYoy, insertCol + 1, shiftable, insertCol);
            cloneDataCell(row, srcVal, insertCol, shiftable);
            cloneYoyCell(row, srcYoy, insertCol + 1, insertCol, lastYearSameMonth + 2);
        }
        // 5) ç´¯è®¡åˆ—补当月项;累计同比分母补去年同月列
        int cumCol = insertCol + 2, cumYoyCol = insertCol + 3;
@@ -185,21 +264,46 @@
        return insertCol;
    }
    /** è¡¨å¤´è¡Œï¼ˆ0 åŸºï¼‰ï¼šå«ã€Œå¹´+月」表头单元格的行;轨道、轮渡有两个块 â†’ ä¸¤è¡Œã€‚ */
    /**
     * è¡¨å¤´è¡Œï¼ˆ0 åŸºï¼‰ï¼šè½¨é“、轮渡这类一页两个块的有两行。
     *
     * <p>2026-10-12 äºŒæ¬¡ä¿®æ­£ï¼šå…ˆå–「月份表头格最多的那一行」作基准,把它的月份列当作标准列,
     * åªæœ‰è¿™äº›åˆ—上大半是月份格的行才算表头行。前版只要求「整行 â‰¥2 ä¸ªæœˆä»½æ ¼ã€ï¼Œ
     * ä¼šæŠŠæ•°æ®è¡Œé‡Œæ°å¥½è½æˆæ—¥æœŸåºåˆ—的数值当成表头 â†’ æ‰©åˆ—时把月份日期写进数据行
     * ï¼ˆ2026-09 æ¯ç‰ˆ ` è´§è¿!S11`、`出租!S6` ç­‰ 13 æ ¼è¢«å†™æˆ 46266)。
     */
    private List<Integer> headerRowsOf(Sheet sh) {
        List<Integer> rows = new ArrayList<>();
        int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + HEADER_SCAN_ROWS);
        for (int r = sh.getFirstRowNum(); r <= lastRow; r++) {
        int first = sh.getFirstRowNum();
        int lastRow = Math.min(sh.getLastRowNum(), first + HEADER_SCAN_ROWS);
        List<Integer> base = new ArrayList<>();
        for (int r = first; r <= lastRow; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                if (yearMonthOf(row.getCell(c)) != null) {
                    rows.add(r);
                    break;
                }
            List<Integer> cols = monthColsOf(row);
            if (cols.size() > base.size()) base = cols;
        }
        List<Integer> rows = new ArrayList<>();
        if (base.size() < 2) return rows;
        int need = Math.max(2, (base.size() + 1) / 2);
        for (int r = first; r <= lastRow; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            int hit = 0;
            for (int c : base) {
                if (yearMonthOf(row.getCell(c)) != null) hit++;
            }
            if (hit >= need) rows.add(r);
        }
        return rows;
    }
    /** è¡Œå†…所有「年+月」表头格的列号(0 åŸºï¼‰ */
    private List<Integer> monthColsOf(Row row) {
        List<Integer> cols = new ArrayList<>();
        for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
            if (yearMonthOf(row.getCell(c)) != null) cols.add(c);
        }
        return cols;
    }
    private Map<Integer, Integer> monthCols(Sheet sh, int year) {
@@ -283,17 +387,47 @@
        }
    }
    private void cloneDataCell(Row row, int srcCol, int dstCol, Set<String> shiftable, int insertCol) {
    private void cloneDataCell(Row row, int srcCol, int dstCol, Set<String> shiftable) {
        Cell s = row.getCell(srcCol);
        if (s == null) return;
        Cell d = getOrCreate(row, dstCol);
        d.setBlank();
        d.setCellStyle(s.getCellStyle());
        if (s.getCellType() == CellType.FORMULA) {
            d.setCellFormula(shiftRefs(s.getCellFormula(), insertCol, shiftable));
            d.setCellFormula(shiftRefsAll(s.getCellFormula(), shiftable));
        }
    }
    /** å½“月同比列:不克隆上月公式,直接按「本月值 / åŽ»å¹´åŒæœˆ âˆ’ 1」重写(去年同月列已按表头定位) */
    private void cloneYoyCell(Row row, int srcCol, int dstCol, int valCol0, int denCol0) {
        Cell s = row.getCell(srcCol);
        if (s == null) return;
        Cell d = getOrCreate(row, dstCol);
        d.setBlank();
        d.setCellStyle(s.getCellStyle());
        if (s.getCellType() == CellType.FORMULA) {
            int excelRow = row.getRowNum() + 1;
            d.setCellFormula(colLetter(valCol0) + excelRow + "/" + colLetter(denCol0) + excelRow + "-1");
        }
    }
    /** ç›¸å¯¹å¤åˆ¶ç”¨çš„平移:把本页/已扩列页的引用整体 +2(不做列号阈值判断) */
    private String shiftRefsAll(String formula, Set<String> shiftable) {
        Matcher m = REF.matcher(formula);
        StringBuffer sb = new StringBuffer();
        while (m.find()) {
            String sheet = m.group(2) != null ? m.group(2) : m.group(3);
            if ((sheet != null && !shiftable.contains(sheet)) || isFunctionName(formula, m)) {
                m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0)));
                continue;
            }
            int colIdx = colIndex(m.group(5));
            m.appendReplacement(sb, Matcher.quoteReplacement(rebuild(m, sheet, colIdx + 2)));
        }
        m.appendTail(sb);
        return sb.toString();
    }
    private void shiftAllRefs(Sheet sh, int insertCol, Set<String> shiftable) {
        for (int r = 0; r <= sh.getLastRowNum(); r++) {
            Row row = sh.getRow(r);
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -34,14 +34,20 @@
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck;
import com.trafficaudit.reportexport.layout.SummaryLayoutExpander;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -104,6 +110,10 @@
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private SummaryLayoutExpander summaryLayoutExpander;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private SummaryMissingDataCheck missingDataCheck;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
@@ -1433,10 +1443,11 @@
        Map<String, ScaleSplitTransport> result = new HashMap<>();
        List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
            new LambdaQueryWrapper<ScaleSplitTransport>()
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12:只认目标期,缺数留空、不静默回退上一期
        for (ScaleSplitTransport record : list) {
            if (record.getReportPeriod() == null) continue;
            if (record.getReportPeriod().compareTo(period) > 0) continue;
            if (!period.equals(record.getReportPeriod())) continue;
            String city = RegionUtil.normalizeCityName(record.getRegionName());
            if (city == null) continue;
            if ("湖北省".equals(city)) continue;
@@ -1469,11 +1480,12 @@
    private ScaleSplitTransport getProvinceCumulative(String period) {
        List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
            new LambdaQueryWrapper<ScaleSplitTransport>()
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12:同上(只认目标期)
        ScaleSplitTransport best = null;
        for (ScaleSplitTransport record : list) {
            if (record.getReportPeriod() == null) continue;
            if (record.getReportPeriod().compareTo(period) > 0) continue;
            if (!period.equals(record.getReportPeriod())) continue;
            String name = RegionUtil.normalizeCityName(record.getRegionName());
            if (!"湖北省".equals(name)) continue;
            if (best == null || record.getReportPeriod().compareTo(best.getReportPeriod()) > 0) best = record;
@@ -3638,14 +3650,70 @@
        if (month >= 12) {
            throw new RuntimeException("扩列:12 æœˆä¹‹åŽéœ€æ–°å¼€ä¸€æœ¬ï¼Œè·¨å¹´è§„则另行处理");
        }
        // 2026-10-12:扩列以「当期产物」为底(产物含当期列的值与公式;母版的当期列是空的,
        // ç›´æŽ¥æ‹¿æ¯ç‰ˆæ‰©åˆ—会把当期数据丢成空洞,导致累计漏月)。产物取自导出落盘缓存;缺产物时回退母版并告警。
        File source = resolveLatestProduct(period);
        boolean fromProduct = source != null;
        if (source == null) {
            source = resolveSummaryMother(period, "month");
            if (source != null) {
                log.warn("汇总大表扩列:未找到 {} çš„产物缓存,回退用母版扩列——历史列里缺少当期数据,累计可能漏月", period);
            }
        }
        if (source == null) {
            throw new RuntimeException("未找到汇总表母版或产物(docs/生成汇总大表):" + year + "å¹´" + month + "月道路运输量汇总表.xlsx");
        }
        try (InputStream in = new FileInputStream(source)) {
            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
            log.info("汇总大表扩列完成:{}{} -> {}å¹´{}月版式(只做版式、当期列留空)",
                    fromProduct ? "产物" : "母版", source.getName(), year, month + 1);
            return out;
        }
    }
    /** å–该期「最新产物」:导出落盘缓存 _cache/summaryWorkbook_<period>_month_v*.xlsx(按修改时间取最新) */
    private File resolveLatestProduct(String period) {
        try {
            String rel = summaryTemplateDir;
            while (rel != null && rel.startsWith("./")) rel = rel.substring(2);
            String[] roots = {
                summaryTemplateDir,
                System.getProperty("user.dir") + "/" + rel,
                System.getProperty("user.dir") + "/../" + rel
            };
            String prefix = "summaryWorkbook_" + period + "_month_";
            for (String root : roots) {
                if (root == null || root.trim().isEmpty()) continue;
                File dir = new File(root, "_cache");
                if (!dir.isDirectory()) continue;
                File[] files = dir.listFiles((d, name) -> name.startsWith(prefix) && name.endsWith(".xlsx"));
                if (files == null || files.length == 0) continue;
                File latest = files[0];
                for (File f : files) {
                    if (f.lastModified() > latest.lastModified()) latest = f;
                }
                return latest;
            }
            return null;
        } catch (Exception e) {
            log.warn("汇总大表扩列:查找产物缓存失败(忽略):{}", e.getMessage());
            return null;
        }
    }
    /** åªä¿®æ­£å½“期母版里 7 ä¸ªæ ‡é¢˜é¡µçš„æ ‡é¢˜æ–‡å­—(按母版期次重写,用于修复滞后标题)。 */
    public byte[] fixSummaryTitles(String period) throws Exception {
        if (period == null || !period.contains("-")) {
            throw new RuntimeException("标题修正仅支持按月,请指定 period=YYYY-MM");
        }
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, "month");
        File mother = resolveSummaryMother(period, "month");
        if (mother == null) {
            throw new RuntimeException("未找到汇总表母版(docs/生成汇总大表):" + year + "å¹´" + month + "月道路运输量汇总表.xlsx");
        }
        try (InputStream in = new FileInputStream(mother)) {
            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
            log.info("汇总大表扩列完成:{}å¹´{}月母版 -> {}å¹´{}月版式(只做版式、数据留空)", year, month, year, month + 1);
            return out;
            return summaryLayoutExpander.fixTitles(org.apache.poi.util.IOUtils.toByteArray(in), year, month);
        }
    }
@@ -3691,6 +3759,8 @@
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            dbProblems.addAll(freightReadinessCheck.check(effPeriod));   // B4:货运口径导出前体检(6 æ¡è§„则)
            dbProblems.addAll(missingDataCheck.check(effPeriod));        // ç¼ºæ•°æ¸…单(该有的数没有)
            reportProgress(progress, 22, "回填当月数据");
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);
@@ -3700,7 +3770,9 @@
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // å››å¼ è·¨é¡µåˆæˆé¡µéšæŠ¥è¡¨æœŸåˆ·æ–°
            int widthApplied = applySummaryReferenceLayout(wb);
            // 2026-10-12:停用「套用外部参照件《生成_道路运输量汇总表.xlsx》列宽」——版式一律以当期母版为准,
            // é¿å…æ¯ç‰ˆä¸Žå‚照件两处维护(母版已并入参照件的列宽/隐藏)。
            int widthApplied = 0;
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
@@ -3713,11 +3785,112 @@
            log.info("汇总工作簿公式重算完成:成功 {} æ ¼ï¼Œå¤±è´¥ {} æ ¼ï¼ˆå¤±è´¥æ ¼ä¿ç•™æ¯ç‰ˆæ—§ç¼“存值,打开时按 fullCalcOnLoad é‡ç®—)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // è¿˜åŽŸ/改写公式后打开即重算
            appendMissingDataSheet(wb, effPeriod, dbProblems); // 2026-10-12:缺数/体检结果写进生成件(用户要求)
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /**
     * ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页(2026-10-12,用户要求)。
     *
     * <p>把本次导出的「缺数清单」与「货运基数体检」结果显式写进产物:用户拿到文件即可看到本报表期
     * æœ‰å“ªäº›æ•°æ²¡åˆ°ã€å“ªäº›æ•°ä¸åˆç†ï¼Œä¸å¿…翻导出日志。只读提示页,不参与本表计算。
     */
    private void appendMissingDataSheet(XSSFWorkbook wb, String period, List<String> problems) {
        String sheetName = "数据缺失清单";
        int old = wb.getSheetIndex(sheetName);
        if (old >= 0) wb.removeSheetAt(old); // æ¯ç‰ˆç”±ã€Œå½“期产物」派生时可能已带本页,先移除避免重名
        XSSFSheet sh = wb.createSheet(sheetName);
        CellStyle titleStyle = wb.createCellStyle();
        Font titleFont = wb.createFont();
        titleFont.setBold(true);
        titleFont.setFontHeightInPoints((short) 14);
        titleStyle.setFont(titleFont);
        CellStyle metaStyle = wb.createCellStyle();
        Font metaFont = wb.createFont();
        metaFont.setFontHeightInPoints((short) 10);
        metaFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex());
        metaStyle.setFont(metaFont);
        CellStyle sectionStyle = wb.createCellStyle();
        Font sectionFont = wb.createFont();
        sectionFont.setBold(true);
        sectionStyle.setFont(sectionFont);
        CellStyle bodyStyle = wb.createCellStyle();
        bodyStyle.setWrapText(true);
        bodyStyle.setVerticalAlignment(VerticalAlignment.TOP);
        List<String> missing = new ArrayList<>();
        List<String> check = new ArrayList<>();
        List<String> other = new ArrayList<>();
        if (problems != null) {
            for (String p : problems) {
                if (p == null || p.trim().isEmpty()) continue;
                if (p.startsWith("缺数")) missing.add(p);
                else if (p.startsWith("货运体检")) check.add(p);
                else other.add(p);
            }
        }
        int r = 0;
        Row tRow = sh.createRow(r++);
        Cell tCell = tRow.createCell(0);
        tCell.setCellValue("数据缺失清单");
        tCell.setCellStyle(titleStyle);
        Row mRow = sh.createRow(r++);
        Cell mCell = mRow.createCell(0);
        mCell.setCellValue("报表期 " + period + " 生成时间 "
            + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(new java.util.Date())
            + " 共 " + (missing.size() + check.size() + other.size()) + " æ¡");
        mCell.setCellStyle(metaStyle);
        Row nRow = sh.createRow(r++);
        Cell nCell = nRow.createCell(0);
        nCell.setCellValue("本页由程序自动生成(只读提示):一、缺数清单=该有的数没有;二、数据体检=数值不合理(如规下为负)。不参与本表计算。");
        nCell.setCellStyle(metaStyle);
        r++;
        r = writeProblemSection(sh, r, "一、缺数清单(该有的数没有)", missing, sectionStyle, bodyStyle);
        r = writeProblemSection(sh, r, "二、数据体检(数值不合理)", check, sectionStyle, bodyStyle);
        if (!other.isEmpty()) {
            r = writeProblemSection(sh, r, "三、其它提示", other, sectionStyle, bodyStyle);
        }
        sh.setColumnWidth(0, 6 * 256);
        sh.setColumnWidth(1, 120 * 256);
        sh.createFreezePane(0, 3);
    }
    /** å†™ä¸€ä¸ªæ¸…单分区(标题 + é€æ¡ï¼‰ï¼Œè¿”回下一个可写行号 */
    private int writeProblemSection(XSSFSheet sh, int rowIdx, String title, List<String> lines,
                                    CellStyle sectionStyle, CellStyle bodyStyle) {
        Row hr = sh.createRow(rowIdx++);
        Cell hc = hr.createCell(0);
        hc.setCellValue(title);
        hc.setCellStyle(sectionStyle);
        sh.addMergedRegion(new CellRangeAddress(rowIdx - 1, rowIdx - 1, 0, 1));
        if (lines.isEmpty()) {
            Row er = sh.createRow(rowIdx++);
            er.createCell(0).setCellValue("—");
            er.createCell(1).setCellValue("无");
            return rowIdx;
        }
        int i = 1;
        for (String line : lines) {
            Row row = sh.createRow(rowIdx++);
            row.createCell(0).setCellValue(i++);
            Cell c = row.createCell(1);
            c.setCellValue(line);
            c.setCellStyle(bodyStyle);
        }
        return rowIdx;
    }
    /** å¯¼å‡ºè¿›åº¦å›žè°ƒï¼ˆå¼‚步导出任务用):回调自身异常不影响导出 */
@@ -3922,15 +4095,38 @@
                int colOff = dstTitle.getColumnIndex() - sc0;
                int h = rankBlockHeight(src, sr0, sc0);
                int w = rankBlockWidth(src, sr0, sc0);
                // 2026-10-12:整块缺数(源侧一个数值都没有,如 9 æœˆç¼ºâ‘¡â‘¢æ—¶çš„周转量排名)→ ç›®æ ‡å—只留标题与标签,
                // æ•°æ®/排名/占比一律留空;否则母版的 RANK/占比公式会在空块上算出「全 1 + #DIV/0!」。
                boolean blockHasData = false;
                for (int r = 1; r < h && !blockHasData; r++) {
                    Row sr = src.getRow(sr0 + r);
                    if (sr == null) continue;
                    for (int c = 0; c < w; c++) {
                        Cell sc0c = sr.getCell(sc0 + c);
                        if (sc0c != null && rankNumeric(sc0c, ev) != null) {
                            blockHasData = true;
                            break;
                        }
                    }
                }
                for (int r = 0; r < h; r++) {
                    Row srcRow = src.getRow(sr0 + r);
                    if (srcRow == null) continue;
                    Row dstRow = dst.getRow(sr0 + r + rowOff);
                    if (dstRow == null) dstRow = dst.createRow(sr0 + r + rowOff);
                    for (int c = 0; c < w; c++) {
                        Cell sc = srcRow.getCell(sc0 + c);
                        if (sc == null) continue;
                        int dc = sc0 + c + colOff;
                        Cell sc = srcRow.getCell(sc0 + c);
                        if (!blockHasData && r > 0) {
                            // æ•´å—缺数:只保留标签(市州名/指标名),数据与排名/占比重算结果一律留空
                            if (sc == null || !isRankLabel(sc)) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (sc == null) {
                            // 2026-10-12:源侧整格缺失 = ç¼ºæ•° â†’ ç›®æ ‡æ ¼æŒ‰ã€Œç¼ºæ•°ç•™ç©ºã€æ¸…空(不再沿用母版上一报表期的旧值)
                            if (r > 0) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (r == 0) {
                            // æ ‡é¢˜è¡Œï¼šåŒæ­¥å—标题文案。母版标题是 inlineStr,POI çš„ setCellValue åªå†™ <v>、
                            // ä¸æ›´æ–° <is>,Excel ä¼šç»§ç»­æ˜¾ç¤ºæ—§æœˆä»½ï¼Œå¿…须先 setBlank æ¸…掉再写。
@@ -3965,7 +4161,12 @@
                            continue;
                        }
                        Double v = rankNumeric(sc, ev);
                        if (v == null) continue;
                        if (v == null) {
                            // 2026-10-12:源侧数值位为空(如缺②全口径时排名表的「规下」列)= ç¼ºæ•° â†’ ç•™ç©ºï¼›
                            // æºä¾§æ˜¯æ ‡ç­¾æ–‡æœ¬ï¼ˆå¸‚州名/指标名)时不是缺数,保留母版自带内容。
                            if (!isRankLabel(sc)) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (dcCell == null) dcCell = rankCell(dstRow, dc);
                        dcCell.setCellValue(v);
                        copied++;
@@ -3982,6 +4183,34 @@
        Cell left = col > 0 ? row.getCell(col - 1) : null;
        if (left != null) c.setCellStyle(left.getCellStyle());
        return c;
    }
    /**
     * ç›®æ ‡æ ¼æŒ‰ã€Œç¼ºæ•°ç•™ç©ºã€æ¸…空。
     *
     * <p>2026-10-12:这里连母版自带的排名/占比公式格一起清(缺数期不显示"上一期残留值 + å…¨ 1 æŽ’名 + #DIV/0!"),
     * ä¸Žã€Šç”Ÿæˆ_货运量排名/周转量排名/中口径排名》这些用户日常核对件的空态一致;下期源侧有数时按源侧重写回来。
     * æ–‡å­—标签格不动。
     */
    private void clearRankCell(Row dstRow, int col) {
        Cell c = dstRow.getCell(col);
        if (c == null) return;
        if (c.getCellType() != CellType.STRING) c.setBlank();
    }
    /** æºä¾§æ˜¯å¦ä¸ºæ–‡å­—标签(市州名/指标名)——标签不是缺数,不清空目标格 */
    private boolean isRankLabel(Cell c) {
        if (c.getCellType() != CellType.STRING) return false;
        String v = c.getStringCellValue();
        if (v == null) return false;
        String x = v.replace(",", "").trim();
        if (x.isEmpty()) return false;
        try {
            Double.parseDouble(x);
            return false;
        } catch (NumberFormatException ignore) {
            return true;
        }
    }
    /** å…¬å¼å†…所有 A1 å¼•用整体平移:列 +dCol、行 +dRow(SUM/RANK ç­‰å‡½æ•°ååŽæŽ¥æ‹¬å·ä¸ä¼šè¢«åŒ¹é…ï¼‰ */
@@ -4497,8 +4726,8 @@
        if (sh == null) return 0;
        smSetTitle(sh, 0, 0, "城市客运客运量分市州1-" + month + "月累计情况");
        smSetTitle(sh, 22, 0, "城市客运客运周转量分市州1-" + month + "月累计情况");
        smSetTitle(sh, 45, 0, "城市客运客运量分市州" + month + "月情况");
        smSetTitle(sh, 67, 0, "城市客运客运周转量分市州" + month + "月情况");
        smSetTitle(sh, 44, 0, "城市客运客运量分市州" + month + "月情况");   // 0基44 = Excel ç¬¬45行(母版里标题在 A45,A46 æ˜¯"单位"行)
        smSetTitle(sh, 66, 0, "城市客运客运周转量分市州" + month + "月情况");   // 0基66 = Excel ç¬¬67行(同上)
        int n = 0;
        n += smWriteCityByRegionBlock(sh, 4, true, curCum, lastCum);
        n += smWriteCityByRegionBlock(sh, 26, false, curCum, lastCum);
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -114,6 +114,88 @@
        assertFalse(java.util.Arrays.equals(twoDecimals, threeDecimals));
    }
    @Test
    void rendersLineChartWithConfiguredDayLabels() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries();
        congestion.setName("\u5e73\u5747\u62e5\u6324\u5ea6");
        congestion.setValues(java.util.Arrays.asList(
                new BigDecimal("0.50"), new BigDecimal("0.48"), new BigDecimal("0.47"),
                new BigDecimal("0.49"), new BigDecimal("0.49"), new BigDecimal("0.49"),
                new BigDecimal("0.47")));
        java.util.List<String> sevenDays = java.util.Arrays.asList(
                "10\u67081\u65e5", "10\u67082\u65e5", "10\u67083\u65e5", "10\u67084\u65e5",
                "10\u67085\u65e5", "10\u67086\u65e5", "10\u67087\u65e5");
        byte[] custom = service.lineChart("\u62e5\u6324\u5ea6",
                java.util.Collections.singletonList(congestion), false, 2, sevenDays, 1100, 590);
        byte[] defaults = service.lineChart("\u62e5\u6324\u5ea6",
                java.util.Collections.singletonList(congestion), false, 2, 1100, 590);
        assertPng(custom);
        assertPng(defaults);
        assertFalse(java.util.Arrays.equals(custom, defaults),
                "custom day labels should change the rendered axis");
    }
    @Test
    void pinsRequestedLineLabelToFixedAnchor() throws Exception {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries();
        series.setName("2026\u5e74");
        series.setValues(java.util.Collections.singletonList(new BigDecimal("72.4")));
        java.util.List<HolidayTrendChartService.ChartSeries> single =
                java.util.Collections.singletonList(series);
        byte[] plain = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2, 1100, 590);
        byte[] emptyOverrides = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.emptyList());
        assertArrayEquals(plain, emptyOverrides,
                "an empty override list must keep the automatic placement");
        byte[] unmatched = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 7, HolidayTrendChartService.LineLabelAnchor.ABOVE)));
        assertArrayEquals(plain, unmatched,
                "an override for a label that does not exist must not change rendering");
        byte[] above = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 0, HolidayTrendChartService.LineLabelAnchor.ABOVE)));
        byte[] below = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 0, HolidayTrendChartService.LineLabelAnchor.BELOW)));
        assertPng(above);
        assertPng(below);
        assertFalse(java.util.Arrays.equals(above, below),
                "ABOVE and BELOW anchors must render differently");
        assertTrue(meanSeriesColorY(above) < meanSeriesColorY(below),
                "the ABOVE anchor must place the label higher than the BELOW anchor");
    }
    private double meanSeriesColorY(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        assertNotNull(image);
        int target = 0xC0504D;
        long count = 0;
        long sum = 0;
        for (int y = 0; y < image.getHeight() - 60; y++) {
            for (int x = 0; x < image.getWidth(); x++) {
                if ((image.getRGB(x, y) & 0xFFFFFF) == target) {
                    count++;
                    sum += y;
                }
            }
        }
        assertTrue(count > 0, "expected series-coloured pixels for the item label");
        return (double) sum / count;
    }
    private void assertNoLargeLightGrayBackground(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        int matches = 0;
traffic-audit-web/src/views/ReportExport.vue
@@ -190,6 +190,25 @@
            <el-button size="small" v-else type="primary" @click="closeTask">关闭</el-button>
          </span>
        </el-dialog>
        <!-- æ±‡æ€»å¤§è¡¨å¯¼å‡ºå‰ç¼ºæ•°æ¸…单弹窗(2026-10-12 ç”¨æˆ·è¦æ±‚:缺数摆到台面上) -->
        <el-dialog title="生成前提示 - æ•°æ®ç¼ºå¤±æ¸…单" :visible.sync="missDialogVisible" width="720px" top="6vh">
          <div class="miss-body">
            <p class="miss-lead">报表期 <b>{{ missDialogPeriod }}</b>:以下数据本次生成会缺失/异常,生成件末尾也会附《数据缺失清单》页。</p>
            <template v-if="missDialogMissing.length">
              <div class="miss-sec">一、缺数清单(该有的数没有) 共 {{ missDialogMissing.length }} æ¡</div>
              <ul class="miss-list"><li v-for="(t, i) in missDialogMissing" :key="'m' + i">{{ t }}</li></ul>
            </template>
            <template v-if="missDialogCheck.length">
              <div class="miss-sec">二、数据体检(数值不合理) 共 {{ missDialogCheck.length }} æ¡</div>
              <ul class="miss-list"><li v-for="(t, i) in missDialogCheck" :key="'c' + i">{{ t }}</li></ul>
            </template>
            <p class="miss-tip">缺数多为源数据尚未导入,导入后重新生成即可;体检项请核对源文件。</p>
          </div>
          <span slot="footer">
            <el-button size="small" @click="missDialogVisible = false">取消</el-button>
            <el-button size="small" type="primary" @click="confirmMissDialog">仍要生成</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
@@ -228,6 +247,12 @@
      taskFileName: '',
      taskFileSize: 0,
      taskKey: 'summaryWorkbook',
      // å¯¼å‡ºå‰ç¼ºæ•°æ¸…单弹窗(2026-10-12):缺数/体检摆到台面上,确认后再生成
      missDialogVisible: false,
      missDialogPeriod: '',
      missDialogMissing: [],
      missDialogCheck: [],
      missDialogRefresh: false,
      modules: [
        {
          key: 'summary', title: '汇总大表', items: [
@@ -626,6 +651,28 @@
        })
        .catch(err => this.failWith(err))
    },
    // å¯¼å‡ºå‰ç¼ºæ•°æ£€æŸ¥ï¼šè°ƒåªè¯»æŽ¥å£æŠŠç¼ºæ•°/体检结果摆到用户面前,确认后再走异步生成任务
    generateSummaryWorkbook(refresh) {
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      const params = { period: this.period }
      Promise.all([
        api.get('/report/summaryWorkbook/missing', { params, timeout: 60000 }).catch(() => null),
        api.get('/report/summaryWorkbook/freightCheck', { params, timeout: 60000 }).catch(() => null)
      ]).then(res => {
        const miss = (res[0] && res[0].code === 200 && res[0].data) || []
        const chk = (res[1] && res[1].code === 200 && res[1].data) || []
        if (!miss.length && !chk.length) { this.startSummaryTask(refresh); return }
        this.missDialogPeriod = this.period
        this.missDialogMissing = miss
        this.missDialogCheck = chk
        this.missDialogRefresh = refresh
        this.missDialogVisible = true
      })
    },
    confirmMissDialog() {
      this.missDialogVisible = false
      this.startSummaryTask(this.missDialogRefresh)
    },
    restartTask() { this.startSummaryTask(true) },
    closeTask() { this.taskVisible = false; this.stopTaskPolling(); this.loading = '' },
    // ç”Ÿæˆä¸­æ”¶èµ·å¼¹çª—:任务继续在后台执行,完成后仍会自动下载(与原来同步导出的「点完即下载」一致)
@@ -643,7 +690,7 @@
      const doExport = () => {
        if (items.length === 1) {
          // æ±‡æ€»å¤§è¡¨ç”Ÿæˆä¸€æ¬¡çº¦ 10~20 ç§’,走异步任务(进度弹窗),避免点击后长时间无响应
          if (items[0].key === 'summaryWorkbook') { this.startSummaryTask(false); return }
          if (items[0].key === 'summaryWorkbook') { this.generateSummaryWorkbook(false); return }
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
@@ -794,6 +841,13 @@
.task-meta { margin: 2px 0; font-size: 12px; color:#909399; line-height: 1.6; }
.task-error { margin: 8px 0 0; font-size: 13px; color:#F56C6C; word-break: break-all; }
.task-done { margin: 8px 0 0; font-size: 13px; color:#67C23A; line-height: 1.6; }
/* å¯¼å‡ºå‰ç¼ºæ•°æ¸…单弹窗 */
.miss-body { max-height: 62vh; overflow-y: auto; }
.miss-lead { font-size: 13px; color: #303133; margin: 0 0 10px; line-height: 1.7; }
.miss-sec { font-size: 13px; font-weight: 600; color: #F56C6C; margin: 10px 0 4px; }
.miss-list { margin: 0; padding-left: 20px; font-size: 13px; color: #303133; line-height: 1.7; }
.miss-list li { margin: 2px 0; }
.miss-tip { font-size: 12px; color: #909399; margin: 10px 0 0; }
</style>
<style>