1个文件已添加
6个文件已修改
631 ■■■■■ 已修改文件
HANDOFF.md 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成汇总大表/2026年9月道路运输量汇总表.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/生成计算参照与验收口径_2026-10-13.md 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/确认回执汇总与剩余疑问_汇总大表数据驱动改造_2026-09-07.md 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/要求与验收基线清单.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryWorkbookFiller.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 300 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,25 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
### 10-13(1):汇总大表《货运周转量排名》块错位修复(母版删多余标题行 + ä»£ç æŒ‰ã€Œå…¨çœã€è¡Œé”šç‚¹å¯¹é½ + å†™åŽè‡ªæ£€ï¼‰ã€å¾…验收】
- **现象(用户报告 + æ ¸å¯¹ï¼‰**:9 æœˆ â‘¡â‘¢ å…¥åº“后,生成件《货运周转量排名》整块错位一行——全省值落到**表头行**(覆盖「累计完成(万吨公里)」文字)、市州值与市州名**错开一行**(武汉行装黄石的数…林区行是母版旧值)、**武汉的值丢失**;`RANK/占比/Q/R` éšé”™ä½ä¸€å¹¶é”™ã€‚
- **根因(两层)**:① æ¯ç‰ˆè¯¥é¡µæ¯å—比源底稿 `docs/货运/生成_周转量排名.xlsx` **多 1 è¡Œ**(标题合并占两行:`A1:R2`、`A23:R24`;全省行在第 5/27 è¡Œï¼‰ï¼›â‘¡ `ReportExportService.copyRankValues` æŒ‰ã€Œå—标题行 + æºè¡Œåç§»ã€é€è¡Œç…§æ¬ï¼Œä¸” `:4334` é‡åˆ°ç›®æ ‡æ ¼æ˜¯å…¬å¼å°±è·³è¿‡ â†’ å½¢æˆ"值列保留 SUM、排名列被写成上一行市州"的半错位。该缺陷**一直存在**(7/8/9 æœˆæ¯ç‰ˆåŒç»“构),因这页此前走"整块缺数留空"分支(②③未导)而未暴露。
- **修复**:
  1. **母版**(用户 19:47 æ‰§è¡Œï¼‰ï¼š`docs/生成汇总大表/2026å¹´9月道路运输量汇总表.xlsx`《货运周转量排名》删掉两块多出的标题行 â†’ å…¨çœè¡Œå›žåˆ°ç¬¬ **4/25** è¡Œï¼Œä¸Žã€Šè´§è¿é‡æŽ’名》同构;Excel å·²è‡ªåŠ¨ä¿®å¥½åˆå¹¶åŒºä¸Ž `SUM(B5:B21)`/`SUM(B26:B42)` ç­‰å…¬å¼åŒºé—´ã€‚
  2. **代码**(`ReportExportService.copyRankValues` â†’ ä¼ å‚ `List<String> problems`):改按目标块「全省」行做锚点,`rowOff = ç›®æ ‡å…¨çœè¡Œ âˆ’ æºå…¨çœè¡Œ`;识别不到锚点回退标题行偏移并 WARN;新增**写后自检**(目标全省行与源侧不一致 â‰¥2 åˆ— â†’ WARN + å†™å…¥ã€Šæ•°æ®ç¼ºå¤±æ¸…单》)。三个排名页(货运量/周转量/中口径)统一生效。
- **验证(20:06 é‡æ–°ç”Ÿæˆ 2026-09,dev 8090/8080 å·²é‡å¯åŠ è½½æ–°ä»£ç ï¼‰**:《货运周转量排名》累计块/当月块 Ã— å…¨çœ + 17 å¸‚å·ž Ã— 16 åˆ—(规上/规下/合计值、名次、占比、同比、增速名次、Q/R)与库内 `scale_split_transport` 2026-09(CUMULATIVE/MONTH)**逐格一致,0 å¤„不一致**;缺数清单 4→2 æ¡ï¼ˆâ‘¡â‘¢å·²å…¥åº“,仅剩网约车 2 æ¡ï¼‰ï¼›é¡µç­¾å®šä½ 20 é¡µã€ç´¯è®¡é‡å»º 0 æ ¼ã€å…¬å¼é‡ç®— 39969 æ ¼æˆåŠŸã€‚
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表周转量排名锚点对齐_2026-10-13.md`。产物:`_tmp/_fix_1013/生成_道路运输量汇总表_2026-09_验证.xlsx`(未入库)。
- **【待验收/待定夺/待处理】**:
  1. ã€å¾…验收】《货运周转量排名》母版删行 + ä»£ç é”šç‚¹å¯¹é½ä¸Žè‡ªæ£€ï¼ˆé¡µé¢é‡æ–°ç”Ÿæˆå³å¯å¤çŽ°éªŒè¯ï¼‰ã€‚
  2. ã€å¾…用户拍板】《货运量排名》累计块「规下增速 / å¢žé€ŸæŽ’名」**30 æ ¼**口径:生成件(全省 0.052525)vs æ¡Œé¢ã€Š2026å¹´9月客运量、货运量预测.xlsx》(全省 -0.224015),需定以哪边为准。
  3. ã€å¾…用户拍板】版式期望:块顺序(生成件=累计在上;预测件=当月在上)、市州名带不带"市"。
  4. ã€å¾…处理】7/8 æœˆæ¯ç‰ˆåŒä¸º"多 1 è¡Œ"结构(在 `归档/`)——锚点对齐后正确性已不受影响,下次取回出表时确认即可,暂不改归档件。
  5. ã€12 æœˆå‰ã€‘跨年母版需新开一本(扩列 `month>=12` æ˜Žç¡®æ‹’绝);建议做成半自动,规则待定(保留几年历史块 / æ–°ä¸€å¹´æœˆåº¦åˆ—布放 / 2027-01 æ¯ç‰ˆæ˜¯å¦ç•™æ¡£ï¼‰ã€‚
  6. ã€ç¼ºæ•°/缺行】网约车 9 æœˆï¼ˆè®¢å•、全省 pin、源文件);公交 9 æœˆ 141 è¡Œ vs 142 è¡Œï¼ˆæºæ–‡ä»¶ç¼º `随州市·广水市安顺公交有限公司`)。
- **环境**:dev å‰åŽç«¯ 20:00 é‡å¯ï¼ˆ`backend.log` è§ `Started TrafficAuditApplication`、`Tomcat started on port(s): 8090`);启动日志另有一条与本次无关的 `traffic_audit.holiday_calendar` è¡¨ä¸å­˜åœ¨å‘Šè­¦ã€‚
- **数据边界**:本轮只做生成与只读核对(库内查询只读),**未导入、未覆盖任何业务数据**。
### 10-12(14):汇总大表「缺数清单弹窗 + ç”Ÿæˆä»¶ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页」+ ä½“检假阳性修复 + å…¬äº¤ 9月缺行核查【待验收】
- **动因**:用户要求把缺数摆到台面上(P0-3 å‰©ä½™ï¼‰ï¼Œå¹¶åœ¨ç”Ÿæˆä»¶é‡Œç»™ä¸€é¡µã€Šæ•°æ®ç¼ºå¤±æ¸…单》。
docs/Éú³É»ã×Ü´ó±í/2026Äê9ÔµÀ·ÔËÊäÁ¿»ã×ܱí.xlsx
Binary files differ
docs/Éú³É¼ÆËã²ÎÕÕÓëÑéÊÕ¿Ú¾¶_2026-10-13.md
New file
@@ -0,0 +1,103 @@
# ç”Ÿæˆè®¡ç®—参照与验收口径(2026-10-13 æ±‡æ€»ï¼‰
> ä¸€é¡µå›žç­”两件事:① æ±‡æ€»å¤§è¡¨çš„"生成计算"**参照了同事专项统计的哪些做法**;② **每次出表怎么验收**(四层验收,已脚本化)。
> æ¥æºï¼š`docs/汇总大表M0版式重建器规格_2026-10-06.md` ç¬¬åä¸€èŠ‚ï¼ˆ2026-10-08 å¯¹é½è¡¨ï¼‰ã€`docs/汇总大表生成数据验证方法_2026-10-12.md`、`docs/要求与验收基线清单.md` R6、`docs/确认回执汇总…_2026-09-07.md` ç¬¬å…«èŠ‚ã€‚
## ä¸€ã€å‚照同事「专项统计」的映射(2026-10-08 åŽŸæ–‡å¯¹é½è¡¨ï¼‰
| åŒäº‹ä¸“项统计的做法 | ä»–为什么能成立 | æ±‡æ€»å¤§è¡¨çš„对应做法 | çŠ¶æ€ |
|---|---|---|---|
| ä¸€ä»½æ¯ç‰ˆï¼ˆ`2026年十一数据.xlsx`),**天为行、年为列** | å¢žé•¿ç»´åº¦æ˜¯ã€Œå¹´ã€ã€å¤©æ•°å›ºå®š 8 | æŒ‰ **F10 æŒ‰æœˆæŒ‰éœ€æ‰©åˆ—**:读 N æœˆæ¯ç‰ˆäº§å‡º N+1 æœˆç‰ˆå¼ï¼ˆä¸é¢„留空列),母版只当版式来源 | âœ… å·²å®žçŽ°ï¼ˆæ‰©åˆ—å™¨ï¼‰ |
| æ¯ç‰ˆ**同时是数据容器**(历史年份值就在并排年列里) | åŽ†å²å€¼éšæ¨¡æ¿èµ° | **反过来做**:历史参照值入库、母版只留版式、参照组由 DB å›žå¡« | â¬œ æœªå®žæ–½ï¼ˆ10-08 å®šæ–¹å‘) |
| è¡¨å¤´/行标签**语义定位**(`findYearColumn`/`parseDayIndex`) | åˆ—序变了也能导 | é¡µè§„则表 `SummarySheetRule`:**按表头认月份组、按行标签认市州/指标,不硬编码列锚点** | ðŸš§ éƒ¨åˆ†ï¼ˆæœˆåº¦å—还依赖母版公式;2026-09 æ¼æœˆå³æºäºŽæ­¤ï¼‰ |
| æ˜“漂移口径**外置成表**(`holiday_calendar`/`passenger_coefficient`/`vehicle_adjustment`) | è§„则不散在代码与单元格里 | å¤–置:参照年保留组数、排名口径(全省不排名)、`B=G+L/2+Q/10`、缺数置空规则 | ðŸš§ éƒ¨åˆ† |
| `DECIMAL(30,15)` + `BigDecimal` + æŒ‰ Excel æ˜¾ç¤ºç²¾åº¦å–值 | ç²¾åº¦å£å¾„统一、无浮点尾差 | æ‰¿æŽ¥å¾…办「16 å¼ è¡¨ 228 ä¸ª double åˆ—改 DECIMAL+BigDecimal」;已做 F1 å…¨ç²¾åº¦å­˜å‚¨ï¼‹ä¸¤ä½å°æ•°æ˜¾ç¤ºã€æ¯ç‰ˆæ–‡æœ¬æ•°å­—归一 | ðŸš§ éƒ¨åˆ† |
| **导入两阶段确认** + å…¨ç¨‹åªè¯»æ ¸å¯¹ï¼ˆåŽ†å²æ•°æ®ä¸è¢«è¯¯è¦†ç›–ï¼‰ | å…ˆå‡ºå·®å¼‚清单、用户确认后才写 | **生成前先出缺数/差异清单、人工确认后再出表**(B4 åŸºæ•°ä½“检 + ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页) | âœ… å·²å®žçް |
| è¾“出走模板文件(docx/PNG) | ç‰ˆå¼æ¥æºå•一 | ä¿ç•™çŽ°æœ‰åšæ³•ï¼ˆå¿«æŠ¥æ”¹ `word/document.xml`、JFreeChart å‡º PNG) | â€” |
**一句话**:借的是**方法与纪律**(语义定位、口径外置、两阶段确认、精度统一),**不照搬**他"母版兼数据容器"的做法——我们反过来让历史值入库。
## äºŒã€ç”Ÿæˆè®¡ç®—的分段与各自参照(2026-10-12《生成数据验证方法》)
原则:**每一段都要有一个独立于该段的参照**;两边同源就验不出东西。
| æ®µ | é—®é¢˜ | å‚ç…§ | è°è´Ÿè´£ | ç¨‹åºèƒ½è‡ªè¯ |
|---|---|---|---|---|
| L0 æºæ–‡ä»¶â†’库 | æºæ•°æ®å¯¹ä¸å¯¹ | æºæ–‡ä»¶/下发表模板 | æ•°æ®ã€ä¸šåŠ¡ | ä¸èƒ½ |
| L1 åº“→口径值 | ç®—法口径对不对 | **手写 SQL å¤ç®—**(不看母版/定稿) | ç¨‹åº | èƒ½ |
| L2 å£å¾„值→单元格 | å†™å›žå¯¹ä¸å¯¹ | ä¸Šä¸€æœŸæ¯ç‰ˆ/生成件("不该动的格未变") | ç¨‹åº | èƒ½ |
| L3 å•元格→整本 | ä¸Žäººå·¥å®šç¨¿ä¸€è‡´å— | æ‰‹å·¥å®šç¨¿ï¼ˆ**记版本 + sha256**) | åŒæ–¹å¤æ ¸ | éƒ¨åˆ† |
| L4 è·¨é¡µåˆæˆé¡µ | å‹¾ç¨½å¯¹ä¸å¯¹ | å‹¾ç¨½å¼æœ¬èº«ï¼ˆå¦‚ `道路运输周转量 B=G+L/2+Q/10`) | ç¨‹åº | èƒ½ |
典型失败模式(原文):L0 æ¼å¯¼/列错位/单位差 10000/重复导入叠加;L1 è§„下倒减为负、Σ市州≠省行;L2 å½“月列没写、上期列被重算冲掉、公式断链;L3 æŠŠ"定稿滞后"当"程序回归";L4 æºé¡µå¯¹è€Œåˆæˆé¡µå¼•用错列。
## ä¸‰ã€æ¯æ¬¡å‡ºè¡¨çš„四层验收(脚本化;与第二节的分段是两套切分,别混)
工具:`汇总表四层验收.py`(L1–L4)+ `汇总表全盘体检.py`(增量版,含 A/B/C/D)。命令:
```
python æ±‡æ€»è¡¨å››å±‚验收.py <生成表.xlsx> --源表 <当期源表.xlsx> --上期 <上期件.xlsx> --报告 éªŒæ”¶æŠ¥å‘Š.txt
```
| å±‚ | æŸ¥ä»€ä¹ˆ | çœŸå€¼/依据 | é€šè¿‡æ ‡å‡† |
|---|---|---|---|
| éªŒæ”¶ L1 ç»“构断言 | å…¨ç°¿å…¬å¼ä¸Žè¡¨å¤´ | è¡¨å¤´æœ¬èº« | ç´¯è®¡=当年 1..M æœˆä¹‹å’Œï¼›ç´¯è®¡åŒæ¯”分母=去年 1..M æœˆï¼›**历史列零变化**;无月首日期串;`#REF!`=0 |
| éªŒæ”¶ L2 ä¸Žæºè¡¨ | 8 ä¸ªå¯è§å°è´¦é¡µ | **当期业务源表** | é€æ ¼ diff=0;差异须逐条结论并签核 |
| éªŒæ”¶ L3 å†…部勾稽 | å…¨ç°¿ | ç”± L2 æŽ¨å¯¼ | å…¨çœ=Σ市州;累计=Σ各月;排名/占比自洽 |
| éªŒæ”¶ L4 è·¨æœŸæŠ¥è­¦ | å…¨ç°¿ | ä¸ŠæœŸä»¶ | åŒå£å¾„环比超阈值(默认 Â±30%、基数≥100)即提示 |
**白名单机制**:已知且已解释的差异显式登记(轮渡块形态、母版模板遗留日期值、源表自带脏值被清空…);**白名单之外任何差异=不通过**,不再靠人工逐条解释。
**两套切分的对应关系**(重要):
| éªŒæ”¶å››å±‚ | è¦†ç›–第二节的哪一段 |
|---|---|
| éªŒæ”¶ L1 ç»“构断言 | é“¾æ¡ L2(写回层)为主 |
| éªŒæ”¶ L2 ä¸Žæºè¡¨/定稿 | é“¾æ¡ L3(出表层);源表可用时也覆盖 L0 çš„结果 |
| éªŒæ”¶ L3 å†…部勾稽 | é“¾æ¡ L4(合成页) |
| éªŒæ”¶ L4 è·¨æœŸæŠ¥è­¦ | é“¾æ¡ L1 çš„"环比异常"体检 |
| **未覆盖** | **链条 L1 çš„"SQL é”šç‚¹å¤ç®—"尚未脚本化(待办)** |
## å››ã€é€šè¿‡æ ‡å‡†ä¸Žä¸‰æ¡çºªå¾‹
- **通过**=四层全过 + ç™½åå•外零差异;**任一未覆盖 â†’ ç»“论只能写"未覆盖",不得写"已验证"**。
- çºªå¾‹ â‘ **独立参照**(同源互比等于没验);②**一次只换一个变量**(换代码/换数据/换母版分开验);③**差异先分类再结论**(结构/格式/浮点尾差只计数,实质数值差异进清单)。
- è´£ä»»è¾¹ç•Œï¼šç¨‹åºä¿è¯"**不搬错 + ä¸Žæºè¡¨ä¸€è‡´ + å¯è¿½æº¯**";**数据本身的正确性**由导入审核 + ä¸šåŠ¡å£å¾„ç¡®è®¤ + æ¯æœŸç­¾æ ¸è´Ÿè´£ã€‚
## äº”、待办(按优先级)
1. é“¾æ¡ L1 çš„ **SQL é”šç‚¹å¤ç®—脚本化**(全省 + 2 å¸‚州,覆盖 è§„上/规下/合计/周转量/中口径/城市内)。
2. **历史参照值入库**、母版只留版式(10-08 å·²å®šæ–¹å‘)。
3. è½®æ¸¡å— 8 æ ¼å£å¾„确认(当前列白名单);跨年(2027-01)扩列规则;"缺当月母版时自动扩列"接主链。
## å…­ã€ã€Œçº¦å®š 8 æ¡ã€vs çŽ°çŠ¶ vs çŽ°åšæ³•ï¼ˆ2026-10-13 æ ¸å®žï¼‰
| # | çº¦å®šå¯ä»¥å‚ç…§çš„ | åšäº†å— | çŽ°åœ¨å®žé™…æ€Žä¹ˆåš |
|---|---|---|---|
| 1 | æŒ‰éœ€å¢žé•¿åˆ— â†’ F10 æŒ‰æœˆæŒ‰éœ€æ‰©åˆ— | âœ… | æ‰©åˆ—器已实现;底稿取 `_cache\summaryWorkbook_<期>_month_v*.xlsx`;**未接主链**(`resolveSummaryMother` åªç²¾ç¡®åŒ¹é…ï¼‰â†’ æ¯æœˆä»éœ€äººå·¥ä¸€æ­¥ï¼ˆæ‰©åˆ—→确认落盘→生成) |
| 2 | è¡¨å¤´/行标签语义定位 | ðŸš§ | å·²åšï¼šè¡¨å¤´åŠ¨æ€å®šä½ã€æŒ‰ã€ŒA å¸‚州名 + B æŒ‡æ ‡æ–‡æ¡ˆã€å®šä½è¡Œã€æ‰©åˆ—按基准月份列判表头、累计/累计同比按表头重建(2026-10-13 æ–°å¢žï¼‰ã€‚未做:页规则表 `SummarySheetRule`(用启发式替代) |
| 3 | è¾“出走模板文件 | âœ… | å¤åˆ¶æ¯ç‰ˆ â†’ å›žå¡«/还原 â†’ æŽ’名页按母版标题格对齐搬块 â†’ 7 æ ‡é¢˜é¡µæŒ‰ period çް写 |
| 4 | å£å¾„外置成表 | â¬œ | å†™æ­»åœ¨ä»£ç /母版:系数 `2、10`、排名口径、缺数置空规则 |
| 5 | ç²¾åº¦ç»Ÿä¸€ï¼ˆDECIMAL+BigDecimal) | â¬œ | ä»èµ° double;已做 F1 å…¨ç²¾åº¦ï¼‹ä¸¤ä½å°æ•°æ˜¾ç¤ºã€æ¯ç‰ˆæ–‡æœ¬æ•°å­—归一 |
| 6 | ä¸¤é˜¶æ®µç¡®è®¤ï¼‹åªè¯»æ ¸å¯¹ | âœ…(环节不同) | åŒäº‹åœ¨å¯¼å…¥ä¾§åšï¼›æˆ‘们在**生成侧**做(缺数清单+货运体检+导出前弹窗+《数据缺失清单》页)。**导入侧仍是重复导入直接覆盖** |
| 7 | äºŒç»´è¡¨ä¸‰å±‚职责 | ðŸš§ | åŽŸå§‹å€¼ âœ…(导入表齐全);配置值、人工调整值**均无表**;**Excel ä»æ‰¿æ‹…历史值存储**(2025 å‚照值在母版里) |
| 8 | äººå·¥è°ƒæ•´å€¼å•独建表(覆盖层) | â¬œ | é  `_备份_`/`_bak_` æ¯ç‰ˆ + äººå·¥æ”¹æ¯ç‰ˆå…œ |
**不可照搬 3 æ¡çš„æ‰§è¡Œ**:不把 Excel å½“数据源 âœ…(业务数据走 DB,但历史参照值仍在母版 âš ï¸ï¼‰ï¼›æ¯ç‰ˆåªç•™ç‰ˆå¼ â¬œï¼›ç‹¬ç«‹ã€Œæ–°å¹´ç»„」逻辑 âŒï¼ˆè·¨å¹´ `month>=12` æŠ›é”™ï¼‰ã€‚
## ä¸ƒã€ä»»åŠ¡æ¸…å•ï¼ˆå€Ÿ 3 æ¡ + è‡ªå»º 3 æ¡ï¼‰
**借同事的**:① è¯­ä¹‰å®šä½ï¼ˆæ¯”对与定位都不硬编码列锚点);② å‚照值入库、母版只留版式(阶段 2 æ ¸å¿ƒï¼‰ï¼›â‘¢ å£å¾„与人工调整外置成表 + è¦†ç›–层留痕(覆盖值+原因+时间)。
**自建**:④ **SQL é”šç‚¹å¤ç®—**(链条 L1,唯一能发现"源表自身错"的层);⑤ **L2 æ¯”对覆盖补齐**(班线包车/网约车/货运,用语义映射);⑥ **L4 è·¨æœŸé˜ˆå€¼æŠ¥è­¦**(已实现)。
## å…«ã€2026-10-13 è¯­ä¹‰æ¯”对结果(新增脚本 `汇总表语义比对.py`)
按「行=市州+指标、列=å¹´+月+类型」归位后比对 2026-09 ç”Ÿæˆä»¶ ä¸Ž æ¡Œé¢æºè¡¨ï¼š
| é¡µç­¾ | å¯æ¯”行 | å·®å¼‚ | ç»“论 |
|---|---|---|---|
| ç­çº¿åŒ…车 | 54 | **0** | âœ… åŽŸè¢«"表头布局不同"跳过,现可比且完全一致 |
| å…¬äº¤ / å‡ºç§Ÿ / åŸŽå¸‚客运 / å…¬è·¯æ€»å®¢è¿ / è½¨é“、轮渡 | 72/69/36/36/10 | **0** | âœ… |
| ç½‘约车 | 72 | 8 | âš ï¸ å…¨çœ 4 ä¸ªæŒ‡æ ‡çš„ 2026-09 å½“月值 = ç”Ÿæˆ 0、源表有值(4997.74 / 29166.70 / 4571.18 / 23367.09);同比 = **-100%**。属**库内 9 æœˆç½‘约车缺数**(订单/pin æœªå¯¼ï¼‰ï¼Œéžç®—é”™ â†’ **但"缺数期同比显示 -100%"应改为留空**(待处理) |
| ä¸­å£å¾„明细 / ä¸­å£å¾„客运量 | 181 | 62 / 22 | å·®å¼‚全为 `生成=None / æº='#DIV/0!'` â†’ **源表脏值被清空**(生成件更干净) |
**结论**:把语义比对补上后,`班线包车` å¯è¯å®žä¸€è‡´ï¼›`网约车` çš„差异可归因到"缺数",不属于算错。
docs/È·ÈÏ»ØÖ´»ã×ÜÓëÊ£ÓàÒÉÎÊ_»ã×Ü´ó±íÊý¾ÝÇý¶¯¸ÄÔì_2026-09-07.md
@@ -28,7 +28,7 @@
| D3 | æ¯å¹´æ–°å¼€ä¸€æœ¬ï¼ˆå«ä¸Šä¸€å¹´å‚照组);2026 æ±‡æ€»è¡¨åªåˆ° 2026-12 |
| D4 | ç”¨æˆ·æŒ‡å®š period;缺数月份列提示清单 |
| D5 | ç‹¬ç«‹å°è¡¨ä¸ç»§ç»­ç”Ÿæˆï¼ˆè§å‰©ä½™ç–‘é—® Q2) |
| E1 | éªŒæ”¶ï¼šå…ˆå›žå½’“生成 2026-07 == å·²ä¿®æ­£æ¯ç‰ˆ diff=0”,再过 8 æœˆ |
| E1 | éªŒæ”¶ï¼šå…ˆå›žå½’“生成 2026-07 == å·²ä¿®æ­£æ¯ç‰ˆ diff=0”,再过 8 æœˆ **【2026-10-13 å·²ä¿®è®¢ï¼šæ¯ç‰ˆå·²éª¨æž¶åŒ–,不再作等价基线;验收改按四层口径,见文末第八节】** |
| E2 | 8 æœˆéªŒè¯ï¼šç³»ç»Ÿè‡ªåŠ¨ç”Ÿæˆ vs äººå·¥æ¯”对;逐市州加总=全省;与源月报逐项对齐 |
| E3 | ç¼ºæ•°â†’汇总表留空+提示清单;轨迹测算等审核脏数据不进入大表 |
@@ -74,3 +74,24 @@
- ä»¥æœ¬ã€Šç¡®è®¤å›žæ‰§æ±‡æ€»â€¦ã€‹æ–‡æ¡£ä¸ºå”¯ä¸€å†³ç­–基线;凡已在 A1-E3 / Q1-Q3 / æœ¬è¡¨ç¡®è®¤è¿‡çš„口径,直接执行,不再重复提问。
- åªå°±æ–‡æ¡£æœªè¦†ç›–的新决策向用户提问。
## å…«ã€2026-10-13 ä¿®è®¢ï¼šéªŒæ”¶å£å¾„(替代 E1 åŽŸæŽªè¾žï¼‰
**修订原因**:E1 å†™äºŽæ”¹é€ åˆæœŸï¼Œå½“时「母版 â‰ˆ æ‰‹å·¥å®šç¨¿ä»¶ã€ï¼Œæ•…「生成 == æ¯ç‰ˆ diff=0」成立。此后母版被骨架化
(当期列留空、排名页与四张合成页存的是上一期静态值),相等关系不再成立(实测:生成 2026-09 ä¸Žæ¯ç‰ˆç›¸æ¯”,
差异全部落在「母版留空由生成回填」与「排名/合成页按当期重算」两类,**生成空、母版有值 = 0 å¤„,无丢数**)。
**新口径:验收=四层(全部通过才可交付)**
| å±‚ | æŸ¥ä»€ä¹ˆ | çœŸå€¼/依据 | é€šè¿‡æ ‡å‡† |
|---|---|---|---|
| L1 ç»“构断言 | å…¨ç°¿å…¬å¼ä¸Žè¡¨å¤´ | è¡¨å¤´æœ¬èº« | ç´¯è®¡=当年 1..M æœˆä¹‹å’Œï¼›ç´¯è®¡åŒæ¯”分母=去年 1..M æœˆï¼›**历史列(去年及以前)零变化**;无月首日期串写入;`#REF!`=0 |
| L2 å°è´¦æ•°å€¼ | 8 ä¸ªå¯è§å°è´¦é¡µ | **当期业务源表** | é€æ ¼ diff=0;有差异须逐条给出结论并签核 |
| L3 å†…部勾稽 | å…¨ç°¿ | ç”± L2 æŽ¨å¯¼ | å…¨çœ=Σ市州;累计=Σ各月;排名/占比自洽 |
| L4 è·¨æœŸæŠ¥è­¦ | å…¨ç°¿ | ä¸ŠæœŸä»¶ | åŒå£å¾„环比超阈值(默认 Â±30%,基数≥100)即提示 |
- **母版只作版式来源,不作验收基线**;验收基线 = å½“期业务源表 + ä¸šåŠ¡ç¡®è®¤åŽçš„å®šç¨¿ä»¶ï¼ˆå»ºè®®å…¥åº“ä¸º
  `docs/生成汇总大表/基线/golden_YYYY-MM.xlsx`)。
- **白名单机制**:已知且已解释的差异(如轮渡块形态、母版模板遗留日期值、源表自带脏值被清空)显式列入白名单;
  **白名单之外的任何差异=不通过**,不再靠人工逐条解释。
- å·¥å…·ï¼š`汇总表四层验收.py`(用法见文件头);2026-09 å®žæµ‹ï¼šL1/L2/L3/L4 å…¨é€šè¿‡ï¼Œç™½åå• 36 æ¡ã€‚
- è¯´æ˜Žï¼šæœ¬å£å¾„保证「程序不把对的数据搞错 + ä¸Žæºè¡¨ä¸€è‡´ + å¯è¿½æº¯ã€ï¼›**数据本身的正确性**仍由导入审核 + ä¸šåŠ¡å£å¾„ç¡®è®¤ + æ¯æœŸç­¾æ ¸è´Ÿè´£ã€‚
docs/ÒªÇóÓëÑéÊÕ»ùÏßÇåµ¥.md
@@ -33,9 +33,10 @@
|---|---|---|---|
| R1 | é€æœˆæ‰©å±•生成;保留母版缓存与母版程序复制;累计公式去掉未到月份、同比分母联动 | âœ… M0/M1 | |
| R2 | ç”Ÿæˆâ€œé“路运输量汇总表(整本)”= å½“月表 + ç´¯è®¡ï¼ˆ1-X月)| âœ… | |
| R3 | ç”Ÿæˆç‰©ä¸Žæ¯ç‰ˆ/库 0 å·®å¼‚为验收标准(含中口径分析页、排名页累计块) | âœ… 7月回归 | |
| R3 | ç”Ÿæˆç‰©ä¸Žæ¯ç‰ˆ/库 0 å·®å¼‚为验收标准(含中口径分析页、排名页累计块) | âœ… 7月回归(**2026-10-13 å·²ä¿®è®¢**:母版已「骨架化」=当期列留空+排名/合成页存上期静态值,不能再当等价基线;验收改按 R6) | |
| R4 | ç‹¬ç«‹å°è¡¨ä¿ç•™ä½†å‰ç«¯éšè—ï¼Œç­‰å¤§è¡¨è·‘通后可取消 | âœ… æ‹æ¿ | |
| R5 | æŽ’名/分析/汇总的**当月数据**要与累计数据分别计算,禁止把累计值当当月值 | ðŸš§ M2/M3 å®žå¼¹ | |
| R6 | **验收=四层**(2026-10-13 å®šï¼Œæ›¿ä»£åŽŸ E1/R3 çš„“与母版 diff=0”):L1 ç»“构断言(累计=当年1..M月之和、累计同比分母=去年1..M月、历史列零变化、无月首日期串/无错误值);L2 ä¸Ž**当期业务源表**逐格比对(0 å·®å¼‚,差异须逐条结论并签核);L3 å†…部勾稽(全省=Σ市州、累计=Σ各月、排名/占比自洽);L4 è·¨æœŸæŠ¥è­¦ï¼ˆä¸Žä¸ŠæœŸä»¶åŒå£å¾„环比,超阈值提示——唯一能兜“没见过的新错误”的一层)。工具:`汇总表四层验收.py`;**白名单之外的任何差异=不通过**;母版只作版式来源、不作等价基线 | ðŸš§ å·¥å…·å·²å°±ç»ªï¼ŒE1 æŽªè¾žå·²åŒæ­¥ä¿®è®¢ | |
## å››ã€å®¡æ ¸è§„则
| ç¼–号 | è¦æ±‚ | çŠ¶æ€ | å¤‡æ³¨ |
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryWorkbookFiller.java
@@ -50,6 +50,9 @@
    /** 2026 å¹´ 1 æœˆå½“月值列(0 åŸºï¼Œå³ C åˆ—) */
    private static final int FIRST_MONTH_COL = 2;
    /** è¡¨å¤´è¡Œæ‰«æèŒƒå›´ï¼ˆå…¼å®¹â€œä¸€é¡µä¸¤å—”的轨道、轮渡:第 2 å—表头在第 11 è¡Œï¼‰ */
    private static final int HDR_SCAN_ROWS = 15;
    /** å›žå¡« 1..keepMonths æœˆå°è´¦é¡µï¼Œè¿”回写入格数;缺数提示写入 problems */
    public int fillMonthlyLedger(XSSFWorkbook wb, String period, int year, int keepMonths, List<String> problems) {
        if (wb == null || keepMonths <= 0) return 0;
@@ -837,6 +840,186 @@
        return false;
    }
    /**
     * ã€Œç´¯è®¡ / ç´¯è®¡åŒæ¯”」按表头重建(2026-10-13 æ–°å¢žï¼Œæ ¹æ²»â€œç´¯è®¡æ¼æœˆâ€ï¼‰ã€‚
     *
     * <p>背景:当月列由“按月扩列器”插入时只在旧公式末尾追加当月项、不重建;一旦历史上漏过某月
     * ï¼ˆæ‰©åˆ—器早期版本把含巧合日期值的数据行误判成表头行从而整行跳过——2026-09 çš„
     * å…¬äº¤!U8、出租!U6、中口径明细!U5 å³æ­¤ï¼‰ï¼Œæ¼é¡¹ä¼šæ°¸ä¹…保留并逐月累积。
     *
     * <p>本方法在生成收尾时按表头重建公式,底稿里的历史欠账自动补齐,且不再依赖底稿公式本身正确:
     * <ul>
     *   <li>「累计」:公式引用全部落在本月度块的“当年当月值列”上时,重写为当年 1..month å„月值之和;</li>
     *   <li>「累计同比」:公式引用只用到本月度块的“去年月份列 + æœ¬è¡Œç´¯è®¡åˆ—”时,分母重写为去年 1..month å„月值之和;</li>
     *   <li>已经完整的公式零改动;跨页引用等其它形态不动,只计数告警,避免误伤。</li>
     * </ul>
     * å¤šå—页(轨道、轮渡一页两块)按“该行上方最近表头行”确定所属月份块。
     */
    public int repairCumulativeFormulas(XSSFWorkbook wb, int year, int month) {
        if (wb == null || month < 1) return 0;
        int fixed = 0, skipped = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet sh = wb.getSheetAt(i);
            if (sh == null || !isMonthLedgerSheet(sh.getSheetName())) continue;
            List<Integer> hdrs = headerRowsOf(sh, year);
            for (int hi = 0; hi < hdrs.size(); hi++) {
                int h = hdrs.get(hi);
                int blockEnd = (hi + 1 < hdrs.size()) ? hdrs.get(hi + 1) - 1 : sh.getLastRowNum();
                Map<Integer, Integer> cur = new LinkedHashMap<>();
                Map<Integer, Integer> lastY = new LinkedHashMap<>();
                monthColsOfRow(sh, h, year, month, cur, lastY);
                if (cur.isEmpty() || lastY.isEmpty()) continue;
                int lastMonthCol = 0;
                for (int c : cur.values()) lastMonthCol = Math.max(lastMonthCol, c);
                Integer cumCol = cumulativeColOfRow(sh, h, lastMonthCol);
                if (cumCol == null) continue;
                Integer cumYoyCol = cumYoyColOfRow(sh, h, cumCol);
                java.util.Set<Integer> curSet = new java.util.HashSet<>(cur.values());
                java.util.Set<Integer> lastSet = new java.util.HashSet<>(lastY.values());
                for (int r = h + 1; r <= blockEnd; r++) {
                    Row row = sh.getRow(r);
                    if (row == null) continue;
                    Cell cum = row.getCell(cumCol);
                    if (cum != null && cum.getCellType() == org.apache.poi.ss.usermodel.CellType.FORMULA) {
                        java.util.Set<Integer> used = sameRowRefCols(cum.getCellFormula(), r + 1);
                        if (!used.isEmpty()) {
                            if (used.equals(curSet)) {
                                // å·²å®Œæ•´ï¼Œé›¶æ”¹åЍ
                            } else if (curSet.containsAll(used)) {
                                String nf = refsJoined(cur, r + 1);
                                if (nf != null && !nf.equals(cum.getCellFormula())) {
                                    cum.setCellFormula(nf);
                                    fixed++;
                                }
                            } else {
                                skipped++;
                            }
                        }
                    }
                    if (cumYoyCol != null) {
                        Cell cy = row.getCell(cumYoyCol);
                        if (cy != null && cy.getCellType() == org.apache.poi.ss.usermodel.CellType.FORMULA) {
                            java.util.Set<Integer> used = sameRowRefCols(cy.getCellFormula(), r + 1);
                            used.remove(cumCol);
                            if (!used.isEmpty()) {
                                if (used.equals(lastSet)) {
                                    // å·²å®Œæ•´ï¼Œé›¶æ”¹åЍ
                                } else if (lastSet.containsAll(used)) {
                                    String den = refsJoined(lastY, r + 1);
                                    if (den != null) {
                                        String nf = cellRef(cumCol, r + 1) + "/SUM(" + den + ")-1";
                                        if (!nf.equals(cy.getCellFormula())) {
                                            cy.setCellFormula(nf);
                                            fixed++;
                                        }
                                    }
                                } else {
                                    skipped++;
                                }
                            }
                        }
                    }
                }
            }
        }
        if (fixed > 0 && log != null) {
            log.info("汇总工作簿:累计/累计同比按表头重建 {} æ ¼ï¼ˆç¬¬ {} æœˆï¼›å½¢æ€ä¸ç¬¦è·³è¿‡ {} æ ¼ï¼‰", fixed, month, skipped);
        } else if (skipped > 0 && log != null) {
            log.warn("汇总工作簿:{} æ ¼ç´¯è®¡/累计同比公式形态不符,未自动重建(需人工确认)", skipped);
        }
        return fixed;
    }
    /** è¡¨å¤´è¡Œï¼ˆ0 åŸºï¼‰ï¼šå…ˆå–“月份格最多的那一行”为基准,只有这些列上过半是月份格的行才算表头行(同扩列器口径) */
    private List<Integer> headerRowsOf(Sheet sh, int year) {
        int last = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + HDR_SCAN_ROWS);
        List<Integer> base = new ArrayList<>();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            List<Integer> cols = monthHeaderCols(sh.getRow(r), year);
            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 = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            int hit = 0;
            for (int c : base) if (yearMonthOf(row.getCell(c), year) != null) hit++;
            if (hit >= need) rows.add(r);
        }
        return rows;
    }
    private List<Integer> monthHeaderCols(Row row, int year) {
        List<Integer> cols = new ArrayList<>();
        if (row == null) return cols;
        for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
            if (yearMonthOf(row.getCell(c), year) != null) cols.add(c);
        }
        return cols;
    }
    /** è¡¨å¤´è¡Œ -> å½“å¹´ 1..month æœˆå€¼åˆ— / å޻年 1..month æœˆå€¼åˆ—(同月取最左一列) */
    private void monthColsOfRow(Sheet sh, int hdrRow, int year, int month,
                                Map<Integer, Integer> cur, Map<Integer, Integer> lastY) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return;
        for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
            Integer ym = yearMonthOf(row.getCell(c), year);
            if (ym == null) continue;
            int y = ym / 100, m = ym % 100;
            if (m < 1 || m > month) continue;
            if (y == year && !cur.containsKey(m)) cur.put(m, c);
            else if (y == year - 1 && !lastY.containsKey(m)) lastY.put(m, c);
        }
    }
    /** è¯¥è¡¨å¤´è¡Œã€Œç´¯è®¡ã€åˆ—(0 åŸºï¼‰ï¼šæœ€åŽä¸€ä¸ªæœˆä»½åˆ—右侧 3 åˆ—内含“累计”且不含“同比”的表头格 */
    private Integer cumulativeColOfRow(Sheet sh, int hdrRow, int lastMonthCol) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return null;
        int end = Math.min(row.getLastCellNum() - 1, lastMonthCol + 3);
        for (int c = lastMonthCol + 1; c <= end; c++) {
            String t = text(row.getCell(c));
            if (t == null) continue;
            t = t.trim();
            if (t.contains("累计") && !t.contains("同比") && !YM_RANGE_CUM.matcher(t).matches()) return c;
        }
        return null;
    }
    /** ã€Œç´¯è®¡ã€å³é‚»ï¼ˆæˆ–再右一格)是否为「累计同比」 */
    private Integer cumYoyColOfRow(Sheet sh, int hdrRow, int cumCol) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return null;
        String t = text(row.getCell(cumCol + 1));
        if (t != null && t.trim().contains("同比")) return cumCol + 1;
        String t2 = text(row.getCell(cumCol + 2));
        return (t2 != null && t2.trim().contains("同比")) ? cumCol + 2 : null;
    }
    /** å…¬å¼ä¸­â€œå¼•用同一行”的列号集合(0 åŸºï¼‰ï¼›SUM ç­‰å‡½æ•°ååŽæ— æ•°å­—,不会被误匹配 */
    private java.util.Set<Integer> sameRowRefCols(String formula, int row1) {
        java.util.Set<Integer> cols = new java.util.HashSet<>();
        if (formula == null) return cols;
        java.util.regex.Matcher m = A1_REF.matcher(formula);
        while (m.find()) {
            if (Integer.parseInt(m.group(4)) == row1) cols.add(colIndexOf(m.group(2)));
        }
        return cols;
    }
    /** æŠŠ {月->列} æŒ‰æœˆä»½é¡ºåºæ‹¼æˆ "C5+E5+G5..." */
    private String refsJoined(Map<Integer, Integer> monthToCol, int row1) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<Integer, Integer> e : new java.util.TreeMap<>(monthToCol).entrySet()) {
            if (sb.length() > 0) sb.append('+');
            sb.append(cellRef(e.getValue(), row1));
        }
        return sb.length() == 0 ? null : sb.toString();
    }
    /** è¯¥é¡µã€Œç´¯è®¡ / ç´¯è®¡åŒæ¯”」两列(0 åŸºï¼‰ï¼ŒæŒ‰è¡¨å¤´å‰ 5 è¡Œæ‰¾ â€œç´¯è®¡â€ æˆ– â€œ{year}年累计”,且其右列含 â€œåŒæ¯”” */
    private int[] cumulativeCols(Sheet sh, int year) {
        int last = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -3767,30 +3767,218 @@
            int monthFormulaFilled = summaryWorkbookFiller.ensureMonthFormulas(wb, year, effMonth);
            // ç´¯è®¡åŒæ¯”分母修正:母版把「去年各月」与「去年1-M月累计」辅助列同时计入(重复计/漏当月)
            int cumYoyFixed = summaryWorkbookFiller.repairCumulativeYoyFormulas(wb, year, effMonth);
            // 2026-10-13:累计列 / ç´¯è®¡åŒæ¯”分母按表头重建(根治扩列器早期“累计漏月”的历史欠账)
            int cumRebuilt = summaryWorkbookFiller.repairCumulativeFormulas(wb, year, effMonth);
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month", dbProblems);
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // å››å¼ è·¨é¡µåˆæˆé¡µéšæŠ¥è¡¨æœŸåˆ·æ–°
            // 2026-10-12:停用「套用外部参照件《生成_道路运输量汇总表.xlsx》列宽」——版式一律以当期母版为准,
            // é¿å…æ¯ç‰ˆä¸Žå‚照件两处维护(母版已并入参照件的列宽/隐藏)。
            int widthApplied = 0;
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "修正累计同比分母格数={},重建累计/累计同比格数={},刷新排名页数值格数={},"
                    + "刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, cumRebuilt, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
            applyTwoDecimalFormat(wb); // æ•°å€¼æ˜¾ç¤ºä¸¤ä½å°æ•°ï¼ˆä¿ç•™å…¨ç²¾åº¦ï¼‰ï¼›% / æ—¥æœŸç­‰æ—¢æœ‰æ ·å¼ä¸æ”¹å˜
            int inlineFixed = normalizeStaleInlineStrings(wb); // æ¸…掉「非 inlineStr å´æ®‹ç•™ <is>」的非法格,否则 Excel æ‹’绝打开
            if (inlineFixed > 0) log.warn("汇总工作簿清掉残留 inlineStr éžæ³•æ ¼ {} ä¸ªï¼ˆæ¯ç‰ˆç”± openpyxl å†™å‡ºæ—¶çš„典型问题)", inlineFixed);
            reportProgress(progress, 70, "重算整本公式(约 4 ä¸‡æ ¼ï¼‰");
            int[] recalc = recalcWorkbookFormulas(wb); // å…¬å¼é‡ç®—并写回缓存值:全省行(Σ市州)/累计列/排名 SUM æ‰èƒ½è¢«é¢„览与二次处理读到
            setSheetViewsToCurrentMonth(wb, year, effMonth); // æ‰“开时定位到本期数据列(不影响数据与格式)
            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);
            return normalizeSheetViews(toBytes(wb), wb, year, effMonth);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /**
     * æ‰“开工作簿时的定位(2026-10-13):把每个页签的滚动/选中位置归一到「本期数据列」,
     * å¹¶æ¸…掉母版(被 Excel/WPS å­˜è¿‡ï¼‰æ®‹ç•™çš„æ—§ topLeftCell/activeCell â€”— å¦åˆ™æ¯ä¸ªé¡µç­¾ä¼šåœåœ¨
     * ä¸Šæ¬¡ç¼–辑时的奇怪位置(实测母版里 åŸŽå¸‚客运 BQ38、中口径明细 CC103、货运排名 A40…)。
     * å†»ç»“拆分(xSplit/ySplit/state)原样保留,只把锚点挪到本期月份列。
     */
    private byte[] normalizeSheetViews(byte[] xlsx, XSSFWorkbook wb, int year, int month) {
        try {
            java.util.Map<String, String> targets = sheetViewTargets(wb, year, month);
            if (targets.isEmpty()) return xlsx;
            java.util.Map<String, byte[]> entries = new java.util.LinkedHashMap<>();
            try (java.util.zip.ZipInputStream zin = new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(xlsx))) {
                java.util.zip.ZipEntry e;
                byte[] buf = new byte[8192];
                while ((e = zin.getNextEntry()) != null) {
                    java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream();
                    int n;
                    while ((n = zin.read(buf)) > 0) b.write(buf, 0, n);
                    entries.put(e.getName(), b.toByteArray());
                }
            }
            byte[] wbx = entries.get("xl/workbook.xml");
            byte[] rlx = entries.get("xl/_rels/workbook.xml.rels");
            if (wbx == null || rlx == null) return xlsx;
            String wbs = new String(wbx, java.nio.charset.StandardCharsets.UTF_8);
            String rls = new String(rlx, java.nio.charset.StandardCharsets.UTF_8);
            java.util.Map<String, String> rid2t = new java.util.HashMap<>();
            java.util.regex.Matcher rm = java.util.regex.Pattern.compile("Id=\"([^\"]+)\"[^>]*Target=\"([^\"]+)\"").matcher(rls);
            while (rm.find()) rid2t.put(rm.group(1), rm.group(2));
            java.util.Map<String, String> name2part = new java.util.LinkedHashMap<>();
            java.util.regex.Matcher sm = java.util.regex.Pattern.compile("<sheet[^>]*name=\"([^\"]+)\"[^>]*r:id=\"([^\"]+)\"").matcher(wbs);
            while (sm.find()) {
                String t = rid2t.get(sm.group(2));
                if (t == null) continue;
                if (t.startsWith("/")) t = t.substring(1);
                else if (!t.startsWith("xl/")) t = "xl/" + t;
                name2part.put(sm.group(1), t);
            }
            int patched = 0;
            for (java.util.Map.Entry<String, String> en : name2part.entrySet()) {
                String target = targets.get(en.getKey());
                byte[] part = entries.get(en.getValue());
                if (target == null || part == null) continue;
                String xml = new String(part, java.nio.charset.StandardCharsets.UTF_8);
                String np = patchSheetView(xml, target);
                if (!np.equals(xml)) { entries.put(en.getValue(), np.getBytes(java.nio.charset.StandardCharsets.UTF_8)); patched++; }
            }
            try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(xlsx.length);
                 java.util.zip.ZipOutputStream zout = new java.util.zip.ZipOutputStream(bos)) {
                for (java.util.Map.Entry<String, byte[]> en : entries.entrySet()) {
                    zout.putNextEntry(new java.util.zip.ZipEntry(en.getKey()));
                    zout.write(en.getValue());
                    zout.closeEntry();
                }
                zout.finish();
                log.info("汇总工作簿:页签打开位置已归一到本期({}å¹´{}月),处理 {} ä¸ªé¡µç­¾", year, month, patched);
                return bos.toByteArray();
            }
        } catch (Exception ex) {
            log.warn("汇总工作簿:页签打开位置归一失败(忽略,保留原样):{}", ex.toString());
            return xlsx;
        }
    }
    /** æ¯é¡µçš„定位目标:有本期月份列 â†’ è¯¥æœˆè¡¨å¤´æ ¼ï¼›æ²¡æœ‰ï¼ˆæŽ’名/合成页)→ A1(顺带清掉残留位置) */
    private java.util.Map<String, String> sheetViewTargets(XSSFWorkbook wb, int year, int month) {
        java.util.Map<String, String> m = new java.util.LinkedHashMap<>();
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            XSSFSheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            int headerRow = -1, col = -1;
            int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
            for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                    Integer ym = smYearMonthOf(row.getCell(c));
                    if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; }
                }
            }
            m.put(sh.getSheetName(), col >= 0 ? a1Ref(headerRow, col) : "A1");
        }
        return m;
    }
    /** 0 åŸºè¡Œåˆ— -> "S3" */
    private String a1Ref(int row0, int col0) {
        StringBuilder sb = new StringBuilder();
        int n = col0 + 1;
        while (n > 0) { int rem = (n - 1) % 26; sb.insert(0, (char) ('A' + rem)); n = (n - 1) / 26; }
        return sb.toString() + (row0 + 1);
    }
    /** åŽ»æŽ‰å±žæ€§ */
    private String stripAttr(String attrs, String name) {
        int i = attrs.indexOf(name + "=\"");
        if (i < 0) return attrs;
        int e = attrs.indexOf('"', i + name.length() + 2);
        if (e < 0) return attrs;
        return attrs.substring(0, i) + attrs.substring(e + 1);
    }
    /** æ‰¾æ ‡ç­¾èµ·ç‚¹ï¼šè¦æ±‚标签名后面紧跟 ç©ºæ ¼/>,避免 "<sheetView" å‘½ä¸­ "<sheetViews" */
    private int findTag(String xml, String tag) {
        int from = 0;
        while (true) {
            int i = xml.indexOf(tag, from);
            if (i < 0) return -1;
            int nx = i + tag.length();
            char ch = nx < xml.length() ? xml.charAt(nx) : '>';
            if (ch == ' ' || ch == '>' || ch == '/') return i;
            from = i + 1;
        }
    }
    /** å–属性值(不存在返回 null) */
    private String attrValue(String attrs, String name) {
        int i = attrs.indexOf(name + "=\"");
        if (i < 0) return null;
        int e = attrs.indexOf('"', i + name.length() + 2);
        return e < 0 ? null : attrs.substring(i + name.length() + 2, e);
    }
    /** è¿½åŠ å±žæ€§ï¼ˆå·²å­˜åœ¨åˆ™ä¸é‡å¤åŠ ï¼‰ */
    private String addAttr(String attrs, String name, String v) {
        if (attrValue(attrs, name) != null) return attrs;
        String sep = (attrs.isEmpty() || attrs.endsWith(" ")) ? "" : " ";
        return attrs + sep + name + "=\"" + v + "\"";
    }
    /**
     * å•页 XML å½’一:sheetView / pane çš„ topLeftCell、活动 pane çš„ selection å…¨éƒ¨æŒ‡å‘ target。
     * å†»ç»“拆分(xSplit/ySplit/state)原样保留;破坏结构则返回原文(failed-safe)。
     */
    private String patchSheetView(String xml, String target) {
        String orig = xml;
        int i = findTag(xml, "<sheetView");
        if (i >= 0) {
            int j = xml.indexOf('>', i);
            if (j > 0) {
                String attrs = addAttr(stripAttr(xml.substring(i + 10, j), "topLeftCell"), "topLeftCell", target);
                xml = xml.substring(0, i) + "<sheetView" + attrs + ">" + xml.substring(j + 1);
            }
        }
        String activePane = null;
        int p = findTag(xml, "<pane");
        if (p >= 0) {
            int q = xml.indexOf("/>", p);
            if (q > 0) {
                String raw = xml.substring(p + 5, q);
                activePane = attrValue(raw, "activePane");
                String attrs = addAttr(stripAttr(raw, "topLeftCell"), "topLeftCell", target);
                xml = xml.substring(0, p) + "<pane" + attrs + "/>" + xml.substring(q + 2);
            }
        }
        StringBuilder sb = new StringBuilder();
        int last = 0, pos = 0;
        boolean done = false, any = false;
        while (true) {
            int s0 = xml.indexOf("<selection", pos);
            if (s0 < 0) break;
            int s1 = xml.indexOf("/>", s0);
            if (s1 < 0) break;
            String raw = xml.substring(s0 + 10, s1);
            String attrs = stripAttr(stripAttr(raw, "activeCell"), "sqref");
            String pane = attrValue(raw, "pane");
            boolean hit = (activePane != null) ? (pane != null && pane.equals(activePane)) : !done;
            if (hit) { attrs = addAttr(addAttr(attrs, "activeCell", target), "sqref", target); done = true; }
            sb.append(xml, last, s0).append("<selection").append(attrs).append("/>");
            last = s1 + 2;
            pos = s1 + 2;
            any = true;
        }
        if (!any) {
            int close = xml.indexOf("</sheetView>");
            if (close > 0) xml = xml.substring(0, close) + "<selection activeCell=\"" + target + "\" sqref=\"" + target + "\"/>" + xml.substring(close);
        } else {
            sb.append(xml, last, xml.length());
            xml = sb.toString();
        }
        if (!xml.contains("<sheetViews>") || !xml.contains("</sheetViews>")) return orig;
        return xml;
    }
    /**
@@ -4062,11 +4250,11 @@
     * çš„结果刷新数值,避免汇总表里的排名页停留在旧月份。
     * æ•°å€¼æ ¼æ¬å€¼ã€å…¬å¼æ ¼æŒ‰åç§»å¹³ç§»å…¬å¼åŽå†™å…¥ï¼›ç›®æ ‡æ ¼æœ¬èº«å·²æ˜¯å…¬å¼çš„(SUM/RANK/占比/补数)保留不动,打开时按新数据重算。
     */
    private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode) throws Exception {
    private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode, List<String> problems) throws Exception {
        int n = 0;
        n += copyRankValues(wb, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}});
        n += copyRankValues(wb, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems);
        n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems);
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}}, problems);
        return n;
    }
@@ -4075,13 +4263,15 @@
     * srcBlocks[i] = æºå— i çš„èµ·å§‹ {行, åˆ—}(0 åŸºï¼‰ï¼›ç›®æ ‡å—按标题顺序(累计块在前、当月块在后)对应。
     * æ•°å€¼æ ¼ç›´æŽ¥æ¬å€¼ï¼›æºä¾§æ˜¯å…¬å¼çš„(排名/占比等)按目标块偏移整体平移后写入公式,打开时按汇总表自身数据重算。
     */
    private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks) throws Exception {
    private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks,
                              List<String> problems) throws Exception {
        XSSFSheet dst = dstWb.getSheet(dstSheetName);
        if (dst == null) return 0;
        List<XSSFCell> dstTitles = rankTitleCells(dst);
        if (dstTitles.isEmpty()) return 0;
        int copied = 0;
        FormulaEvaluator dstEv = dstWb.getCreationHelper().createFormulaEvaluator();
        List<int[]> anchors = new ArrayList<>(); // {源全省行, ç›®æ ‡å…¨çœè¡Œ, å—首列, åˆ—偏移, å—宽}
        try (InputStream sin = new ByteArrayInputStream(srcBytes);
             XSSFWorkbook srcWb = new XSSFWorkbook(sin)) {
            XSSFSheet src = srcWb.getSheetAt(0);
@@ -4091,10 +4281,27 @@
                int sr0 = srcBlocks[b][0];
                int sc0 = srcBlocks[b][1];
                XSSFCell dstTitle = dstTitles.get(b);
                int rowOff = dstTitle.getRowIndex() - sr0;
                int colOff = dstTitle.getColumnIndex() - sc0;
                int rowOff = dstTitle.getRowIndex() - sr0;
                // 2026-10-13:《货运周转量排名》母版块标题曾占两行(合并),"标题行+固定偏移"会把源行
                // æ•´ä½“写错位一行(全省值落到表头行、市州值与市州名错开)。改为用「全省」行做锚点对齐:
                // rowOff = ç›®æ ‡å…¨çœè¡Œ - æºå…¨çœè¡Œï¼›è¯†åˆ«ä¸åˆ°é”šç‚¹å†å›žé€€æ ‡é¢˜è¡Œåç§»å¹¶å‘Šè­¦ã€‚
                Integer srcAnchor = rankAnchorRow(src, sr0, sc0);
                Integer dstAnchor = rankAnchorRow(dst, dstTitle.getRowIndex(), dstTitle.getColumnIndex());
                if (srcAnchor != null && dstAnchor != null) {
                    int anchored = dstAnchor - srcAnchor;
                    if (anchored != rowOff) {
                        log.info("排名页《{}》第 {} å—:标题行偏移 {} ä¸Žã€Œå…¨çœã€é”šç‚¹åç§» {} ä¸ä¸€è‡´ï¼ŒæŒ‰é”šç‚¹å¯¹é½ï¼ˆæºå…¨çœè¡Œ={},目标全省行={})",
                                dstSheetName, b + 1, rowOff, anchored, srcAnchor + 1, dstAnchor + 1);
                    }
                    rowOff = anchored;
                } else {
                    log.warn("排名页《{}》第 {} å—:未识别到「全省」锚点(源={},目标={}),回退按标题行偏移 {} å¯¹é½",
                            dstSheetName, b + 1, srcAnchor, dstAnchor, rowOff);
                }
                int h = rankBlockHeight(src, sr0, sc0);
                int w = rankBlockWidth(src, sr0, sc0);
                anchors.add(new int[]{srcAnchor == null ? -1 : srcAnchor, dstAnchor == null ? -1 : dstAnchor, sc0, colOff, w});
                // 2026-10-12:整块缺数(源侧一个数值都没有,如 9 æœˆç¼ºâ‘¡â‘¢æ—¶çš„周转量排名)→ ç›®æ ‡å—只留标题与标签,
                // æ•°æ®/排名/占比一律留空;否则母版的 RANK/占比公式会在空块上算出「全 1 + #DIV/0!」。
                boolean blockHasData = false;
@@ -4173,6 +4380,34 @@
                    }
                }
            }
        // 2026-10-13 è‡ªæ£€ï¼šç›®æ ‡ã€Œå…¨çœã€è¡Œåº”与源侧一致(偏移错位、汇总区间不对时报警并写进缺数清单)
        for (int[] an : anchors) {
            if (an[0] < 0 || an[1] < 0) continue;
            Row srr = src.getRow(an[0]);
            Row drr = dst.getRow(an[1]);
            if (srr == null || drr == null) continue;
            int badCols = 0;
            StringBuilder ex = new StringBuilder();
            for (int c = 0; c < an[4]; c++) {
                Cell sCell = srr.getCell(an[2] + c);
                Cell dCell = drr.getCell(an[2] + c + an[3]);
                Double sv = sCell == null ? null : rankNumeric(sCell, ev);
                Double dv = dCell == null ? null : rankNumeric(dCell, dstEv);
                if (sv == null && dv == null) continue;
                if (sv == null || dv == null || Math.abs(sv - dv) > Math.max(1.0, Math.abs(sv) * 1e-4)) {
                    badCols++;
                    if (badCols <= 3) {
                        if (ex.length() > 0) ex.append(";");
                        ex.append(colLetter(an[2] + c)).append(" æº=").append(sv).append(" ç›®æ ‡=").append(dv);
                    }
                }
            }
            if (badCols >= 2) {
                String msg = "排名页《" + dstSheetName + "》「全省」行与源侧不一致(" + badCols + " åˆ—):" + ex;
                log.warn(msg);
                if (problems != null) problems.add("生成校验:" + msg);
            }
        }
        }
        return copied;
    }
@@ -4239,6 +4474,23 @@
    }
    /** æŽ’名页里的“块标题”单元格(含“全省分市州”的说明文字),按行、列顺序返回 */
    /** åœ¨ [fromRow, fromRow+40) èŒƒå›´å†…、块起始两列里找整格文本为「全省」的行号(0 åŸºï¼‰ï¼›æ‰¾ä¸åˆ°è¿”回 null。
     *  2026-10-13:排名页块结构可能因母版版式不同而整体上/下移一行,用「全省」行做锚点可自动对齐。 */
    private Integer rankAnchorRow(Sheet sh, int fromRow, int col0) {
        int last = Math.min(sh.getLastRowNum(), fromRow + 40);
        for (int r = fromRow; r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            for (int c = Math.max(0, col0); c <= col0 + 1; c++) {
                Cell cell = row.getCell(c);
                if (cell == null || cell.getCellType() != CellType.STRING) continue;
                String v = cell.getStringCellValue();
                if (v != null && v.replace(" ", "").equals("全省")) return r;
            }
        }
        return null;
    }
    private List<XSSFCell> rankTitleCells(Sheet sh) {
        List<XSSFCell> out = new ArrayList<>();
        for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) {
@@ -4563,6 +4815,34 @@
        return cols;
    }
    /**
     * æ‰“开工作簿时把每个页签定位到「本期数据」位置(2026-10-13 ç”¨æˆ·è¦æ±‚)。
     * åšæ³•:凡能按表头识别出「当年当月值列」的页,把活动单元格设到该月表头格;其余页保持默认。
     * åªå½±å“æ‰“开时的滚动/选中位置,不改任何数据与格式。
     */
    private int setSheetViewsToCurrentMonth(XSSFWorkbook wb, int year, int month) {
        int n = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            XSSFSheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            int headerRow = -1, col = -1;
            int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
            for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                    Integer ym = smYearMonthOf(row.getCell(c));
                    if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; }
                }
            }
            if (col < 0) continue;
            sh.setActiveCell(new org.apache.poi.ss.util.CellAddress(headerRow, col));
            n++;
        }
        if (n > 0) log.info("汇总工作簿:{} ä¸ªé¡µç­¾å·²å®šä½åˆ°æœ¬æœŸï¼ˆ{}å¹´{}月)数据列", n, year, month);
        return n;
    }
    /** è¡¨å¤´å•元格 -> å¹´*100+月;只认「YYYYå¹´M月」文本或「某月 1 å·ã€çš„æ—¥æœŸåºåˆ—号,排除累计/同比/环比 */
    private Integer smYearMonthOf(Cell c) {
        if (c == null) return null;