From f329f504040f3608478c687a99aa36a3cf5720b7 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期六, 03 十月 2026 23:45:04 +0800
Subject: [PATCH] feat(observation): 新增观测站分日调查数据导入与明细查看

---
 HANDOFF.md                                                                                                   |   17 +
 deploy-templates/README-部署说明.txt                                                                             |    1 
 traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java |  350 ++++++++++++++++++++++++++
 docs/sql_observation_station.sql                                                                             |   39 +++
 traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java              |   18 +
 deploy-templates/db/sql_observation_station.sql                                                              |   39 +++
 traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java |   65 +++++
 traffic-audit-web/src/views/HolidayStat.vue                                                                  |  169 ++++++++++++
 traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java     |   19 +
 traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java           |   51 +++
 pack-deploy.ps1                                                                                              |    2 
 11 files changed, 768 insertions(+), 2 deletions(-)

diff --git a/HANDOFF.md b/HANDOFF.md
index fccabaf..2bcd665 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -37,6 +37,18 @@
 - **楠岃瘉**锛歚HolidayBulletinServiceTest` 4 涓敤渚嬮�氳繃锛堟柊澧� `cumulativeParagraphUsesWanUnitAndSingleRounding` 鍥哄寲銆屼竾浜烘 + 涓�娆″彇鏁淬�嶏級锛涢噸鍚悗绔悗閲嶆柊鐢熸垚 2026-10-02 蹇姤锛屾鏂囦负 `绱3088.63涓囦汉娆★紝姣�2025骞村悓鏈熶笅闄�4.4%锛屾瘮2024骞村悓鏈熶笅闄�1.6%`锛屼笌鎵嬪伐绋夸粎宸棦瀹氱殑鍙栨暣鍙e緞锛�2026-10-01 鍥炲綊姝e父锛坄绱1643.17涓囦汉娆锛夈��
 - **鐘舵��**锛氬緟鐢ㄦ埛銆岄獙鏀堕�氳繃銆嶅悗鎻愪氦/鎺ㄩ�併��
 
+### 10-03 鏅氾紙4锛夛細鏂板銆岃娴嬬珯鍒嗘棩璋冩煡鏁版嵁銆嶅師濮嬫暟鎹鍏ワ紙寰呴獙鏀讹級
+- **鑳屾櫙锛堢敤鎴� 2026-10-03 鎻愬嚭锛�**锛氭櫘閫氬浗鐪侀亾杞︽祦閲忕殑鏉ユ簮涔嬩竴鏄娴嬬珯鍒嗘棩璋冩煡鏁版嵁锛堟ā鏉� `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹� (2026骞�10鏈�1鏃�).xls`锛屽叾涓瓧娈点�屼腑灏忓娴侀噺銆嶅嵆鏅�氬浗鐪侀亾杞︽祦閲忔潵婧愶級銆�**绯荤粺鍐呬笉鍋氬叧鑱�**锛岃妭鍋囨棩缁熻浠嶈蛋鍘熸湁瀵煎叆锛涙湰鍔熻兘鍙仛鍘熷鏁版嵁鐣欏瓨锛屼负鍚庣画鍒嗘瀽鎸栨帢绉疮鏁版嵁鍩虹銆�
+- **鏁版嵁琛�**锛氭柊澧� `observation_station_flow`锛孌DL 瑙� `docs/sql_observation_station.sql`锛岄儴缃插寘鍚屾涓� `deploy-templates/db/sql_observation_station.sql`銆備繚鐣欐ā鏉垮叏閮� 26 鍒楋紙瑙傛祴绔欑紪鍙�/鍚嶇О/瑙傛祴閲岀▼/鍦板競/鍖哄幙/琛岄┒鏂瑰悜 + 鍚勮溅鍨嬫祦閲� + 瀹㈣溅/璐ц溅/姹借溅/鏈哄姩杞︽祦閲� + 鍚勭被褰撻噺 + 鏈哄姩杞﹂�熷害 + 鎷ユ尋搴� + 鏉ユ簮鏂囦欢鍚嶏級銆傚敮涓�閿� `(obs_date, station_code, direction)`锛�**鏁板�煎垪涓�寰� `DECIMAL(30,15)`**锛孞ava 瀹炰綋瀛楁涓�寰� `BigDecimal`锛屼笌鑺傚亣鏃ヨ〃鍚屽彛寰勶紝鏃� float/double銆�
+- **瀵煎叆瀹炵幇**锛氭柊澧炲寘 `com.trafficaudit.observation`锛坋ntity / mapper / dto / service / controller锛夈�傜敤 POI `WorkbookFactory` 鍚屾椂鏀寔 `.xls` 鍜� `.xlsx`锛�**琛ㄥご鎸夊垪鍚嶅尮閰�**锛堝垪椤哄簭鍙樺寲涓嶅奖鍝嶅鍏ワ級锛屾壂鎻忓墠 10 琛屽畾浣嶈〃澶达紱鏃ユ湡鍏煎 Excel 鏃ユ湡鏍煎紡涓� `yyyy-M-d`/`yyyy/M/d`/`yyyyMMdd`/涓枃骞存湀鏃ワ紱鍚屼竴鏃ユ湡**閲嶅瀵煎叆鍗虫暣浣撹鐩�**锛堝厛鍒犺鏃ユ湡鍐嶆彃鍏ワ級锛屽箓绛夈��
+- **鎺ュ彛**锛歚POST /api/observation/import`銆乣GET /api/observation/dates`锛堟寜鏃ユ湡姹囨�荤珯鏁般�佷腑灏忓娴侀噺鍚堣銆佹渶杩戝鍏ユ椂闂达級銆乣GET /api/observation/rows?date=&limit=`銆乣POST /api/observation/delete?date=`銆�
+- **鍓嶇**锛歚traffic-audit-web/src/views/HolidayStat.vue`锛堜笓椤圭粺璁� - 鑺傚亣鏃ワ級鏂板銆岃娴嬬珯鍒嗘棩璋冩煡鏁版嵁銆嶅崱鐗囷細閫夋嫨鏂囦欢 鈫� 瀵煎叆 鈫� 鎸夋棩鏈熸眹鎬昏〃 鈫� 鏌ョ湅鏄庣粏锛堝脊绐楋級鈫� 鍒犻櫎銆�
+- **瀹炴祴锛堢湡瀹炴ā鏉匡紝鏈満 3305锛�**锛氬鍏� 692 琛� / 692 绔� / 0 璺宠繃 / 0 澶辫触锛涗笁椤瑰悎璁′笌婧愭枃浠堕�愰」涓�鑷达紙涓皬瀹㈡祦閲� 3,523,873銆佹満鍔ㄨ溅娴侀噺 5,487,965銆佹満鍔ㄨ溅褰撻噺 7,140,575锛夛紱**692 琛� 脳 25 瀛楁鍏ㄩ噺閫愯姣斿 0 澶勪笉涓�鑷�**锛涗腑鏂囷紙绔欏悕/鍦板競/鍖哄幙锛夎惤搴撴棤涔辩爜锛涢噸澶嶅鍏ヤ粛涓� 692 琛岋紙瑕嗙洊鑰岄潪绱姞锛夛紱涓婁紶闈� Excel 杩斿洖鏄庣‘閿欒銆傛梺璇侊細692 绔欎腑灏忓娴侀噺鍚堣 3,523,873 杈� = 352.3873 涓囪締锛屼笌 2026-10-01 鑺傚亣鏃ャ�屾櫘閫氬浗鐪侀亾銆嶅叆搴撳�煎畬鍏ㄤ竴鑷达紝鍗拌瘉浜嗘暟鎹潵婧愬叧绯汇��
+- **椤哄甫淇**锛歚pack-deploy.ps1` 鎶� db 鑴氭湰澶嶅埗杩涘寘鍐� docs 鐨勬竻鍗曢噷婕忎簡 `sql_holiday_previous_day_migration.sql`锛屾湰娆′竴骞惰ˉ涓� `sql_observation_station.sql`锛沗deploy-templates/README-閮ㄧ讲璇存槑.txt` 鐨� db 娓呭崟鍚屾鏇存柊銆�
+- **10-03 娣卞杩藉姞锛堣娴嬬珯鐣岄潰寰皟锛�**锛氣憼銆屾煡鐪嬨�嶆槑缁嗗脊绐楁寜妯℃澘琛ㄥご灞曠ず鍘熷鏁版嵁鍏ㄩ儴瀛楁锛堣娴嬫棩鏈�/绔欏彿/绔欏悕/閲岀▼/鍦板競/鍖哄幙/鏂瑰悜 + 鍏ㄩ儴杞﹀瀷娴侀噺銆佸璐ф苯杞︽満鍔ㄨ溅娴侀噺銆佸悇绫诲綋閲忋�侀�熷害銆佹嫢鎸ゅ害锛屽彟闄勬潵婧愭枃浠跺悕锛屽叡 27 鍒楋紝妯悜婊氬姩锛夛紱鈶℃眹鎬昏〃銆屼腑灏忓娴侀噺鍚堣銆嶅崟浣嶇敱杈嗘鏀逛负**涓囪締娆�**銆佷繚鐣� 4 浣嶅皬鏁帮紙`formatWanVehicles`锛屽 3,523,873 鈫� 352.3873锛夈�傚墠绔� dev server 宸茬儹鏇存柊缂栬瘧閫氳繃銆�
+- **10-03 娣卞杩藉姞锛堣娴嬬珯鍒楄〃/鏄庣粏淇锛�**锛氣憼**鏄庣粏琛屽簭**鍘熸潵鎸� `stationCode` 鎺掑簭锛屼笌 Excel 琛屽簭瀵逛笉涓婏紝鏀逛负鎸夊鍏ラ『搴忥紙`id ASC`锛屽嵆 Excel 琛屽簭锛夛紝渚夸簬閫愯姣斿锛涒憽鏄庣粏**鍒楅『搴�**涓庢ā鏉� 26 鍒楀畬鍏ㄤ竴鑷达紙鏈熬闄勬潵婧愭枃浠跺悕锛夛紱鈶�**瑙傛祴閲岀▼/鏈哄姩杞﹂�熷害/鎷ユ尋搴�**鏀逛负鎸夊師濮嬪皬鏁版樉绀猴紙`formatPlain`锛屽幓灏鹃浂锛屽 50.92 / 43.882 / 0.777 / 58锛夛紝涓嶅啀鍥哄畾 2 浣嶏紱鈶�**17 涓祦閲忎笌褰撻噺鍒�**鏀逛负鏁存暟鏄剧ず锛坄formatInteger`锛屽 6413锛夛紝涓� Excel 涓�鑷达紱鈶ゆ眹鎬诲垪琛ㄣ�岃娴嬫棩鏈熴�嶆敼涓�**姝e簭**锛坢apper `ORDER BY obs_date ASC`锛夈�傚凡鐢� 10-01/10-02 鐪熷疄鏁版嵁楠岃瘉琛屽簭涓� Excel 鍓� 5 琛屼竴鑷达紙涓府/涓囧簵/娌煎北绔�/閭惧煄绔�/鐧戒簯锛夈��
+- **鐘舵��**锛氬緟鐢ㄦ埛銆岄獙鏀堕�氳繃銆嶅悗鎻愪氦/鎺ㄩ�併��
+
 ---
 
 ### 琛ュ厖锛�10-03 涓嬪崍锛夛細鏈嶅姟鍣ㄩ儴缃叉姤 鈥淎ccess denied ... (using password: NO)鈥� 鈥斺�� 鏍瑰洜涓庝慨澶�
@@ -501,6 +513,11 @@
 - **2026-08-28 澶嶆牳銆岀偣鏁版嵁瀵煎叆娴忚鍣ㄥ穿婧冦�嶏紙鎵撳寘娴嬭瘯鐜锛岄儴鍒嗙敤鎴凤級**锛氶噸鐐规帓鏌� `GET /api/import/batches`鈥斺�斿悗绔� limit 涓婇檺 200锛涘叏琛� 81 鏉°�乪rror_detail 鏈�闀� 2.6KB銆佸叏閲忓搷搴旂害 17KB锛堝墠绔彧鍙� 50 鏉★級锛涘墠绔〃鏍间粎 8 鍒楃煭瀛楁锛坋rrorDetail 涓嶆覆鏌擄級銆佹棤杞/瀹氭椂鍣ㄣ��**缁撹锛氭帴鍙d笉鏄穿婧冩牴鍥�**锛堣嫢鎺ュ彛鏈夐棶棰橈紝鎵�鏈夌敤鎴烽兘浼氬穿锛岃�岄潪"閮ㄥ垎鐢ㄦ埛"锛夈�傜幇璞$鍚堝鎴风鐜宸紓锛堢涓夋柟鎵╁睍娉ㄥ叆/鏃ф樉鍗¢┍鍔�/浣庨厤鍐呭瓨锛屼笌 08-27 杩呴浄鎵╁睍+Intel 鏃ч┍鍔ㄧ粨璁轰竴鑷达級銆傚缓璁宕╂簝鐢ㄦ埛渚濇楠岃瘉锛氭棤鐥曟ā寮忊啋鎹� Edge鈫掔鎵╁睍锛屽苟鐪� chrome://crashes銆�
 - 銆愭柊闇�姹偮蜂粎璁板綍鏈疄鐜奥�2026-08-28銆戝鍔�**缁忔祹鎸囨爣璇曠畻鍔熻兘**锛氶瀵奸潪甯稿叧娉ㄧ粡娴庢寚鏍囷紝**璐ц繍鏄ぇ澶�**锛涘父椹荤幇鍦虹殑鎵�鏈変汉閮借浼氳绠楄揣杩愮浉鍏虫暟鎹紙璐ц繍閲�/鍛ㄨ浆閲忕瓑锛夈�傞瀵肩粡甯歌姹傛妸鏌愪釜鏁版嵁銆屽帇涓�鍘嬨�嶏紝浠婂悗鑰冭檻鍦ㄨ蒋浠堕噷澧炲姞璇曠畻鍔熻兘鈥斺�斿彲鎸夐渶瑕佽皟鏁�/娴嬬畻鎸囨爣鏁板�硷紙濡傛ā鎷熶笅璋冩煇浼佷笟/鏌愬彛寰勭殑璐ц繍閲忓悗鐪嬫眹鎬讳笌瓒嬪娍褰卞搷锛夛紝杈呭姪鐜板満浜哄憳蹇�熻瘯绠椾笌搴斿銆�
 
+- 銆愬緟鍔灺�2026-10-03銆�**鏁板�肩簿搴︾粺涓�鏀归��**锛氭妸鏃㈡湁涓氬姟琛ㄧ殑鏁板�煎垪鐢� `double` 鏀逛负 `DECIMAL(30,15)`锛孞ava 瀹炰綋瀛楁鐢� `Double/Float` 鏀逛负 `BigDecimal`锛屼笌鑺傚亣鏃ヨ〃銆佽娴嬬珯琛ㄧ粺涓�鍙e緞锛堢敤鎴� 2026-10-03 鏄庣‘瑕佹眰锛屾嫨鏈哄疄鏂斤級銆�
+  - 鐜扮姸鐩樼偣锛坱raffic_audit锛�2026-10-03 鏌ヨ锛夛細**16 寮犺〃銆佸叡 228 涓� double 鍒�**鈥斺�擿freight_turnover_import` 48銆乣city_bus_monthly` 34銆乣h2032_enterprise_monthly` 33銆乣city_taxi_monthly` 29銆乣wyc_order_monthly` 22銆乣h2031_enterprise_monthly` 18銆乣h204_vehicle_quarterly` 12銆乣scale_split_transport` 9銆乣investment_monthly` 5銆乣investment_system` 5銆乣wyc_total_monthly` 4銆乣h204_auth_vehicle` 2銆乣investment_project` 2銆乣passenger_individual_monthly` 2銆乣transport_auth_vehicle` 2銆乣vehicle_track_mileage` 1銆�
+  - 宸叉槸 decimal 鍙e緞銆佹棤闇�鏀瑰姩锛歚holiday_calendar` / `holiday_passenger_coefficient` / `holiday_passenger_flow` / `holiday_vehicle_flow` / `observation_station_flow`銆�
+  - 鏀归�犺寖鍥翠笉鍙槸鏀瑰垪绫诲瀷锛氬疄浣撶被瀛楁绫诲瀷銆佸鍏ヨВ鏋愶紙涓嶈缁� double 涓浆锛夈�佽绠椾笌鍙栨暣閫昏緫銆佹姤琛ㄥ鍑烘牸寮忓寲锛屼互鍙婂瓨閲忔暟鎹殑杩佺Щ鑴氭湰锛岄兘瑕佷竴璧锋敼锛涙敼瀹岃鍋氶�愯〃鏁板�兼娊鏌ャ��
+
 ## 鍏�佹瘡鏃ヤ氦鎺ョ害瀹�
 1. 鏂板璇濈涓�姝ワ細璇� `AGENTS.md` + `HANDOFF.md`锛涙秹鍙� AI 鍒嗘瀽/瀹℃牳鏃跺弬鑰� `docs/` 涓庢暟鎹簱琛ㄧ粨鏋勩��
 2. 姣忓ぉ缁撴潫鏃讹細鏇存柊 `HANDOFF.md`锛堣繘灞曘�佸潙銆佹柊闇�姹傘�佸緟鍔烇級锛屽啀鍏冲璇濄��
diff --git "a/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt" "b/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
index 357a775..c14e75a 100644
--- "a/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
+++ "b/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
@@ -40,6 +40,7 @@
     sql_holiday_decimal_migration.sql        鑺傚亣鏃ユ暟鍊煎垪绮惧害杩佺Щ锛堟棫搴撳崌绾э級
     sql_holiday_comparison_base_migration.sql 鑺傚亣鏃ョ幆姣斿熀鏈熷瓧娈佃縼绉伙紙鏃у簱鍗囩骇锛�
     sql_holiday_previous_day_migration.sql    鑺傚亣鏃ュ墠涓�鏃ラ厤缃縼绉伙紙鏃у簱鍗囩骇锛�
+    sql_observation_station.sql               瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹〃锛堟柊鍔熻兘锛屾棫搴撶己琛ㄦ椂鎵ц锛�
   README-閮ㄧ讲璇存槑.txt
 
 銆愰儴缃叉満鐜瑕佹眰銆�
diff --git a/deploy-templates/db/sql_observation_station.sql b/deploy-templates/db/sql_observation_station.sql
new file mode 100644
index 0000000..01c638d
--- /dev/null
+++ b/deploy-templates/db/sql_observation_station.sql
@@ -0,0 +1,39 @@
+-- Observation station daily survey data (raw retention for future analysis).
+-- Run against the traffic_audit database.
+CREATE TABLE IF NOT EXISTS observation_station_flow (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  obs_date DATE NOT NULL COMMENT 'Observation date',
+  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
+  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
+  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
+  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
+  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
+  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
+  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
+  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
+  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
+  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
+  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
+  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
+  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
+  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
+  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
+  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
+  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
+  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
+  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
+  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
+  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
+  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
+  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
+  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
+  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
+  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
+  KEY idx_observation_station_date (obs_date),
+  KEY idx_observation_station_city (city_name),
+  KEY idx_observation_station_code (station_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/docs/sql_observation_station.sql b/docs/sql_observation_station.sql
new file mode 100644
index 0000000..01c638d
--- /dev/null
+++ b/docs/sql_observation_station.sql
@@ -0,0 +1,39 @@
+-- Observation station daily survey data (raw retention for future analysis).
+-- Run against the traffic_audit database.
+CREATE TABLE IF NOT EXISTS observation_station_flow (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  obs_date DATE NOT NULL COMMENT 'Observation date',
+  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
+  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
+  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
+  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
+  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
+  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
+  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
+  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
+  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
+  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
+  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
+  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
+  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
+  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
+  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
+  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
+  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
+  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
+  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
+  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
+  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
+  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
+  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
+  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
+  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
+  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
+  KEY idx_observation_station_date (obs_date),
+  KEY idx_observation_station_city (city_name),
+  KEY idx_observation_station_code (station_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/pack-deploy.ps1 b/pack-deploy.ps1
index 0720fdc..71de807 100644
--- a/pack-deploy.ps1
+++ b/pack-deploy.ps1
@@ -188,7 +188,7 @@
 cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache 宸ヤ綔鏃ョ粨 鍔熻兘娴嬭瘯鎶ュ憡 闂姹囨�� /XF 鐢熸垚_*.xlsx _bak_* >nul"
 if ($LASTEXITCODE -ge 8) { throw "docs 澶嶅埗澶辫触锛坮obocopy 杩斿洖 $LASTEXITCODE锛�" }
 # db 鑴氭湰涔熸斁涓�浠藉埌 docs锛堜笌鏃у寘涓�鑷达細docs/init.sql 绛夛級
-Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql') $docsDest -Force -ErrorAction SilentlyContinue
+Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
 
 # README锛氱敤妯℃澘骞舵浛鎹㈡棩鏈熷崰浣�
 $readme = Get-Content (Join-Path $tplDir 'README-閮ㄧ讲璇存槑.txt') -Raw -Encoding UTF8
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
new file mode 100644
index 0000000..6a77ca5
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
@@ -0,0 +1,65 @@
+package com.trafficaudit.observation.controller;
+
+import com.trafficaudit.common.Result;
+import com.trafficaudit.observation.dto.ObservationDateSummary;
+import com.trafficaudit.observation.entity.ObservationStationFlow;
+import com.trafficaudit.observation.service.ObservationStationImportService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Observation station raw data. Collected and stored only; not linked to holiday flow data.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/observation")
+public class ObservationStationController {
+
+    @Resource
+    private ObservationStationImportService importService;
+
+    @PostMapping("/import")
+    public Result<Map<String, Object>> importWorkbook(@RequestParam("file") MultipartFile file) {
+        try {
+            return Result.ok(importService.importWorkbook(file));
+        } catch (Exception e) {
+            log.error("observation station import error", e);
+            return Result.error(400, e.getMessage());
+        }
+    }
+
+    @GetMapping("/dates")
+    public Result<List<ObservationDateSummary>> dates() {
+        return Result.ok(importService.listDateSummaries());
+    }
+
+    @GetMapping("/rows")
+    public Result<List<ObservationStationFlow>> rows(@RequestParam(value = "date", required = false) String date,
+                                                      @RequestParam(value = "limit", defaultValue = "2000") int limit) {
+        try {
+            LocalDate target = (date == null || date.trim().isEmpty()) ? null : LocalDate.parse(date);
+            return Result.ok(importService.listRows(target, limit));
+        } catch (Exception e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
+    @PostMapping("/delete")
+    public Result<Integer> delete(@RequestParam("date") String date) {
+        try {
+            return Result.ok(importService.deleteDate(LocalDate.parse(date)));
+        } catch (Exception e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
new file mode 100644
index 0000000..d7281d3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
@@ -0,0 +1,18 @@
+package com.trafficaudit.observation.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/** Per-date rollup shown on the import page. */
+@Data
+public class ObservationDateSummary {
+    private LocalDate obsDate;
+    private Integer stationCount;
+    private BigDecimal smallMediumPassengerTotal;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime importedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
new file mode 100644
index 0000000..6b26fd2
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
@@ -0,0 +1,51 @@
+package com.trafficaudit.observation.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * Observation station daily survey data.
+ * Stored raw for later analysis; it is deliberately NOT linked to the holiday flow tables,
+ * even though 涓皬瀹㈡祦閲� is one of the sources of the ordinary national/provincial road flow.
+ */
+@Data
+@TableName("observation_station_flow")
+public class ObservationStationFlow {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private LocalDate obsDate;
+    private String stationCode;
+    private String stationName;
+    private BigDecimal mileage;
+    private String cityName;
+    private String countyName;
+    private String direction;
+    private BigDecimal smallMediumPassenger;
+    private BigDecimal largePassenger;
+    private BigDecimal smallTruck;
+    private BigDecimal mediumTruck;
+    private BigDecimal largeTruck;
+    private BigDecimal extraLargeTruck;
+    private BigDecimal containerTruck;
+    private BigDecimal motorcycle;
+    private BigDecimal tractor;
+    private BigDecimal passengerTotal;
+    private BigDecimal truckTotal;
+    private BigDecimal carTotal;
+    private BigDecimal motorTotal;
+    private BigDecimal passengerEquivalent;
+    private BigDecimal truckEquivalent;
+    private BigDecimal carEquivalent;
+    private BigDecimal motorEquivalent;
+    private BigDecimal vehicleSpeed;
+    private BigDecimal congestionDegree;
+    private String sourceFile;
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
new file mode 100644
index 0000000..51cfee3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
@@ -0,0 +1,19 @@
+package com.trafficaudit.observation.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.observation.dto.ObservationDateSummary;
+import com.trafficaudit.observation.entity.ObservationStationFlow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ObservationStationFlowMapper extends BaseMapper<ObservationStationFlow> {
+
+    @Select("SELECT obs_date, COUNT(*) AS station_count, "
+            + "SUM(small_medium_passenger) AS small_medium_passenger_total, "
+            + "MAX(updated_at) AS imported_at "
+            + "FROM observation_station_flow GROUP BY obs_date ORDER BY obs_date ASC")
+    List<ObservationDateSummary> selectDateSummary();
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
new file mode 100644
index 0000000..643a784
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
@@ -0,0 +1,350 @@
+package com.trafficaudit.observation.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.observation.dto.ObservationDateSummary;
+import com.trafficaudit.observation.entity.ObservationStationFlow;
+import com.trafficaudit.observation.mapper.ObservationStationFlowMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.DateUtil;
+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.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Imports the "observation station daily survey" workbook (.xls/.xlsx).
+ * Header names are matched by text, so column order may change between releases.
+ * Re-importing the same date replaces that date's rows.
+ */
+@Slf4j
+@Service
+public class ObservationStationImportService {
+
+    private static final String H_DATE = "瑙傛祴鏃ユ湡";
+    private static final String H_CODE = "瑙傛祴绔欑紪鍙�";
+    private static final String H_NAME = "瑙傛祴绔欏悕绉�";
+    private static final String H_MILEAGE = "瑙傛祴閲岀▼";
+    private static final String H_CITY = "鍦板競鍚嶇О";
+    private static final String H_COUNTY = "鍖哄幙鍚嶇О";
+    private static final String H_DIRECTION = "琛岄┒鏂瑰悜";
+    private static final String H_SMALL_MEDIUM_PASSENGER = "涓皬瀹㈡祦閲�";
+    private static final String H_LARGE_PASSENGER = "澶у杞︽祦閲�";
+    private static final String H_SMALL_TRUCK = "灏忚揣杞︽祦閲�";
+    private static final String H_MEDIUM_TRUCK = "涓揣杞︽祦閲�";
+    private static final String H_LARGE_TRUCK = "澶ц揣杞︽祦閲�";
+    private static final String H_EXTRA_LARGE_TRUCK = "鐗瑰ぇ璐ф祦閲�";
+    private static final String H_CONTAINER = "闆嗚绠辨祦閲�";
+    private static final String H_MOTORCYCLE = "鎽╂墭杞︽祦閲�";
+    private static final String H_TRACTOR = "鎷栨媺鏈烘祦閲�";
+    private static final String H_PASSENGER_TOTAL = "瀹㈣溅娴侀噺";
+    private static final String H_TRUCK_TOTAL = "璐ц溅娴侀噺";
+    private static final String H_CAR_TOTAL = "姹借溅娴侀噺";
+    private static final String H_MOTOR_TOTAL = "鏈哄姩杞︽祦閲�";
+    private static final String H_PASSENGER_EQUIVALENT = "瀹㈣溅褰撻噺";
+    private static final String H_TRUCK_EQUIVALENT = "璐ц溅褰撻噺";
+    private static final String H_CAR_EQUIVALENT = "姹借溅褰撻噺";
+    private static final String H_MOTOR_EQUIVALENT = "鏈哄姩杞﹀綋閲�";
+    private static final String H_SPEED = "鏈哄姩杞﹂�熷害";
+    private static final String H_CONGESTION = "鎷ユ尋搴�";
+
+    private static final int HEADER_SCAN_ROWS = 10;
+    private static final int MAX_REPORTED_ERRORS = 50;
+
+    private static final DateTimeFormatter[] DATE_FORMATS = {
+            DateTimeFormatter.ofPattern("yyyy-M-d"),
+            DateTimeFormatter.ofPattern("yyyy/M/d"),
+            DateTimeFormatter.ofPattern("yyyy.M.d"),
+            DateTimeFormatter.ofPattern("yyyyMMdd")
+    };
+
+    @Resource
+    private ObservationStationFlowMapper stationFlowMapper;
+
+    @Transactional
+    public Map<String, Object> importWorkbook(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("璇烽�夋嫨瑕佸鍏ョ殑 Excel 鏂囦欢");
+        }
+        String sourceFile = file.getOriginalFilename();
+        List<ObservationStationFlow> rows = new ArrayList<>();
+        Set<LocalDate> dates = new LinkedHashSet<>();
+        Set<String> stationCodes = new LinkedHashSet<>();
+        List<String> errors = new ArrayList<>();
+        int skipped = 0;
+
+        DataFormatter formatter = new DataFormatter();
+        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
+            Sheet sheet = workbook.getNumberOfSheets() > 0 ? workbook.getSheetAt(0) : null;
+            if (sheet == null) {
+                throw new IllegalArgumentException("宸ヤ綔绨夸腑娌℃湁鍙鍙栫殑宸ヤ綔琛�");
+            }
+            int headerRowIndex = findHeaderRow(sheet, formatter);
+            if (headerRowIndex < 0) {
+                throw new IllegalArgumentException("鏈壘鍒拌〃澶达紝璇风‘璁ゆ枃浠跺寘鍚玕"瑙傛祴鏃ユ湡\"鍜孿"瑙傛祴绔欑紪鍙穃"涓ゅ垪");
+            }
+            Map<String, Integer> headerIndex = buildHeaderIndex(sheet.getRow(headerRowIndex), formatter);
+
+            for (int r = headerRowIndex + 1; r <= sheet.getLastRowNum(); r++) {
+                Row row = sheet.getRow(r);
+                if (row == null) {
+                    skipped++;
+                    continue;
+                }
+                String code = stringCell(row, headerIndex, H_CODE, formatter);
+                LocalDate obsDate = dateCell(row, headerIndex, H_DATE, formatter);
+                if (isBlank(code) && obsDate == null) {
+                    skipped++;
+                    continue;
+                }
+                int rowNo = r + 1;
+                if (obsDate == null) {
+                    addError(errors, "绗�" + rowNo + "琛岋細瑙傛祴鏃ユ湡鏃犳硶璇嗗埆");
+                    continue;
+                }
+                if (isBlank(code)) {
+                    addError(errors, "绗�" + rowNo + "琛岋細瑙傛祴绔欑紪鍙蜂负绌�");
+                    continue;
+                }
+                ObservationStationFlow entity = new ObservationStationFlow();
+                entity.setObsDate(obsDate);
+                entity.setStationCode(code);
+                entity.setStationName(stringCell(row, headerIndex, H_NAME, formatter));
+                entity.setMileage(decimalCell(row, headerIndex, H_MILEAGE, formatter));
+                entity.setCityName(stringCell(row, headerIndex, H_CITY, formatter));
+                entity.setCountyName(stringCell(row, headerIndex, H_COUNTY, formatter));
+                String direction = stringCell(row, headerIndex, H_DIRECTION, formatter);
+                entity.setDirection(direction == null ? "" : direction);
+                entity.setSmallMediumPassenger(decimalCell(row, headerIndex, H_SMALL_MEDIUM_PASSENGER, formatter));
+                entity.setLargePassenger(decimalCell(row, headerIndex, H_LARGE_PASSENGER, formatter));
+                entity.setSmallTruck(decimalCell(row, headerIndex, H_SMALL_TRUCK, formatter));
+                entity.setMediumTruck(decimalCell(row, headerIndex, H_MEDIUM_TRUCK, formatter));
+                entity.setLargeTruck(decimalCell(row, headerIndex, H_LARGE_TRUCK, formatter));
+                entity.setExtraLargeTruck(decimalCell(row, headerIndex, H_EXTRA_LARGE_TRUCK, formatter));
+                entity.setContainerTruck(decimalCell(row, headerIndex, H_CONTAINER, formatter));
+                entity.setMotorcycle(decimalCell(row, headerIndex, H_MOTORCYCLE, formatter));
+                entity.setTractor(decimalCell(row, headerIndex, H_TRACTOR, formatter));
+                entity.setPassengerTotal(decimalCell(row, headerIndex, H_PASSENGER_TOTAL, formatter));
+                entity.setTruckTotal(decimalCell(row, headerIndex, H_TRUCK_TOTAL, formatter));
+                entity.setCarTotal(decimalCell(row, headerIndex, H_CAR_TOTAL, formatter));
+                entity.setMotorTotal(decimalCell(row, headerIndex, H_MOTOR_TOTAL, formatter));
+                entity.setPassengerEquivalent(decimalCell(row, headerIndex, H_PASSENGER_EQUIVALENT, formatter));
+                entity.setTruckEquivalent(decimalCell(row, headerIndex, H_TRUCK_EQUIVALENT, formatter));
+                entity.setCarEquivalent(decimalCell(row, headerIndex, H_CAR_EQUIVALENT, formatter));
+                entity.setMotorEquivalent(decimalCell(row, headerIndex, H_MOTOR_EQUIVALENT, formatter));
+                entity.setVehicleSpeed(decimalCell(row, headerIndex, H_SPEED, formatter));
+                entity.setCongestionDegree(decimalCell(row, headerIndex, H_CONGESTION, formatter));
+                entity.setSourceFile(sourceFile);
+                rows.add(entity);
+                dates.add(obsDate);
+                stationCodes.add(code);
+            }
+        } catch (IllegalArgumentException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalArgumentException("宸ヤ綔绨胯В鏋愬け璐�: " + e.getMessage(), e);
+        }
+
+        if (!rows.isEmpty()) {
+            stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
+                    .in(ObservationStationFlow::getObsDate, dates));
+            for (ObservationStationFlow row : rows) {
+                stationFlowMapper.insert(row);
+            }
+        }
+
+        List<String> dateTexts = new ArrayList<>();
+        for (LocalDate date : dates) {
+            dateTexts.add(date.toString());
+        }
+        BigDecimal smallMediumTotal = BigDecimal.ZERO;
+        for (ObservationStationFlow row : rows) {
+            if (row.getSmallMediumPassenger() != null) {
+                smallMediumTotal = smallMediumTotal.add(row.getSmallMediumPassenger());
+            }
+        }
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("totalRows", rows.size());
+        summary.put("stationCount", stationCodes.size());
+        summary.put("dates", dateTexts);
+        summary.put("skippedRows", skipped);
+        summary.put("smallMediumPassengerTotal", smallMediumTotal);
+        summary.put("errors", errors);
+        log.info("Observation station import done: file={} rows={} dates={} skipped={} failed={}",
+                sourceFile, rows.size(), dateTexts, skipped, errors.size());
+        return summary;
+    }
+
+    public List<ObservationDateSummary> listDateSummaries() {
+        return stationFlowMapper.selectDateSummary();
+    }
+
+    public List<ObservationStationFlow> listRows(LocalDate date, int limit) {
+        int effectiveLimit = limit <= 0 ? 2000 : Math.min(limit, 5000);
+        return stationFlowMapper.selectList(new LambdaQueryWrapper<ObservationStationFlow>()
+                .eq(date != null, ObservationStationFlow::getObsDate, date)
+                .orderByAsc(ObservationStationFlow::getId)
+                .last("LIMIT " + effectiveLimit));
+    }
+
+    public int deleteDate(LocalDate date) {
+        if (date == null) {
+            throw new IllegalArgumentException("璇烽�夋嫨瑕佸垹闄ょ殑瑙傛祴鏃ユ湡");
+        }
+        return stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
+                .eq(ObservationStationFlow::getObsDate, date));
+    }
+
+    private void addError(List<String> errors, String message) {
+        if (errors.size() < MAX_REPORTED_ERRORS) {
+            errors.add(message);
+        }
+    }
+
+    private int findHeaderRow(Sheet sheet, DataFormatter formatter) {
+        int lastRow = Math.min(sheet.getLastRowNum(), HEADER_SCAN_ROWS);
+        for (int r = 0; r <= lastRow; r++) {
+            Row row = sheet.getRow(r);
+            if (row == null) {
+                continue;
+            }
+            Map<String, Integer> index = buildHeaderIndex(row, formatter);
+            if (index.containsKey(normalize(H_DATE)) && index.containsKey(normalize(H_CODE))) {
+                return r;
+            }
+        }
+        return -1;
+    }
+
+    private Map<String, Integer> buildHeaderIndex(Row row, DataFormatter formatter) {
+        Map<String, Integer> index = new LinkedHashMap<>();
+        if (row == null) {
+            return index;
+        }
+        for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) {
+            Cell cell = row.getCell(c);
+            String header = normalize(formatter.formatCellValue(cell));
+            if (!header.isEmpty() && !index.containsKey(header)) {
+                index.put(header, c);
+            }
+        }
+        return index;
+    }
+
+    private String normalize(String value) {
+        if (value == null) {
+            return "";
+        }
+        StringBuilder builder = new StringBuilder(value.length());
+        for (int i = 0; i < value.length(); i++) {
+            char ch = value.charAt(i);
+            if (ch == 0xFEFF || Character.isWhitespace(ch)) {
+                continue;
+            }
+            builder.append(ch);
+        }
+        return builder.toString();
+    }
+
+    private String stringCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
+        Integer column = headerIndex.get(normalize(header));
+        if (column == null) {
+            return null;
+        }
+        Cell cell = row.getCell(column);
+        if (cell == null) {
+            return null;
+        }
+        String value = formatter.formatCellValue(cell).trim();
+        return value.isEmpty() ? null : value;
+    }
+
+    private LocalDate dateCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
+        Integer column = headerIndex.get(normalize(header));
+        if (column == null) {
+            return null;
+        }
+        return readDate(row.getCell(column), formatter);
+    }
+
+    private BigDecimal decimalCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
+        Integer column = headerIndex.get(normalize(header));
+        if (column == null) {
+            return null;
+        }
+        return readDecimal(row.getCell(column), formatter);
+    }
+
+    private LocalDate readDate(Cell cell, DataFormatter formatter) {
+        if (cell == null) {
+            return null;
+        }
+        CellType type = cell.getCellType();
+        if (type == CellType.FORMULA) {
+            type = cell.getCachedFormulaResultType();
+        }
+        if (type == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
+            return cell.getLocalDateTimeCellValue().toLocalDate();
+        }
+        String text = formatter.formatCellValue(cell).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        text = text.replace("骞�", "-").replace("鏈�", "-").replace("鏃�", "").trim();
+        for (DateTimeFormatter format : DATE_FORMATS) {
+            try {
+                return LocalDate.parse(text, format);
+            } catch (Exception ignored) {
+                // Try the next supported pattern.
+            }
+        }
+        return null;
+    }
+
+    private BigDecimal readDecimal(Cell cell, DataFormatter formatter) {
+        if (cell == null) {
+            return null;
+        }
+        CellType type = cell.getCellType();
+        if (type == CellType.FORMULA) {
+            type = cell.getCachedFormulaResultType();
+        }
+        if (type == CellType.NUMERIC) {
+            return BigDecimal.valueOf(cell.getNumericCellValue());
+        }
+        if (type == CellType.BLANK) {
+            return null;
+        }
+        String text = formatter.formatCellValue(cell).trim().replace(",", "").replace(" ", "");
+        if (text.isEmpty() || "-".equals(text) || "鈥�".equals(text) || "/".equals(text)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(text);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}
diff --git a/traffic-audit-web/src/views/HolidayStat.vue b/traffic-audit-web/src/views/HolidayStat.vue
index db696a7..aeceadc 100644
--- a/traffic-audit-web/src/views/HolidayStat.vue
+++ b/traffic-audit-web/src/views/HolidayStat.vue
@@ -77,6 +77,74 @@
               <el-table-column label="鎬讳汉鍛樻祦鍔ㄩ噺锛堜竾浜烘锛�" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
             </el-table>
           </el-card>
+
+          <el-card shadow="never" style="margin-top:14px">
+            <div slot="header">
+              <span>瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹�</span>
+              <span class="hint">鐢ㄤ簬鍘熷鏁版嵁鐣欏瓨锛屼笉涓庝笂鏂硅妭鍋囨棩缁熻鏁版嵁鍏宠仈</span>
+            </div>
+            <div class="operation-row">
+              <div class="operation-item">
+                <span class="label">瀵煎叆鏁版嵁</span>
+                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
+                           accept=".xls,.xlsx" :on-change="onObservationFileChange" :on-remove="onObservationFileRemove"
+                           :file-list="observationFileList">
+                  <el-button type="primary" plain icon="el-icon-folder-opened">閫夋嫨鏂囦欢</el-button>
+                </el-upload>
+                <el-button type="primary" icon="el-icon-upload2" :loading="observationImporting"
+                           :disabled="!observationFile" @click="importObservation">瀵煎叆</el-button>
+              </div>
+            </div>
+            <div v-if="observationMessage" class="import-message">
+              <pre>{{ observationMessage }}</pre>
+            </div>
+            <el-table :data="observationDates" border stripe size="small" style="margin-top:12px" v-loading="observationLoading">
+              <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="130" align="center"></el-table-column>
+              <el-table-column prop="stationCount" label="瑙傛祴绔欐暟" width="110" align="right"></el-table-column>
+              <el-table-column label="涓皬瀹㈡祦閲忓悎璁★紙涓囪締娆★級" width="210" align="right">
+                <template slot-scope="s">{{ formatWanVehicles(s.row.smallMediumPassengerTotal) }}</template>
+              </el-table-column>
+              <el-table-column prop="importedAt" label="鏈�杩戝鍏ユ椂闂�" min-width="180"></el-table-column>
+              <el-table-column label="鎿嶄綔" width="150" align="center">
+                <template slot-scope="s">
+                  <el-button type="text" @click="viewObservation(s.row.obsDate)">鏌ョ湅</el-button>
+                  <el-button type="text" class="danger-text" @click="deleteObservation(s.row.obsDate)">鍒犻櫎</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-card>
+
+          <el-dialog :title="'瑙傛祴绔欐槑缁� - ' + observationDialogDate" :visible.sync="observationDialogVisible" width="92%" top="5vh">
+            <el-table :data="observationRows" border stripe size="mini" height="540" v-loading="observationRowsLoading">
+              <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="110" fixed="left" align="center"></el-table-column>
+              <el-table-column prop="stationCode" label="瑙傛祴绔欑紪鍙�" width="130" fixed="left"></el-table-column>
+              <el-table-column prop="stationName" label="瑙傛祴绔欏悕绉�" width="120"></el-table-column>
+              <el-table-column label="瑙傛祴閲岀▼" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.mileage) }}</template></el-table-column>
+              <el-table-column prop="cityName" label="鍦板競鍚嶇О" width="140"></el-table-column>
+              <el-table-column prop="countyName" label="鍖哄幙鍚嶇О" width="110"></el-table-column>
+              <el-table-column prop="direction" label="琛岄┒鏂瑰悜" width="90"></el-table-column>
+              <el-table-column label="涓皬瀹㈡祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallMediumPassenger) }}</template></el-table-column>
+              <el-table-column label="澶у杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largePassenger) }}</template></el-table-column>
+              <el-table-column label="灏忚揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallTruck) }}</template></el-table-column>
+              <el-table-column label="涓揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.mediumTruck) }}</template></el-table-column>
+              <el-table-column label="澶ц揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largeTruck) }}</template></el-table-column>
+              <el-table-column label="鐗瑰ぇ璐ф祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.extraLargeTruck) }}</template></el-table-column>
+              <el-table-column label="闆嗚绠辨祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.containerTruck) }}</template></el-table-column>
+              <el-table-column label="鎽╂墭杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorcycle) }}</template></el-table-column>
+              <el-table-column label="鎷栨媺鏈烘祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.tractor) }}</template></el-table-column>
+              <el-table-column label="瀹㈣溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerTotal) }}</template></el-table-column>
+              <el-table-column label="璐ц溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckTotal) }}</template></el-table-column>
+              <el-table-column label="姹借溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carTotal) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorTotal) }}</template></el-table-column>
+              <el-table-column label="瀹㈣溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerEquivalent) }}</template></el-table-column>
+              <el-table-column label="璐ц溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckEquivalent) }}</template></el-table-column>
+              <el-table-column label="姹借溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carEquivalent) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞﹀綋閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorEquivalent) }}</template></el-table-column>
+              <el-table-column label="鏈哄姩杞﹂�熷害" width="110" align="right"><template slot-scope="s">{{ formatPlain(s.row.vehicleSpeed) }}</template></el-table-column>
+              <el-table-column label="鎷ユ尋搴�" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.congestionDegree) }}</template></el-table-column>
+              <el-table-column prop="sourceFile" label="鏉ユ簮鏂囦欢鍚�" min-width="220" show-overflow-tooltip></el-table-column>
+            </el-table>
+          </el-dialog>
         </div>
       </el-main>
     </el-container>
@@ -105,7 +173,17 @@
       fileList: [],
       importMessage: '',
       allowHistoricalOverwrite: false,
-      bulletinDate: this.previousDateString()
+      bulletinDate: this.previousDateString(),
+      observationFile: null,
+      observationFileList: [],
+      observationImporting: false,
+      observationLoading: false,
+      observationMessage: '',
+      observationDates: [],
+      observationDialogVisible: false,
+      observationDialogDate: '',
+      observationRows: [],
+      observationRowsLoading: false
     }
   },
   computed: {
@@ -117,6 +195,7 @@
   mounted() {
     this.loadOptions()
     this.loadOverview()
+    this.loadObservationDates()
   },
   methods: {
     previousDateString() {
@@ -271,9 +350,95 @@
       const data = err && err.response && err.response.data
       this.$message.error((data && data.message) || (err && err.message) || fallback)
     },
+    onObservationFileChange(file) {
+      this.observationFile = file.raw
+      this.observationFileList = [file]
+      this.observationMessage = ''
+    },
+    onObservationFileRemove() {
+      this.observationFile = null
+      this.observationFileList = []
+    },
+    loadObservationDates() {
+      this.observationLoading = true
+      api.get('/observation/dates')
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.observationDates = res.data || []
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹姞杞藉け璐�'))
+        .finally(() => { this.observationLoading = false })
+    },
+    importObservation() {
+      if (!this.observationFile) return
+      const form = new FormData()
+      form.append('file', this.observationFile)
+      this.observationImporting = true
+      this.observationMessage = ''
+      api.post('/observation/import', form, { timeout: 300000, headers: { 'Content-Type': 'multipart/form-data' } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '瀵煎叆澶辫触')
+          const data = res.data || {}
+          const dates = (data.dates && data.dates.length) ? data.dates.join('銆�') : '鈥�'
+          const lines = ['瀵煎叆瀹屾垚锛�' + (data.totalRows || 0) + ' 鏉★紝瑙傛祴绔� ' + (data.stationCount || 0)
+            + ' 涓紝鏃ユ湡 ' + dates + '锛岃烦杩囩┖鐧� ' + (data.skippedRows || 0) + ' 鏉°��']
+          if (data.errors && data.errors.length) {
+            lines.push('澶辫触鏄庣粏锛�')
+            data.errors.forEach(item => lines.push(item))
+          }
+          this.observationMessage = lines.join('\n')
+          this.$message.success('瑙傛祴绔欐暟鎹鍏ュ畬鎴�')
+          this.observationFile = null
+          this.observationFileList = []
+          this.loadObservationDates()
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹鍏ュけ璐�'))
+        .finally(() => { this.observationImporting = false })
+    },
+    viewObservation(date) {
+      this.observationDialogDate = date
+      this.observationDialogVisible = true
+      this.observationRowsLoading = true
+      this.observationRows = []
+      api.get('/observation/rows', { params: { date: date, limit: 2000 } })
+        .then(res => {
+          if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+          this.observationRows = res.data || []
+        })
+        .catch(err => this.showError(err, '瑙傛祴绔欐槑缁嗗姞杞藉け璐�'))
+        .finally(() => { this.observationRowsLoading = false })
+    },
+    deleteObservation(date) {
+      this.$confirm('纭畾鍒犻櫎 ' + date + ' 鐨勮娴嬬珯鏁版嵁鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+        .then(() => api.post('/observation/delete', null, { params: { date: date } }))
+        .then(res => {
+          if (res && res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+          this.$message.success('宸插垹闄�')
+          this.loadObservationDates()
+        })
+        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '鍒犻櫎澶辫触') })
+    },
     formatNumber(value) {
       if (value === null || value === undefined || value === '') return '鈥�'
       return Number(value).toFixed(2)
+    },
+    formatWanVehicles(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return (number / 10000).toFixed(4)
+    },
+    formatInteger(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return String(Math.round(number))
+    },
+    formatPlain(value) {
+      if (value === null || value === undefined || value === '') return '鈥�'
+      const number = Number(value)
+      if (!Number.isFinite(number)) return value
+      return String(number)
     },
     formatCoefficient(value) {
       if (value === null || value === undefined || value === '') return '鈥�'
@@ -299,4 +464,6 @@
 .operation-item .label { color: #606266; font-size: 14px; }
 .import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
 .import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
+.hint { margin-left: 10px; color: #909399; font-size: 12px; }
+.danger-text { color: #f56c6c; }
 </style>

--
Gitblit v1.9.1