feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes: - New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind - 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理 - Data catalog with charts (DMS/ADAS/Lane 3-tab view) - Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance - Audit enhancements: batch operations, rejection categories, Feishu notifications - Operation audit log (操作日志) - World model simulation studio (仿真工坊) - Dataset version management with snapshots and diff - ADAS 7-class dataset integration (138K images organized + compressed) - User management with Feishu integration and pagination - CRUD/search/filter on all pages, card layout redesign - PIL-optimized image overlay rendering - Auto-snapshot on build, in_review workflow stage - Removed embedded algorithm code (now in workspace)
This commit is contained in:
1
platform/as_platform/fleet/.write_test
Normal file
1
platform/as_platform/fleet/.write_test
Normal file
@@ -0,0 +1 @@
|
||||
ok
|
||||
0
platform/as_platform/fleet/__init__.py
Normal file
0
platform/as_platform/fleet/__init__.py
Normal file
21
platform/as_platform/fleet/geo.py
Normal file
21
platform/as_platform/fleet/geo.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Fleet geo helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
EARTH_RADIUS_KM = 6371.0
|
||||
|
||||
|
||||
def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
rlat1, rlng1, rlat2, rlng2 = map(math.radians, (lat1, lng1, lat2, lng2))
|
||||
dlat = rlat2 - rlat1
|
||||
dlng = rlng2 - rlng1
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlng / 2) ** 2
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def simplify_coords(coords: list[list[float]], max_points: int = 2000) -> list[list[float]]:
|
||||
if len(coords) <= max_points:
|
||||
return coords
|
||||
step = max(1, len(coords) // max_points)
|
||||
return [coords[i] for i in range(0, len(coords), step)]
|
||||
212
platform/as_platform/fleet/mock_seed.py
Normal file
212
platform/as_platform/fleet/mock_seed.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Demo fleet seed data (Changsha area, curved paths)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from as_platform.db.models import (
|
||||
FleetCollectionRun,
|
||||
FleetRunMilestone,
|
||||
FleetTrackPoint,
|
||||
FleetVehicle,
|
||||
)
|
||||
from as_platform.fleet.geo import haversine_km
|
||||
|
||||
|
||||
def _catmull_rom_point(
|
||||
p0: tuple[float, float],
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
p3: tuple[float, float],
|
||||
t: float,
|
||||
) -> tuple[float, float]:
|
||||
t2, t3 = t * t, t * t * t
|
||||
lat = 0.5 * (
|
||||
(2 * p1[0])
|
||||
+ (-p0[0] + p2[0]) * t
|
||||
+ (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2
|
||||
+ (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3
|
||||
)
|
||||
lng = 0.5 * (
|
||||
(2 * p1[1])
|
||||
+ (-p0[1] + p2[1]) * t
|
||||
+ (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2
|
||||
+ (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3
|
||||
)
|
||||
return (lat, lng)
|
||||
|
||||
|
||||
def build_curved_route(anchors: list[tuple[float, float]], steps_per_seg: int = 24) -> list[tuple[float, float]]:
|
||||
"""Catmull-Rom 样条密点,地图折线呈弯道。"""
|
||||
if len(anchors) < 2:
|
||||
return list(anchors)
|
||||
pts = [anchors[0], *anchors, anchors[-1]]
|
||||
path: list[tuple[float, float]] = []
|
||||
for i in range(1, len(pts) - 2):
|
||||
p0, p1, p2, p3 = pts[i - 1], pts[i], pts[i + 1], pts[i + 2]
|
||||
for j in range(steps_per_seg):
|
||||
t = j / steps_per_seg
|
||||
path.append(_catmull_rom_point(p0, p1, p2, p3, t))
|
||||
path.append(anchors[-1])
|
||||
return path
|
||||
|
||||
|
||||
def _wiggle(path: list[tuple[float, float]], amp: float = 0.004) -> list[tuple[float, float]]:
|
||||
"""叠加轻微蛇形偏移,避免视觉上像直线。"""
|
||||
out: list[tuple[float, float]] = []
|
||||
for i, (lat, lng) in enumerate(path):
|
||||
w = math.sin(i * 0.35) * amp
|
||||
out.append((lat + w, lng - w * 0.6))
|
||||
return out
|
||||
|
||||
|
||||
# 长沙:锚点刻意折线(之字形 / 弧形),再样条平滑
|
||||
_ANCHORS: dict[str, list[tuple[float, float]]] = {
|
||||
"TBOX-001": [
|
||||
(28.172, 112.902),
|
||||
(28.188, 112.918),
|
||||
(28.205, 112.908),
|
||||
(28.222, 112.928),
|
||||
(28.238, 112.948),
|
||||
(28.252, 112.978),
|
||||
(28.248, 113.012),
|
||||
(28.235, 113.042),
|
||||
(28.218, 113.058),
|
||||
(28.202, 113.048),
|
||||
],
|
||||
"TBOX-002": [
|
||||
(28.278, 112.948),
|
||||
(28.270, 112.972),
|
||||
(28.255, 113.002),
|
||||
(28.238, 113.028),
|
||||
(28.218, 113.048),
|
||||
(28.202, 113.038),
|
||||
(28.198, 113.008),
|
||||
(28.208, 112.978),
|
||||
(28.225, 112.958),
|
||||
(28.248, 112.952),
|
||||
],
|
||||
"TBOX-003": [
|
||||
(28.212, 112.918),
|
||||
(28.225, 112.938),
|
||||
(28.242, 112.928),
|
||||
(28.258, 112.948),
|
||||
(28.272, 112.972),
|
||||
(28.268, 113.002),
|
||||
(28.252, 113.022),
|
||||
(28.232, 113.028),
|
||||
(28.215, 113.012),
|
||||
(28.208, 112.982),
|
||||
(28.212, 112.918),
|
||||
],
|
||||
}
|
||||
|
||||
ROUTES: dict[str, list[tuple[float, float]]] = {
|
||||
k: _wiggle(build_curved_route(v, steps_per_seg=28), amp=0.003)
|
||||
for k, v in _ANCHORS.items()
|
||||
}
|
||||
|
||||
VEHICLES = [
|
||||
("TBOX-001", "湘A·采集01", "岳麓采集车 A", "数据部"),
|
||||
("TBOX-002", "湘A·采集02", "开福采集车 B", "数据部"),
|
||||
("TBOX-003", "湘A·采集03", "天心采集车 C", "数据部"),
|
||||
]
|
||||
|
||||
|
||||
def _add_points(db: Session, run_id: int, coords: list[tuple[float, float]], start: datetime, interval_sec: int = 12) -> float:
|
||||
mileage = 0.0
|
||||
prev = None
|
||||
for i, (lat, lng) in enumerate(coords):
|
||||
ts = start + timedelta(seconds=i * interval_sec)
|
||||
speed = 35.0 + (i % 5) * 3.0
|
||||
pt = FleetTrackPoint(run_id=run_id, ts=ts, lat=lat, lng=lng, speed_kmh=speed, heading=float(i * 10 % 360))
|
||||
db.add(pt)
|
||||
if prev:
|
||||
mileage += haversine_km(prev[0], prev[1], lat, lng)
|
||||
prev = (lat, lng)
|
||||
return mileage
|
||||
|
||||
|
||||
def clear_demo_fleet(db: Session) -> None:
|
||||
db.query(FleetRunMilestone).delete()
|
||||
db.query(FleetTrackPoint).delete()
|
||||
db.query(FleetCollectionRun).delete()
|
||||
db.query(FleetVehicle).delete()
|
||||
db.flush()
|
||||
|
||||
|
||||
def seed_demo_fleet(db: Session) -> bool:
|
||||
if db.query(FleetVehicle).count() > 0:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
for device_id, plate, name, team in VEHICLES:
|
||||
coords = ROUTES[device_id]
|
||||
v = FleetVehicle(
|
||||
plate_no=plate,
|
||||
tbox_device_id=device_id,
|
||||
name=name,
|
||||
team=team,
|
||||
status="active",
|
||||
online=True,
|
||||
last_lat=coords[-1][0],
|
||||
last_lng=coords[-1][1],
|
||||
last_speed_kmh=42.0,
|
||||
last_ts=now,
|
||||
)
|
||||
v.set_meta({"sim_index": 0, "sim_route": device_id, "sim_dir": 1})
|
||||
db.add(v)
|
||||
db.flush()
|
||||
|
||||
ended_start = now - timedelta(hours=6)
|
||||
ended = FleetCollectionRun(
|
||||
vehicle_id=v.id,
|
||||
run_no=f"{device_id}-{(ended_start.strftime('%Y%m%d'))}-01",
|
||||
engineer="陈工",
|
||||
project="dms",
|
||||
batch="demo_batch_01",
|
||||
started_at=ended_start,
|
||||
ended_at=ended_start + timedelta(hours=2),
|
||||
status="ended",
|
||||
source="mock",
|
||||
note="演示历史趟次(长沙弯道)",
|
||||
)
|
||||
db.add(ended)
|
||||
db.flush()
|
||||
em = _add_points(db, ended.id, coords, ended_start)
|
||||
ended.mileage_km = round(em, 3)
|
||||
db.add(FleetRunMilestone(run_id=ended.id, type="start", name="出发", lat=coords[0][0], lng=coords[0][1], mileage_km=0.0, occurred_at=ended_start))
|
||||
db.add(FleetRunMilestone(run_id=ended.id, type="end", name="结束", lat=coords[-1][0], lng=coords[-1][1], mileage_km=ended.mileage_km, occurred_at=ended.ended_at))
|
||||
db.add(FleetRunMilestone(run_id=ended.id, type="data_site", name="采集点-1", lat=coords[len(coords)//2][0], lng=coords[len(coords)//2][1], mileage_km=ended.mileage_km / 2, occurred_at=ended_start + timedelta(hours=1)))
|
||||
|
||||
active_start = now - timedelta(minutes=45)
|
||||
active = FleetCollectionRun(
|
||||
vehicle_id=v.id,
|
||||
run_no=f"{device_id}-{now.strftime('%Y%m%d')}-live",
|
||||
engineer="李工",
|
||||
project="lane",
|
||||
batch="demo_batch_live",
|
||||
started_at=active_start,
|
||||
status="active",
|
||||
source="mock",
|
||||
note="演示进行中趟次(长沙弯道)",
|
||||
)
|
||||
db.add(active)
|
||||
db.flush()
|
||||
# 进行中趟次也写入完整弯道轨迹(不再只种前半段)
|
||||
am = _add_points(db, active.id, coords, active_start)
|
||||
active.mileage_km = round(am, 3)
|
||||
v.last_lat = coords[-1][0]
|
||||
v.last_lng = coords[-1][1]
|
||||
v.last_ts = active_start + timedelta(seconds=(len(coords) - 1) * 12)
|
||||
v.set_meta({"sim_index": len(coords) - 1, "sim_route": device_id, "sim_dir": -1})
|
||||
db.add(FleetRunMilestone(run_id=active.id, type="start", name="出发", lat=coords[0][0], lng=coords[0][1], mileage_km=0.0, occurred_at=active_start))
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def reseed_demo_fleet(db: Session) -> bool:
|
||||
"""清空并重新注入长沙演示数据。"""
|
||||
clear_demo_fleet(db)
|
||||
return seed_demo_fleet(db)
|
||||
544
platform/as_platform/fleet/service.py
Normal file
544
platform/as_platform/fleet/service.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""Fleet map business logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from as_platform.db.models import (
|
||||
FleetCollectionRun,
|
||||
FleetRunMilestone,
|
||||
FleetTrackPoint,
|
||||
FleetVehicle,
|
||||
)
|
||||
from as_platform.fleet.geo import haversine_km, simplify_coords
|
||||
from as_platform.fleet.mock_seed import ROUTES
|
||||
from as_platform.redis.bus import publish
|
||||
|
||||
RUN_IDLE_TIMEOUT_MIN = 10
|
||||
|
||||
|
||||
def _parse_ts(ts: str | datetime | None) -> datetime:
|
||||
if ts is None:
|
||||
return datetime.now(timezone.utc)
|
||||
if isinstance(ts, datetime):
|
||||
return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
|
||||
s = str(ts).strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def get_vehicle_by_device(db: Session, device_id: str) -> FleetVehicle | None:
|
||||
return db.query(FleetVehicle).filter_by(tbox_device_id=device_id).first()
|
||||
|
||||
|
||||
def get_active_run(db: Session, vehicle_id: int) -> FleetCollectionRun | None:
|
||||
return (
|
||||
db.query(FleetCollectionRun)
|
||||
.filter_by(vehicle_id=vehicle_id, status="active")
|
||||
.order_by(desc(FleetCollectionRun.id))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def recalc_run_mileage(db: Session, run_id: int) -> float:
|
||||
pts = (
|
||||
db.query(FleetTrackPoint)
|
||||
.filter_by(run_id=run_id)
|
||||
.order_by(FleetTrackPoint.ts.asc())
|
||||
.all()
|
||||
)
|
||||
total = 0.0
|
||||
prev = None
|
||||
for p in pts:
|
||||
if prev:
|
||||
total += haversine_km(prev[0], prev[1], p.lat, p.lng)
|
||||
prev = (p.lat, p.lng)
|
||||
run = db.get(FleetCollectionRun, run_id)
|
||||
if run:
|
||||
run.mileage_km = round(total, 3)
|
||||
return total
|
||||
|
||||
|
||||
def _ensure_start_milestone(db: Session, run: FleetCollectionRun, lat: float, lng: float, ts: datetime) -> None:
|
||||
exists = db.query(FleetRunMilestone).filter_by(run_id=run.id, type="start").first()
|
||||
if exists:
|
||||
return
|
||||
db.add(FleetRunMilestone(run_id=run.id, type="start", name="出发", lat=lat, lng=lng, mileage_km=0.0, occurred_at=ts))
|
||||
|
||||
|
||||
def _close_run(db: Session, run: FleetCollectionRun, lat: float, lng: float, ts: datetime) -> None:
|
||||
run.status = "ended"
|
||||
run.ended_at = ts
|
||||
recalc_run_mileage(db, run.id)
|
||||
end_ms = db.query(FleetRunMilestone).filter_by(run_id=run.id, type="end").first()
|
||||
if not end_ms:
|
||||
db.add(
|
||||
FleetRunMilestone(
|
||||
run_id=run.id,
|
||||
type="end",
|
||||
name="结束",
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
mileage_km=run.mileage_km,
|
||||
occurred_at=ts,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ingest_tbox_gps(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
device_id = str(payload.get("device_id") or "").strip()
|
||||
if not device_id:
|
||||
raise ValueError("device_id 必填")
|
||||
lat = float(payload["lat"])
|
||||
lng = float(payload["lng"])
|
||||
ts = _parse_ts(payload.get("ts"))
|
||||
speed = float(payload["speed_kmh"]) if payload.get("speed_kmh") is not None else None
|
||||
heading = float(payload["heading"]) if payload.get("heading") is not None else None
|
||||
run_signal = str(payload.get("run_signal") or "active").lower()
|
||||
plate_no = str(payload.get("plate_no") or "").strip()
|
||||
|
||||
vehicle = get_vehicle_by_device(db, device_id)
|
||||
if not vehicle:
|
||||
vehicle = FleetVehicle(
|
||||
plate_no=plate_no or device_id,
|
||||
tbox_device_id=device_id,
|
||||
name=plate_no or device_id,
|
||||
team="T-Box",
|
||||
status="active",
|
||||
online=True,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.flush()
|
||||
|
||||
vehicle.last_lat = lat
|
||||
vehicle.last_lng = lng
|
||||
vehicle.last_speed_kmh = speed
|
||||
vehicle.last_ts = ts
|
||||
vehicle.online = True
|
||||
|
||||
run = get_active_run(db, vehicle.id)
|
||||
if run_signal in ("idle", "off", "end", "stopped"):
|
||||
if run:
|
||||
_close_run(db, run, lat, lng, ts)
|
||||
vehicle.online = False
|
||||
db.flush()
|
||||
publish("fleet.gps", {"vehicle_id": vehicle.id, "device_id": device_id, "lat": lat, "lng": lng})
|
||||
return {"ok": True, "vehicle": vehicle.to_dict(), "run": run.to_dict() if run else None, "closed": True}
|
||||
|
||||
if not run:
|
||||
run_no = f"{device_id}-{ts.strftime('%Y%m%d%H%M')}"
|
||||
run = FleetCollectionRun(
|
||||
vehicle_id=vehicle.id,
|
||||
run_no=run_no,
|
||||
engineer=payload.get("engineer"),
|
||||
project=payload.get("project"),
|
||||
batch=payload.get("batch"),
|
||||
started_at=ts,
|
||||
status="active",
|
||||
source="tbox",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
_ensure_start_milestone(db, run, lat, lng, ts)
|
||||
|
||||
prev = (
|
||||
db.query(FleetTrackPoint)
|
||||
.filter_by(run_id=run.id)
|
||||
.order_by(desc(FleetTrackPoint.ts))
|
||||
.first()
|
||||
)
|
||||
seg = 0.0
|
||||
if prev:
|
||||
seg = haversine_km(prev.lat, prev.lng, lat, lng)
|
||||
db.add(FleetTrackPoint(run_id=run.id, ts=ts, lat=lat, lng=lng, speed_kmh=speed, heading=heading))
|
||||
run.mileage_km = round((run.mileage_km or 0.0) + seg, 3)
|
||||
db.flush()
|
||||
publish("fleet.gps", {"vehicle_id": vehicle.id, "run_id": run.id, "lat": lat, "lng": lng})
|
||||
return {"ok": True, "vehicle": vehicle.to_dict(), "run": run.to_dict()}
|
||||
|
||||
|
||||
def list_vehicles(db: Session) -> list[dict]:
|
||||
return [v.to_dict() for v in db.query(FleetVehicle).order_by(FleetVehicle.id).all()]
|
||||
|
||||
|
||||
def get_vehicle(db: Session, vehicle_id: int) -> dict | None:
|
||||
v = db.get(FleetVehicle, vehicle_id)
|
||||
return v.to_dict() if v else None
|
||||
|
||||
|
||||
def create_vehicle(db: Session, data: dict[str, Any]) -> dict:
|
||||
device_id = str(data.get("tbox_device_id") or "").strip()
|
||||
plate_no = str(data.get("plate_no") or "").strip()
|
||||
if not device_id:
|
||||
raise ValueError("tbox_device_id 必填")
|
||||
if not plate_no:
|
||||
raise ValueError("plate_no 必填")
|
||||
if get_vehicle_by_device(db, device_id):
|
||||
raise ValueError(f"设备 ID 已存在: {device_id}")
|
||||
v = FleetVehicle(
|
||||
plate_no=plate_no,
|
||||
tbox_device_id=device_id,
|
||||
name=str(data.get("name") or plate_no).strip(),
|
||||
team=str(data.get("team") or "").strip() or None,
|
||||
status=str(data.get("status") or "active").strip() or "active",
|
||||
online=False,
|
||||
)
|
||||
db.add(v)
|
||||
db.flush()
|
||||
return v.to_dict()
|
||||
|
||||
|
||||
def update_vehicle(db: Session, vehicle_id: int, data: dict[str, Any]) -> dict:
|
||||
v = db.get(FleetVehicle, vehicle_id)
|
||||
if not v:
|
||||
raise ValueError("车辆不存在")
|
||||
if "tbox_device_id" in data and data["tbox_device_id"]:
|
||||
new_dev = str(data["tbox_device_id"]).strip()
|
||||
other = get_vehicle_by_device(db, new_dev)
|
||||
if other and other.id != vehicle_id:
|
||||
raise ValueError(f"设备 ID 已被占用: {new_dev}")
|
||||
v.tbox_device_id = new_dev
|
||||
if "plate_no" in data and data["plate_no"]:
|
||||
v.plate_no = str(data["plate_no"]).strip()
|
||||
if "name" in data:
|
||||
v.name = str(data["name"] or v.plate_no).strip()
|
||||
if "team" in data:
|
||||
v.team = str(data["team"]).strip() or None
|
||||
if "status" in data and data["status"]:
|
||||
v.status = str(data["status"]).strip()
|
||||
db.flush()
|
||||
return v.to_dict()
|
||||
|
||||
|
||||
def delete_vehicle(db: Session, vehicle_id: int) -> None:
|
||||
v = db.get(FleetVehicle, vehicle_id)
|
||||
if not v:
|
||||
raise ValueError("车辆不存在")
|
||||
if get_active_run(db, vehicle_id):
|
||||
raise ValueError("该车有进行中的趟次,请先结束趟次后再删除")
|
||||
db.delete(v)
|
||||
|
||||
|
||||
def _run_duration_sec(run: FleetCollectionRun) -> int:
|
||||
if not run.started_at:
|
||||
return 0
|
||||
end = run.ended_at
|
||||
if end is None and run.status == "active":
|
||||
end = datetime.now(timezone.utc)
|
||||
if end is None:
|
||||
return 0
|
||||
return max(0, int((end - run.started_at).total_seconds()))
|
||||
|
||||
|
||||
def list_runs(
|
||||
db: Session,
|
||||
vehicle_id: int | None = None,
|
||||
status: str | None = None,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
q = db.query(FleetCollectionRun).order_by(desc(FleetCollectionRun.started_at))
|
||||
if vehicle_id:
|
||||
q = q.filter_by(vehicle_id=vehicle_id)
|
||||
if status:
|
||||
q = q.filter_by(status=status)
|
||||
total = q.count()
|
||||
rows = q.offset(max(0, offset)).limit(max(1, limit)).all()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
d = r.to_dict()
|
||||
d["point_count"] = db.query(FleetTrackPoint).filter_by(run_id=r.id).count()
|
||||
d["milestone_count"] = db.query(FleetRunMilestone).filter_by(run_id=r.id).count()
|
||||
d["duration_sec"] = _run_duration_sec(r)
|
||||
out.append(d)
|
||||
return {"items": out, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
|
||||
def get_run_detail(db: Session, run_id: int) -> dict | None:
|
||||
run = db.get(FleetCollectionRun, run_id)
|
||||
if not run:
|
||||
return None
|
||||
vehicle = db.get(FleetVehicle, run.vehicle_id)
|
||||
milestones = (
|
||||
db.query(FleetRunMilestone).filter_by(run_id=run_id).order_by(FleetRunMilestone.occurred_at.asc()).all()
|
||||
)
|
||||
return {
|
||||
"run": run.to_dict(),
|
||||
"vehicle": vehicle.to_dict() if vehicle else None,
|
||||
"milestones": [m.to_dict() for m in milestones],
|
||||
}
|
||||
|
||||
|
||||
def close_idle_runs(db: Session) -> int:
|
||||
"""无 GPS 超过 RUN_IDLE_TIMEOUT_MIN 的 active 趟次自动结束。"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=RUN_IDLE_TIMEOUT_MIN)
|
||||
closed = 0
|
||||
for run in db.query(FleetCollectionRun).filter_by(status="active").all():
|
||||
vehicle = db.get(FleetVehicle, run.vehicle_id)
|
||||
last_ts = vehicle.last_ts if vehicle else None
|
||||
if not last_ts or last_ts >= cutoff:
|
||||
continue
|
||||
lat = float(vehicle.last_lat or 0) if vehicle else 0.0
|
||||
lng = float(vehicle.last_lng or 0) if vehicle else 0.0
|
||||
_close_run(db, run, lat, lng, datetime.now(timezone.utc))
|
||||
if vehicle:
|
||||
vehicle.online = False
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
|
||||
def ingest_tbox_gps_batch(db: Session, points: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not points:
|
||||
raise ValueError("points 不能为空")
|
||||
if len(points) > 100:
|
||||
raise ValueError("单次最多 100 个点")
|
||||
results: list[dict[str, Any]] = []
|
||||
for payload in points:
|
||||
results.append(ingest_tbox_gps(db, payload))
|
||||
return {"ok": True, "count": len(results), "last": results[-1] if results else None}
|
||||
|
||||
|
||||
def _parse_csv_track(content: str) -> list[tuple[float, float, datetime | None]]:
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
rows = list(csv.reader(StringIO(content.strip())))
|
||||
if not rows:
|
||||
return []
|
||||
start = 0
|
||||
header = [c.strip().lower() for c in rows[0]]
|
||||
if any(h in header for h in ("lat", "lng", "lon", "latitude", "longitude")):
|
||||
start = 1
|
||||
points: list[tuple[float, float, datetime | None]] = []
|
||||
for row in rows[start:]:
|
||||
if len(row) < 2:
|
||||
continue
|
||||
try:
|
||||
if start and len(header) >= 2:
|
||||
idx = {h: i for i, h in enumerate(header)}
|
||||
lat_i = idx.get("lat", idx.get("latitude", 0))
|
||||
lng_i = idx.get("lng", idx.get("lon", idx.get("longitude", 1)))
|
||||
lat = float(row[lat_i])
|
||||
lng = float(row[lng_i])
|
||||
ts_i = idx.get("ts") or idx.get("time")
|
||||
ts = _parse_ts(row[ts_i]) if ts_i is not None and ts_i < len(row) else None
|
||||
else:
|
||||
lat = float(row[0])
|
||||
lng = float(row[1])
|
||||
ts = _parse_ts(row[2]) if len(row) > 2 and row[2].strip() else None
|
||||
points.append((lat, lng, ts))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return points
|
||||
|
||||
|
||||
def import_csv_run(db: Session, *, vehicle_id: int, csv_content: str, note: str | None = None, project: str | None = None, batch: str | None = None) -> dict[str, Any]:
|
||||
vehicle = db.get(FleetVehicle, vehicle_id)
|
||||
if not vehicle:
|
||||
raise ValueError("车辆不存在")
|
||||
pts = _parse_csv_track(csv_content)
|
||||
if len(pts) < 2:
|
||||
raise ValueError("CSV 轨迹点不足")
|
||||
active = get_active_run(db, vehicle_id)
|
||||
if active:
|
||||
_close_run(db, active, pts[0][0], pts[0][1], pts[0][2] or datetime.now(timezone.utc))
|
||||
start_ts = pts[0][2] or datetime.now(timezone.utc)
|
||||
run_no = f"csv-{vehicle_id}-{int(start_ts.timestamp())}"
|
||||
run = FleetCollectionRun(
|
||||
vehicle_id=vehicle_id,
|
||||
run_no=run_no,
|
||||
status="ended",
|
||||
source="csv",
|
||||
note=note,
|
||||
project=project,
|
||||
batch=batch,
|
||||
started_at=start_ts,
|
||||
ended_at=pts[-1][2] or datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
for lat, lng, ts in pts:
|
||||
t = ts or start_ts
|
||||
db.add(FleetTrackPoint(run_id=run.id, lat=lat, lng=lng, ts=t, speed_kmh=0.0))
|
||||
_ensure_start_milestone(db, run, pts[0][0], pts[0][1], start_ts)
|
||||
_close_run(db, run, pts[-1][0], pts[-1][1], pts[-1][2] or datetime.now(timezone.utc))
|
||||
recalc_run_mileage(db, run.id)
|
||||
return {"ok": True, "run_id": run.id, "point_count": len(pts), "mileage_km": run.mileage_km}
|
||||
|
||||
|
||||
def get_live_fleet(db: Session) -> dict[str, Any]:
|
||||
close_idle_runs(db)
|
||||
vehicles = db.query(FleetVehicle).order_by(FleetVehicle.id).all()
|
||||
items = []
|
||||
for v in vehicles:
|
||||
active = get_active_run(db, v.id)
|
||||
items.append(
|
||||
{
|
||||
**v.to_dict(),
|
||||
"active_run_id": active.id if active else None,
|
||||
"active_mileage_km": active.mileage_km if active else None,
|
||||
"active_run_no": active.run_no if active else None,
|
||||
}
|
||||
)
|
||||
active_runs = db.query(FleetCollectionRun).filter_by(status="active").count()
|
||||
total_km = sum(r.mileage_km or 0.0 for r in db.query(FleetCollectionRun).all())
|
||||
return {
|
||||
"vehicles": items,
|
||||
"stats": {
|
||||
"vehicle_count": len(vehicles),
|
||||
"online_count": sum(1 for v in vehicles if v.online),
|
||||
"active_runs": active_runs,
|
||||
"total_mileage_km": round(total_km, 2),
|
||||
},
|
||||
"mock": True,
|
||||
}
|
||||
|
||||
|
||||
def get_run_track_geojson(db: Session, run_id: int, max_points: int = 5000) -> dict:
|
||||
pts = (
|
||||
db.query(FleetTrackPoint)
|
||||
.filter_by(run_id=run_id)
|
||||
.order_by(FleetTrackPoint.ts.asc())
|
||||
.all()
|
||||
)
|
||||
coords = [[p.lng, p.lat] for p in pts]
|
||||
coords = simplify_coords(coords, max_points)
|
||||
milestones = db.query(FleetRunMilestone).filter_by(run_id=run_id).all()
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"kind": "track", "run_id": run_id, "point_count": len(pts)},
|
||||
"geometry": {"type": "LineString", "coordinates": coords},
|
||||
},
|
||||
*[
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"kind": "milestone", **m.to_dict()},
|
||||
"geometry": {"type": "Point", "coordinates": [m.lng, m.lat]},
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
],
|
||||
"points": [p.to_dict() for p in pts[-200:]],
|
||||
}
|
||||
|
||||
|
||||
def fleet_summary(db: Session) -> dict:
|
||||
runs = db.query(FleetCollectionRun).all()
|
||||
by_vehicle: dict[int, float] = {}
|
||||
for r in runs:
|
||||
by_vehicle[r.vehicle_id] = by_vehicle.get(r.vehicle_id, 0.0) + (r.mileage_km or 0.0)
|
||||
vehicles = {v.id: v for v in db.query(FleetVehicle).all()}
|
||||
per_vehicle = [
|
||||
{
|
||||
"vehicle_id": vid,
|
||||
"plate_no": vehicles[vid].plate_no if vid in vehicles else str(vid),
|
||||
"mileage_km": round(km, 2),
|
||||
}
|
||||
for vid, km in by_vehicle.items()
|
||||
]
|
||||
return {
|
||||
"total_runs": len(runs),
|
||||
"active_runs": sum(1 for r in runs if r.status == "active"),
|
||||
"total_mileage_km": round(sum(r.mileage_km or 0.0 for r in runs), 2),
|
||||
"per_vehicle": per_vehicle,
|
||||
}
|
||||
|
||||
|
||||
def simulate_tick(db: Session) -> int:
|
||||
"""Advance mock vehicles along ROUTES (demo)."""
|
||||
n = 0
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in db.query(FleetVehicle).all():
|
||||
meta = v.meta()
|
||||
route_key = meta.get("sim_route") or v.tbox_device_id
|
||||
coords = ROUTES.get(route_key)
|
||||
if not coords:
|
||||
continue
|
||||
run = get_active_run(db, v.id)
|
||||
if not run:
|
||||
continue
|
||||
idx = int(meta.get("sim_index", 0))
|
||||
direction = int(meta.get("sim_dir", 1))
|
||||
next_idx = idx + direction
|
||||
if next_idx >= len(coords):
|
||||
next_idx = len(coords) - 2
|
||||
direction = -1
|
||||
elif next_idx < 0:
|
||||
next_idx = 1
|
||||
direction = 1
|
||||
lat, lng = coords[next_idx]
|
||||
payload = {
|
||||
"device_id": v.tbox_device_id,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"ts": now.isoformat(),
|
||||
"speed_kmh": 38.0 + (next_idx % 4),
|
||||
"run_signal": "active",
|
||||
}
|
||||
ingest_tbox_gps(db, payload)
|
||||
v.set_meta({**meta, "sim_index": next_idx, "sim_route": route_key, "sim_dir": direction})
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _parse_gpx_points(content: str) -> list[tuple[float, float, datetime | None]]:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(content)
|
||||
ns = ""
|
||||
if root.tag.startswith("{"):
|
||||
ns = root.tag.split("}")[0] + "}"
|
||||
points: list[tuple[float, float, datetime | None]] = []
|
||||
for trkpt in root.iter(f"{ns}trkpt"):
|
||||
lat_s = trkpt.get("lat")
|
||||
lon_s = trkpt.get("lon")
|
||||
if lat_s is None or lon_s is None:
|
||||
continue
|
||||
ts_el = trkpt.find(f"{ns}time")
|
||||
ts = _parse_ts(ts_el.text) if ts_el is not None and ts_el.text else None
|
||||
points.append((float(lat_s), float(lon_s), ts))
|
||||
return points
|
||||
|
||||
|
||||
def import_gpx_run(db: Session, *, vehicle_id: int, gpx_content: str, note: str | None = None) -> dict[str, Any]:
|
||||
vehicle = db.get(FleetVehicle, vehicle_id)
|
||||
if not vehicle:
|
||||
raise ValueError("车辆不存在")
|
||||
pts = _parse_gpx_points(gpx_content)
|
||||
if len(pts) < 2:
|
||||
raise ValueError("GPX 轨迹点不足")
|
||||
active = get_active_run(db, vehicle_id)
|
||||
if active:
|
||||
_close_run(db, active, pts[0][0], pts[0][1], pts[0][2] or datetime.now(timezone.utc))
|
||||
start_ts = pts[0][2] or datetime.now(timezone.utc)
|
||||
run_no = f"gpx-{vehicle_id}-{int(start_ts.timestamp())}"
|
||||
run = FleetCollectionRun(
|
||||
vehicle_id=vehicle_id,
|
||||
run_no=run_no,
|
||||
status="ended",
|
||||
source="gpx",
|
||||
note=note,
|
||||
started_at=start_ts,
|
||||
ended_at=pts[-1][2] or datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
prev = None
|
||||
for lat, lng, ts in pts:
|
||||
t = ts or start_ts
|
||||
db.add(FleetTrackPoint(run_id=run.id, lat=lat, lng=lng, ts=t, speed_kmh=0.0))
|
||||
if prev is None:
|
||||
_ensure_start_milestone(db, run, lat, lng, t)
|
||||
prev = (lat, lng)
|
||||
end_ts = pts[-1][2] or datetime.now(timezone.utc)
|
||||
_close_run(db, run, pts[-1][0], pts[-1][1], end_ts)
|
||||
recalc_run_mileage(db, run.id)
|
||||
return {"ok": True, "run_id": run.id, "point_count": len(pts), "mileage_km": run.mileage_km}
|
||||
Reference in New Issue
Block a user