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:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user