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