feat(导入): 网约车订单报告支持 Word 2003 XML(WordML)+格式嗅探与友好提示

问题(用户报障):《湖北网络预约出租汽车2025年09月分析报告.docx》放到桌面后
导入一直失败。定位:该文件不是 docx,而是 Word 2003 XML(WordML 平铺 XML)
被改名成 .docx(3.1MB 纯 XML,非 zip 包),POI 的 XWPFDocument 只能读 OPC 包,
直接抛 "File is not a zip file"。监管平台/同事间流转的报告常是这种格式。

改动(DataImportService.java):
- importWycOrderReport() 入口按文件头嗅探格式后分派:
PK→docx;<w:wordDocument + wordml→新增 WordML DOM 解析路径;
D0CF11E0→明确提示「.doc 请另存为 docx」;其它→「无法识别」;
- 新增 isWordPackage():zip 但无 word/document.xml(如 xlsx 改名)给中文提示,
不再抛 XMLBeans 英文堆栈;
- 新增 parseXmlSafely():DOM 解析关闭外部实体/DTD,防 XXE;
- 表格匹配抽成与格式无关的 matchWycOrderTable(),docx 与 WordML 两条路径
共用同一套表头识别 / 17 市州取值 / 合计行逻辑,避免口径分叉;
- 报表期识别抽成 periodOfParagraphs(),两种格式共用。

验证:8 条用例全部通过(4 正向 / 3 负向 / 1 边界)。2025-09 WordML 报告导入成功,
订单合计 17,652,159、17 市州;抽查武汉 8,076,610 / 黄石 676,644 / 十堰 1,048,593 /
恩施 804,736 / 林区 1,424 与报告附件1 逐项一致;pin 从 wyc_total_monthly 同期复制。
真 docx(2026-08)重导幂等无变化。

附带效果:用户已补齐 2026-01~06 / 2025-01 / 2025-10~12 订单,加上本次 2025-09,
2025-01~12 + 2026-01~08 订单全部齐全;中口径维度 4(城际城乡网约车)1~6 月由
「无源」变为「可算」,中口径排名件与明细件累计口径自动归一(均 31528.0606)。
重跑 8 月导出回归:报表期内不一致 0、与汇总大表「中口径明细」页 704 对格 0 差异。

文档:新增《功能测试报告_网约车订单报告WordML兼容导入_2026-09-18.md》,
并更新《工作日结_2026-09-18.md》。
2个文件已修改
1个文件已添加
275 ■■■■■ 已修改文件
docs/功能测试报告/功能测试报告_网约车订单报告WordML兼容导入_2026-09-18.md 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-09-18.md 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_ÍøÔ¼³µ¶©µ¥±¨¸æWordML¼æÈݵ¼Èë_2026-09-18.md
New file
@@ -0,0 +1,72 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_网约车订单报告 WordML å…¼å®¹å¯¼å…¥_2026-09-18
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
**背景(用户报障)**:把《湖北网络预约出租汽车2025年09月分析报告.docx》放到桌面后用「网约车订单报告」导入,一直失败。
**定位结论**:该文件**不是真正的 docx**。它是 **Word 2003 XML(WordML å¹³é“º XML,单文件)**被改名成了 `.docx`:
- æ–‡ä»¶å¤´æ˜¯ `<?xml version="1.0" ...?> <?mso-application progid="Word.Document"?> <w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" ...>`
- 3.1MB çº¯ XML,**不是 zip åŒ…**;POI çš„ `XWPFDocument` åªèƒ½è¯» OPC(zip) åŒ…,于是抛 `File is not a zip file`。
- è¿™ç±»æ–‡ä»¶æ˜¯ WPS/Word「Word 2003 XML æ–‡æ¡£ã€æ ¼å¼ï¼Œç½‘约车监管平台/同事之间流转很常见,会反复出现。
**改动**:`traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java`
1. `importWycOrderReport()` å…¥å£å…ˆæŒ‰æ–‡ä»¶å¤´å—…探格式,再分派:
   - `PK` å¼€å¤´ â†’ docx(zip):原 `XWPFDocument` è·¯å¾„ï¼›
   - `<w:wordDocument` + `wordml` â†’ **WordML å¹³é“º XML:新增解析路径**;
   - `D0CF11E0` â†’ OLE2 è€ `.doc`:给出「请另存为 docx」的明确提示;
   - å…¶å®ƒ â†’ ã€Œæ— æ³•识别的文件格式」。
2. æ–°å¢ž `isWordPackage()`:zip ä½†åŒ…内没有 `word/document.xml`(如 xlsx æ”¹åï¼‰æ—¶ï¼Œç›´æŽ¥æç¤ºã€Œè¿™ä¸æ˜¯ Word æ–‡æ¡£â€¦â€¦å¤šåŠæ˜¯ Excel æˆ–其它格式」,不再抛出 XMLBeans çš„英文堆栈。
3. æ–°å¢ž `parseXmlSafely()`:DOM è§£æžå¹¶**关闭外部实体/DTD**(报告来自外部平台,防 XXE)。
4. è¡¨æ ¼åŒ¹é…é€»è¾‘抽成与格式无关的 `matchWycOrderTable(tables, orders, sumHolder)`:
   - docx è·¯å¾„把 `XWPFTable` æ–‡æœ¬åŒ–后调用;
   - WordML è·¯å¾„把 `<w:tbl>/<w:tr>/<w:tc>` æ–‡æœ¬åŒ–后调用;
   - è¡¨å¤´è¯†åˆ«ã€17 å¸‚州取值、合计行识别**完全复用同一套逻辑**,两条路径不会出现口径分叉。
5. æŠ¥è¡¨æœŸè¯†åˆ«æŠ½æˆ `periodOfParagraphs(List<String>)`,两种格式共用(标题 `网约车行业YYYYå¹´M月` ä¼˜å…ˆï¼Œå–不到回退「前 10 æ®µä»»æ„ YYYYå¹´M月」)。
## äºŒã€æµ‹è¯•环境
- åŽç«¯ 8090(`mvn -o compile` åŽé‡å¯ï¼ŒåŠ è½½æ–°ç±»ï¼›å¯åŠ¨æ—¥å¿— `Started TrafficAuditApplication`);数据库 `traffic_audit`(127.0.0.1:3308)。
- æŽ¥å£ï¼š`POST /api/import/wycOrderReport`(`file` + `period`,`period` å¯ä¸ºç©º â†’ ç”±æŠ¥å‘Šæ ‡é¢˜æŽ¨æ–­ï¼‰ã€‚
- æµ‹è¯•文件:`D:\桌面\湖北网络预约出租汽车2025å¹´09月分析报告.docx`(WordML,3,145,560 å­—节);`D:\05_数据分类\城市客运\8月\湖北网络预约出租汽车2026å¹´08月分析报告0910最终版.docx`(真 docx)。
- è¯æ®æ–‡ä»¶ï¼š`_tmp/_qa_out/`(命令输出见本报告表格;数据核对见 `_wyc_2025_09_row.txt`)。
## ä¸‰ã€ç”¨ä¾‹è¡¨
| ç¼–号 | ç±»åž‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
|---|---|---|---|---|---|
| 1 | æ­£å‘ | WordML「2025å¹´09月」报告,`period=2025-09` | è§£æžå‡º 17 å¸‚州订单,写入 2025-09 | `导入成功:报表期 2025-09,订单合计 17,652,159 å•,已写入 17 ä¸ªå¸‚å·ž` | é€šè¿‡ |
| 2 | æ­£å‘ | åŒä¸Šï¼Œ`period=` ç•™ç©º | ç”±æ ‡é¢˜ `网约车行业2025å¹´9月` æŽ¨æ–­å‡º 2025-09 | æŠ¥è¡¨æœŸ 2025-09,结果与用例 1 ä¸€è‡´ | é€šè¿‡ |
| 3 | æ­£å‘ | çœŸ docx「2026å¹´08月」报告重导 | ä¸Žåº“内既有值一致(幂等,不改变结果) | è®¢å•合计 30,508,652、武汉 11,550,551,与导入前完全相同 | é€šè¿‡ |
| 4 | æ­£å‘ | 2026å¹´06月报告 | è®¢å•合计 = æŠ¥å‘Šæ­£æ–‡ã€Œå…¨çœè®¢å•总数 2684.30 ä¸‡å•」 | åº“内 2026-06 = 26,843,037 | é€šè¿‡ |
| 5 | è´Ÿå‘ | `.doc`(OLE2 å¤åˆæ–‡æ¡£ï¼ŒWord 97-2003) | æ˜Žç¡®æç¤ºæ”¹ç”¨ docx | `这是 Word 97-2003 è€æ ¼å¼ï¼ˆ.doc),请用 WPS/Word æ‰“开后「另存为」docx æ ¼å¼å†å¯¼å…¥` | é€šè¿‡ |
| 6 | è´Ÿå‘ | xlsx æ”¹åæˆ `.docx` ä¸Šä¼  | æ˜Žç¡®æç¤ºä¸æ˜¯ Word | `这不是 Word æ–‡æ¡£ï¼ˆåŽ‹ç¼©åŒ…é‡Œæ²¡æœ‰ word/document.xml),多半是 Excel æˆ–其它格式……` | é€šè¿‡ |
| 7 | è´Ÿå‘ | éžç½‘约车报告的 docx(《投资表之间的关系.docx》) | æç¤ºæœªè§£æžåˆ°è®¢å•æ•° | `未解析到任何市州订单数,请确认上传的是《网约车行业YYYYå¹´M月运行监测情况分析报告》` | é€šè¿‡ |
| 8 | è¾¹ç•Œ | WordML ä¸­è¡¨æ ¼å« 16 åˆ—、表头在第 1 è¡Œã€å¸‚州名用简称(`武汉`/`林区`) | ç®€ç§°èƒ½æ˜ å°„到规范名(武汉市/神农架林区) | 17 å¸‚州全部识别,无缺失 | é€šè¿‡ |
**入库抽查**(报表期 2025-09,与报告附件1逐项对齐)
| é¡¹ | æŠ¥å‘Šé™„ä»¶1 | åº“内 `wyc_order_monthly` | ç»“论 |
|---|---|---|---|
| è®¢å•合计 | 17,652,159 | 17,652,159 | ä¸€è‡´ |
| æ­¦æ±‰ | 8,076,610 | 8,076,610 | ä¸€è‡´ |
| é»„石 | 676,644 | 676,644 | ä¸€è‡´ |
| åå ° | 1,048,593 | 1,048,593 | ä¸€è‡´ |
| æ©æ–½ | 804,736 | 804,736 | ä¸€è‡´ |
| ç¥žå†œæž¶æž—区 | 1,424 | 1,424 | ä¸€è‡´ |
| å…¨çœ pin(B~E) | æŠ¥å‘Šæ—  | 2794.31 / 2522.54 / 16394.73 / 12998.74(从 `wyc_total_monthly` åŒæœŸå¤åˆ¶ï¼‰ | ç¬¦åˆè®¾è®¡ |
## å››ã€å‘现的问题与处置
1. **格式兼容缺口(本次修复)**:WordML æŠ¥å‘Šæ— æ³•导入。已加解析路径;两种格式共用同一套表格匹配逻辑,避免口径分叉。
2. **错误信息不友好(本次修复)**:xlsx æ”¹ååŽæŠ¥ `org.apache.xmlbeans.XmlException: Element workbook@... is not a valid document@...`。已改为中文业务提示。
3. **20XX å¹´å‰åŽæŠ¥å‘Šæ ‡é¢˜æ ¼å¼ä¸åŒ**:2025 å¹´æŠ¥å‘Šæ ‡é¢˜æ˜¯ã€Œæ¹–北**网约车行业 2025å¹´9月** è¿è¡Œæƒ…况分析报告」,2026 å¹´æ˜¯ã€Œç½‘约车行业2026å¹´6月运行监测情况分析报告」。同一套正则均可命中,无需再改。
4. **数据缺口已由用户补齐**:2026-01~06(14:21 å¯¼å…¥ 6 ä»½ï¼‰ã€2025-01(14:47)、2025-10~12(14:49)均已入库;2025-09 ç”±æœ¬æ¬¡ä¿®å¤åŽè¡¥å…¥ï¼ˆ14:59 é¦–次、15:04 å¤æ ¸ï¼‰ã€‚
   - å…¨éƒ¨ 2025-01~12、2026-01~08 çš„订单与 pin çŽ°å·²é½å…¨ã€‚
   - å½±å“ï¼šä¸­å£å¾„维度 4(城际城乡网约车)1~6 æœˆç”±ã€Œæ— æºã€å˜ä¸ºã€Œå¯ç®—」,**中口径排名件与中口径明细件累计口径自动归一**(此前差值 2063.45 = 1~6 æœˆç½‘约车城乡)。
## äº”、结论
- WordML(Word 2003 XML æ”¹å .docx)报告现已可直接导入,17 å¸‚å·ž + åˆè®¡è§£æžæ­£ç¡®ã€å…¥åº“逐项对齐;真 docx è·¯å¾„无回归。
- 3 æ¡è´Ÿå‘用例均给出中文可操作提示(.doc / éž Word åŒ… / éžæœ¬æŠ¥å‘Šï¼‰ã€‚
- 8 ä¾‹ç”¨ä¾‹å…¨éƒ¨é€šè¿‡ï¼›`docs/功能测试报告/` ä¸‹å·²å½’档本报告。
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-09-18.md
@@ -60,6 +60,23 @@
那是控制台中文乱码造成的误读;母版/模板真实公式是正确的 `=[1]出租!C13-[1]出租!C15` / `=[1]网约车!C13-[1]网约车!C15`,
台账页「总量−城市内」Σ17 å¸‚å·ž = 535.83 / 333.39,与母版定稿全省行完全一致。**母版公式不需要修,之前"待修母版"的结论作废。**
### 5. ç½‘约车订单报告:Word 2003 XML(WordML)兼容导入(15:00)
**用户报障**:《湖北网络预约出租汽车2025年09月分析报告.docx》放在桌面导不进去。
**定位**:该文件根本不是 docx â€”— æ˜¯ **Word 2003 XML(WordML å¹³é“º XML)** è¢«æ”¹åæˆ `.docx`(文件头 `<?mso-application progid="Word.Document"?><w:wordDocument xmlns:w=".../word/2003/wordml">`,3.1MB çº¯ XML、不是 zip åŒ…),POI çš„ `XWPFDocument` åªèƒ½è¯» OPC(zip) åŒ… â†’ æŠ› `File is not a zip file`。监管平台/同事间流转的报告常是这种格式,会反复出现。
**改动(`DataImportService.java`)**:
- `importWycOrderReport()` å…¥å£æŒ‰æ–‡ä»¶å¤´å—…探格式分派:zip→docx;`<w:wordDocument`+`wordml`→**新增 WordML DOM è§£æžè·¯å¾„**;`D0CF11E0`→明确提示「.doc è¯·å¦å­˜ä¸º docx」;其它→「无法识别」;
- æ–°å¢ž `isWordPackage()`:zip ä½†æ—  `word/document.xml`(xlsx æ”¹åï¼‰â†’ ä¸­æ–‡æç¤ºï¼Œä¸å†æŠ› XMLBeans è‹±æ–‡å †æ ˆï¼›
- æ–°å¢ž `parseXmlSafely()`:DOM è§£æžå…³é—­å¤–部实体/DTD(防 XXE);
- è¡¨æ ¼åŒ¹é…æŠ½æˆä¸Žæ ¼å¼æ— å…³çš„ `matchWycOrderTable()`,docx ä¸Ž WordML ä¸¤æ¡è·¯å¾„**共用同一套表头识别/17 å¸‚州取值/合计行逻辑**,不会口径分叉;
- æŠ¥è¡¨æœŸè¯†åˆ«æŠ½æˆ `periodOfParagraphs()`,两种格式共用。
**验证**:8 æ¡ç”¨ä¾‹å…¨éƒ¨é€šè¿‡ï¼ˆè§ã€ŠåŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_网约车订单报告WordML兼容导入_2026-09-18》)。2025-09 å…¥åº“:订单合计 17,652,159;武汉 8,076,610 / é»„石 676,644 / åå ° 1,048,593 / æ©æ–½ 804,736 / æž—区 1,424,与报告附件1 é€é¡¹ä¸€è‡´ï¼›pin ä»Ž `wyc_total_monthly` åŒæœŸå¤åˆ¶ã€‚真 docx(2026-08)重导幂等无变化;`.doc`、xlsx æ”¹åã€éžæœ¬æŠ¥å‘Š docx ä¸‰ç§è´Ÿå‘均给出中文可操作提示。
**数据缺口已补齐(重要)**:用户在 14:21~14:49 ä¹‹é—´è‡ªè¡Œå¯¼å…¥ 2026-01~06(6 ä»½ï¼‰ã€2025-01、2025-10~12;2025-09 ç”±æœ¬æ¬¡ä¿®å¤åŽè¡¥å…¥ã€‚现在 **2025-01~12 + 2026-01~08 çš„订单与 pin å…¨éƒ¨é½å…¨**。直接效果:中口径维度 4 çš„ 1~6 æœˆä»Žã€Œæ— æºã€å˜ä¸ºã€Œå¯ç®—」,**中口径排名件与中口径明细件的累计口径自动归一**(两者 1-8 æœˆç´¯è®¡å‡ä¸º 31528.0606,此前差 2063.45)。
## äºŒã€æµ‹è¯•结果摘要
见 `docs/功能测试报告/功能测试报告_汇总表公式扩页与重算_2026-09-18.md`:8 æ¡æ­£å‘ + 4 æ¡è´Ÿå‘/边界 + 4 é¡¹å…¥åº“抽查,全部通过。
@@ -88,15 +105,18 @@
- å›žå½’:汇总大表 17 é¡µ **0 æ ¼å·®å¼‚**、《生成_中口径排名》**0 æ ¼å·®å¼‚**;
  æ˜Žç»†ä»¶å·®å¼‚仅落在 æ€»è¡Œ / å‡ºç§ŸåŸŽä¹¡ / ç½‘约车城乡 è¡Œçš„æœˆä»½åˆ—与 AA ç´¯è®¡åˆ—(属预期)。
### ç½‘约车订单报告 WordML å¯¼å…¥ï¼ˆ15:00)验证
- 8 æ¡ç”¨ä¾‹ï¼ˆ4 æ­£å‘ / 3 è´Ÿå‘ / 1 è¾¹ç•Œï¼‰å…¨éƒ¨é€šè¿‡ï¼šWordML 2025-09 å¯¼å…¥æˆåŠŸã€æŠ¥è¡¨æœŸç•™ç©ºæ—¶é æ ‡é¢˜è‡ªåŠ¨è¯†åˆ«ã€çœŸ docx å¹‚等无变化、`.doc`/xlsx æ”¹å/非本报告 docx å‡ç»™å‡ºä¸­æ–‡æç¤ºã€‚
- æ•°æ®è¡¥é½åŽé‡è·‘ 8 æœˆå¯¼å‡ºå›žå½’:独立《生成_中口径明细》报表期内不一致 0、报表期后仍有值 0;与汇总大表「中口径明细」页 **704 å¯¹æ ¼ 0 å·®å¼‚**;全省 1-8 æœˆæ€»å®¢è¿é‡ 3733.79…4003.57,网约车城乡 333.39…426.56,累计 AA5=31528.0607、AA9=2911.47。
- æŽ’名件与明细件累计口径一致(均 31528.0606),差值 2063.45 æ¶ˆå¤±ã€‚
## ä¸‰ã€é—留问题与待办
1. ~~中口径排名同比仍是写死值~~ â†’ **已按新口径取值**(M3=0.638 / Q3=0.2247),但仍与中口径明细的 0.0581 ä¸è‡ªæ´½ï¼Œå·®å€¼ 2063.45 = ç½‘约车 1-6 æœˆæ— æºï¼›**收口办法见下方"第 3 æ­¥æ”¶å£å»ºè®®"**。
2. ~~`loadMidClassMap()` ç¼ºå·¡æ¸¸å‡ºç§Ÿ + ç½‘约车两维~~ â†’ **已补**(维度 3、4),独立中口径明细四维齐全。
3. ~~**母版缺陷待修**:中口径明细城市级公式引错页~~ â†’ **误判,已作废**:真实公式是正确的跨簿引用,母版无需修改;当时的"引错页"是控制台中文乱码误读。
4. **数据缺口(已核实,属源头就没有)**:2026-01~06 ç½‘约车订单在**源文件**《模板_网约车订单及全省总量.xlsx》里就是空的(只有全省 pin、无订单列),
   ä¸æ˜¯å¯¼å…¥å¤±è´¥â€”—`wyc_order_monthly` è¯¥ 6 æœŸ `order_*` å…¨ NULL ä¸Žä¹‹å»åˆï¼ˆå¯¼å…¥æ‰¹æ¬¡ `WYC_ORDER`,09-04)。
   å½±å“é¢åªæœ‰ä¸€ä¸ªï¼š`WycSplitCalc.calc()` å›  `orderSum<=0` ç®—不出网约车拆分 â†’ ç»´åº¦ 4 è¯¥ 6 ä¸ªæœˆæ— æºï¼ˆçŽ°å·²ç”±ç¬¬ 4 æ¡æ”¹åŠ¨ä¿ç•™æ¯ç‰ˆå®šç¨¿å€¼å…œä½ï¼‰ã€‚
   **建议**:给网约车订单导入加一条「订单列全空」的显式提示(现在会静默成功),并请业务确认这 6 ä¸ªæœˆè®¢å•是否可补。
4. ~~**数据缺口**:2026-01~06 ç½‘约车订单未入库~~ â†’ **已补齐**(用户 14:21~14:49 å¯¼å…¥ 2026-01~06 + 2025-01 + 2025-10~12;2025-09 ç» WordML ä¿®å¤åŽè¡¥å…¥ï¼‰ã€‚现在 2025-01~12 + 2026-01~08 è®¢å•齐全,维度 4 å…¨æœŸå¯ç®—,排名件/明细件口径归一。仍建议补一条「导入时订单列全空」的显式提示(《模板_网约车订单及全省总量.xlsx》那类文件现在会静默成功)。
5. åº“内 h2031「全省」行 â‰  Î£17 å¸‚州(1-8 æœˆ 27524.49 vs 24481.74,差 3042.75):业务口径问题,建议业务侧核对。
6. æŠ•资:模板里没有的新项目不会自动插行;明细表资金渠道分组行未入库(需加 `fund_channel`);同名不同项目未弹窗。
7. æŠ•资两张明细表(`X月客运站投资明细表` / `X月物流站场投资明细`)生成器未做。
@@ -116,6 +136,6 @@
## å››ã€æ˜Žå¤©è®¡åˆ’
1. ç¬¬ 3 æ­¥æ”¶å£ï¼šæŽ’名件与明细件口径归一(差值 2063.45 = 2026 å¹´ 1-6 æœˆç½‘约车城乡)→ æŽ’名件改取汇总表同源值 â†’ å›ºåŒ–"生成件 vs æ¯ç‰ˆé‡ç®—"逐格回归脚本。
2. ç½‘约车订单导入空列提示(页面明确提示"该期订单列为空,未写入订单数")+ å‘用户确认 2026-01~06 æ˜¯å¦è¡¥æ•°ã€‚
2. ç½‘约车订单导入空列提示(页面明确提示"该期订单列为空,未写入订单数")——数据虽已补齐,但静默成功仍是隐患。
3. æŠ•资"新增项目插行 + åˆ†ç»„行入库(`fund_channel`)"方案落地(等用户对"模块"口径的最后确认)。
4. æŠ•资两张明细表生成器(`X月客运站投资明细表` / `X月物流站场投资明细`)。
traffic-audit-server/src/main/java/com/trafficaudit/dataimport/service/DataImportService.java
@@ -60,6 +60,10 @@
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import cn.hutool.core.io.IoUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -3319,10 +3323,36 @@
        Map<String, Double> orders = new LinkedHashMap<>();
        double[] sumHolder = new double[]{0.0};
        String docPeriod;
        try (java.io.InputStream in = file.getInputStream();
             XWPFDocument doc = new XWPFDocument(in)) {
            docPeriod = periodOfDocx(doc);
        byte[] bytes;
        try (java.io.InputStream in = file.getInputStream()) {
            bytes = IoUtil.readBytes(in);
        }
        boolean isZip = bytes.length > 3 && (bytes[0] & 0xFF) == 0x50 && (bytes[1] & 0xFF) == 0x4B;
        boolean isWordMl = !isZip && looksLikeWordMl(bytes);
        try {
            if (isZip && !isWordPackage(bytes)) {
                throw new RuntimeException("这不是 Word æ–‡æ¡£ï¼ˆåŽ‹ç¼©åŒ…é‡Œæ²¡æœ‰ word/document.xml),"
                        + "多半是 Excel æˆ–其它格式。请上传网约车订单分析报告的 .docx(或 Word 2003 XML)");
            }
            if (isZip) {
                try (XWPFDocument doc = new XWPFDocument(new java.io.ByteArrayInputStream(bytes))) {
                    docPeriod = periodOfParagraphs(paragraphsOfDocx(doc));
            parseWycOrderReportTables(doc, orders, sumHolder);
                }
            } else if (isWordMl) {
                // ç½‘约车监管平台下发的报告常被 WPS/Word å­˜æˆã€ŒWord 2003 XML(平铺 XML)」后再改名 .docx:
                // è¿™ç§æ–‡ä»¶ä¸æ˜¯ zip åŒ…,XWPFDocument æ‰“不开(报 "File is not a zip file"),需按 XML ç›´æŽ¥è§£æžã€‚
                String xml = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
                Document dom = parseXmlSafely(xml);
                docPeriod = periodOfParagraphs(paragraphsOfDocxXml(dom));
                parseWycOrderReportTablesXml(dom, orders, sumHolder);
            } else if (isOle2(bytes)) {
                throw new RuntimeException("这是 Word 97-2003 è€æ ¼å¼ï¼ˆ.doc),请用 WPS/Word æ‰“开后「另存为」docx æ ¼å¼å†å¯¼å…¥");
            } else {
                throw new RuntimeException("无法识别的文件格式:既不是 docx(zip åŒ…),也不是 Word 2003 XML æˆ– .doc");
            }
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException("无法读取 Word æ–‡æ¡£ï¼Œè¯·ä¸Šä¼  .docx æ ¼å¼çš„《网约车行业YYYYå¹´M月运行监测情况分析报告》;"
                    + "若上传的其实是 Excel/PDF,请改用对应模板导入。(原始错误:" + e.getMessage() + ")");
@@ -3388,13 +3418,12 @@
    }
    /** æŠ¥å‘Šæ ‡é¢˜ä¸­çš„æŠ¥è¡¨æœŸï¼šç½‘约车行业2026å¹´7月 -> 2026-07;取不到返回 null */
    private String periodOfDocx(XWPFDocument doc) {
    private String periodOfParagraphs(List<String> paragraphs) {
        java.util.regex.Pattern titlePat = java.util.regex.Pattern.compile("网约车行业\\s*(\\d{4})\\s*å¹´\\s*(\\d{1,2})\\s*月");
        java.util.regex.Pattern anyPat = java.util.regex.Pattern.compile("(\\d{4})\\s*å¹´\\s*(\\d{1,2})\\s*月");
        String fallback = null;
        int scanned = 0;
        for (XWPFParagraph para : doc.getParagraphs()) {
            String text = para.getText();
        for (String text : paragraphs) {
            if (text == null) continue;
            String flat = text.replaceAll("\\s+", "");
            if (flat.isEmpty()) continue;
@@ -3409,6 +3438,92 @@
        return fallback;
    }
    /** docx æ®µè½æ–‡æœ¬ï¼ˆä¿æŒåŽŸå§‹é¡ºåºï¼‰ */
    private List<String> paragraphsOfDocx(XWPFDocument doc) {
        List<String> out = new ArrayList<>();
        for (XWPFParagraph para : doc.getParagraphs()) out.add(para.getText());
        return out;
    }
    /** Word 2003 XML(WordML)段落文本:<w:p> ä¸‹æ‰€æœ‰ <w:t> æ‹¼æŽ¥åŽåŽ‹æŽ‰ç©ºç™½ */
    private List<String> paragraphsOfDocxXml(Document dom) {
        List<String> out = new ArrayList<>();
        for (Element p : elementsByLocalName(dom, "p")) out.add(textOfXmlElement(p));
        return out;
    }
    /** æ˜¯å¦ä¸º Word 2003 XML(WordML å¹³é“º XML)——这种文件常被改名成 .docx */
    private boolean looksLikeWordMl(byte[] bytes) {
        int n = Math.min(bytes.length, 4096);
        String head = new String(bytes, 0, n, java.nio.charset.StandardCharsets.UTF_8);
        return head.contains("wordml") && head.contains("<w:wordDocument");
    }
    /** zip åŒ…内是否含 word/document.xml(用于区分 docx ä¸Ž xlsx/其它 OPC åŒ…) */
    private boolean isWordPackage(byte[] bytes) {
        try (java.util.zip.ZipInputStream zin =
                     new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(bytes))) {
            java.util.zip.ZipEntry e;
            while ((e = zin.getNextEntry()) != null) {
                String n = e.getName();
                if ("word/document.xml".equals(n)) return true;
            }
        } catch (Exception ignore) {
            return false;
        }
        return false;
    }
    /** æ˜¯å¦ä¸º OLE2 å¤åˆæ–‡æ¡£ï¼ˆWord 97-2003 .doc) */
    private boolean isOle2(byte[] b) {
        return b.length > 8 && (b[0] & 0xFF) == 0xD0 && (b[1] & 0xFF) == 0xCF
                && (b[2] & 0xFF) == 0x11 && (b[3] & 0xFF) == 0xE0;
    }
    /** å®‰å…¨è§£æž XML:禁用外部实体/DTD(报告来自外部平台,防 XXE) */
    private Document parseXmlSafely(String xml) throws Exception {
        javax.xml.parsers.DocumentBuilderFactory f = javax.xml.parsers.DocumentBuilderFactory.newInstance();
        f.setNamespaceAware(true);
        try { f.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); } catch (Exception ignore) { }
        try { f.setFeature("http://xml.org/sax/features/external-general-entities", false); } catch (Exception ignore) { }
        try { f.setFeature("http://xml.org/sax/features/external-parameter-entities", false); } catch (Exception ignore) { }
        javax.xml.parsers.DocumentBuilder b = f.newDocumentBuilder();
        b.setEntityResolver(new org.xml.sax.EntityResolver() {
            @Override
            public org.xml.sax.InputSource resolveEntity(String publicId, String systemId) {
                return new org.xml.sax.InputSource(new java.io.StringReader(""));
            }
        });
        return b.parse(new org.xml.sax.InputSource(new java.io.StringReader(xml)));
    }
    /** æŒ‰ localName å–全部后代元素(命名空间无关,兼容 WordML ä¸Ž docx çš„ w: å‰ç¼€ï¼‰ */
    private List<Element> elementsByLocalName(Document dom, String localName) {
        List<Element> out = new ArrayList<>();
        NodeList list = dom.getElementsByTagNameNS("*", localName);
        for (int i = 0; i < list.getLength(); i++) out.add((Element) list.item(i));
        return out;
    }
    /** åªå–直接子元素(避免嵌套表格/嵌套属性元素被算进来) */
    private List<Element> childElementsByLocalName(Element parent, String localName) {
        List<Element> out = new ArrayList<>();
        NodeList kids = parent.getChildNodes();
        for (int i = 0; i < kids.getLength(); i++) {
            Node n = kids.item(i);
            if (n.getNodeType() == Node.ELEMENT_NODE && localName.equals(n.getLocalName())) out.add((Element) n);
        }
        return out;
    }
    /** å…ƒç´ å†…所有 <w:t> æ–‡æœ¬æ‹¼æŽ¥å¹¶åŽ‹æŽ‰ç©ºç™½ */
    private String textOfXmlElement(Element el) {
        StringBuilder sb = new StringBuilder();
        NodeList ts = el.getElementsByTagNameNS("*", "t");
        for (int i = 0; i < ts.getLength(); i++) sb.append(ts.item(i).getTextContent());
        return flat(sb.toString());
    }
    private String ymd(String y, String mo) {
        try {
            int month = Integer.parseInt(mo);
@@ -3421,15 +3536,47 @@
    /** è§£æžæŠ¥å‘Šä¸­çš„「地市名称 | â€¦ | è®¢å•数总数」表,orders=市州订单,sumHolder[0]=合计行订单数 */
    private void parseWycOrderReportTables(XWPFDocument doc, Map<String, Double> orders, double[] sumHolder) {
        List<List<List<String>>> tables = new ArrayList<>();
        for (XWPFTable table : doc.getTables()) {
            List<XWPFTableRow> rows = table.getRows();
            List<List<String>> rows = new ArrayList<>();
            for (XWPFTableRow row : table.getRows()) {
                List<String> cells = new ArrayList<>();
                for (org.apache.poi.xwpf.usermodel.XWPFTableCell c : row.getTableCells()) cells.add(flat(c.getText()));
                rows.add(cells);
            }
            tables.add(rows);
        }
        matchWycOrderTable(tables, orders, sumHolder);
    }
    /** Word 2003 XML(WordML)分支:<w:tbl>/<w:tr>/<w:tc> æŠ½æˆæ–‡æœ¬è¡¨åŽèµ°åŒä¸€å¥—匹配逻辑 */
    private void parseWycOrderReportTablesXml(Document dom, Map<String, Double> orders, double[] sumHolder) {
        List<List<List<String>>> tables = new ArrayList<>();
        for (Element tbl : elementsByLocalName(dom, "tbl")) {
            List<List<String>> rows = new ArrayList<>();
            for (Element tr : childElementsByLocalName(tbl, "tr")) {
                List<String> cells = new ArrayList<>();
                for (Element tc : childElementsByLocalName(tr, "tc")) cells.add(textOfXmlElement(tc));
                rows.add(cells);
            }
            tables.add(rows);
        }
        matchWycOrderTable(tables, orders, sumHolder);
    }
    /**
     * åœ¨ã€Œæ–‡æœ¬åŒ–表格」里找附件1(表头含「订单数总数」/「订单数」)并取 17 å¸‚州订单数与合计。
     * å…¥å‚ tables = è¡¨ -> è¡Œ -> å•元格文本(已压空白),与来源格式无关。
     */
    private void matchWycOrderTable(List<List<List<String>>> tables, Map<String, Double> orders, double[] sumHolder) {
        for (List<List<String>> rows : tables) {
            if (rows.size() < 3) continue;
            int headerRow = -1;
            int orderCol = -1;
            for (int r = 0; r < Math.min(rows.size(), 3); r++) {
                List<org.apache.poi.xwpf.usermodel.XWPFTableCell> cells = rows.get(r).getTableCells();
                List<String> cells = rows.get(r);
                for (int c = 0; c < cells.size(); c++) {
                    String norm = flat(cells.get(c).getText());
                    String norm = cells.get(c);
                    if (norm.contains("订单数总数") || "订单数".equals(norm)) {
                        headerRow = r;
                        orderCol = c;
@@ -3444,11 +3591,11 @@
            double citySum = 0.0;
            boolean hasTotal = false;
            for (int r = headerRow + 1; r < rows.size(); r++) {
                List<org.apache.poi.xwpf.usermodel.XWPFTableCell> cells = rows.get(r).getTableCells();
                if (orderCol >= cells.size()) continue;
                String name = flat(cells.get(0).getText());
                List<String> cells = rows.get(r);
                if (cells.isEmpty() || orderCol >= cells.size()) continue;
                String name = cells.get(0);
                if (name.isEmpty()) continue;
                Double val = parsePlainNumber(cells.get(orderCol).getText());
                Double val = parsePlainNumber(cells.get(orderCol));
                if (val == null) continue;
                String city = RegionUtil.normalizeCityName(name);
                if (city != null && RegionUtil.cityList().contains(city)) {