feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤

- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT)
- 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段
- 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索
- 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState)
- 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖
- TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播
- 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板
- 新增fence_attendance WebSocket topic

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-27 13:04:11 +00:00
parent cde5146bfe
commit 1d06cc5415
17 changed files with 2303 additions and 187 deletions

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import (
BigInteger,
@@ -14,13 +14,10 @@ from sqlalchemy import (
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.config import now_cst as _utcnow
from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Device(Base):
"""Registered Bluetooth badge devices."""
@@ -80,6 +77,7 @@ class LocationRecord(Base):
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
location_type: Mapped[str] = mapped_column(
String(10), nullable=False
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
@@ -125,6 +123,7 @@ class AlarmRecord(Base):
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
alarm_type: Mapped[str] = mapped_column(
String(30), nullable=False
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
@@ -170,6 +169,7 @@ class HeartbeatRecord(Base):
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -195,6 +195,7 @@ class AttendanceRecord(Base):
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
attendance_type: Mapped[str] = mapped_column(
String(20), nullable=False
) # clock_in, clock_out
@@ -238,6 +239,7 @@ class BluetoothRecord(Base):
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
record_type: Mapped[str] = mapped_column(
String(20), nullable=False
) # punch, location
@@ -291,6 +293,80 @@ class BeaconConfig(Base):
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
class FenceConfig(Base):
"""Geofence configuration for area monitoring."""
__tablename__ = "fence_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
fence_type: Mapped[str] = mapped_column(String(20), nullable=False) # circle / polygon / rectangle
# Circle center (WGS-84) or polygon centroid for display
center_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
center_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
radius: Mapped[float | None] = mapped_column(Float, nullable=True) # meters, for circle
# Polygon/rectangle vertices as JSON: [[lng,lat], [lng,lat], ...] (WGS-84)
points: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False)
fill_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
fill_opacity: Mapped[float] = mapped_column(Float, default=0.2, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
def __repr__(self) -> str:
return f"<FenceConfig(id={self.id}, name={self.name}, type={self.fence_type})>"
class DeviceFenceBinding(Base):
"""Many-to-many binding between devices and geofences."""
__tablename__ = "device_fence_bindings"
__table_args__ = (
Index("ix_dfb_device_fence", "device_id", "fence_id", unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
fence_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<DeviceFenceBinding(device_id={self.device_id}, fence_id={self.fence_id})>"
class DeviceFenceState(Base):
"""Runtime state tracking: is a device currently inside a fence?"""
__tablename__ = "device_fence_states"
__table_args__ = (
Index("ix_dfs_device_fence", "device_id", "fence_id", unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
fence_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
)
is_inside: Mapped[bool] = mapped_column(Integer, default=0, nullable=False)
last_transition_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
last_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
def __repr__(self) -> str:
return f"<DeviceFenceState(device_id={self.device_id}, fence_id={self.fence_id}, inside={self.is_inside})>"
class CommandLog(Base):
"""Log of commands sent to devices."""