feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息
- 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫) - LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m) - 地图图例显示各定位类型颜色和精度范围 - 精度圈添加 bubble:true 防止遮挡轨迹点点击 - 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹) - 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom - 最新位置轮询超时从15s延长至30s (适配LBS慢响应) - 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用) - 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取) - 考勤来源字段 attendance_source 区分 device/bluetooth/fence via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -4,18 +4,18 @@ Checks whether a device's reported coordinates fall inside its bound fences.
|
||||
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import now_cst, settings
|
||||
from app.models import (
|
||||
AttendanceRecord,
|
||||
Device,
|
||||
DeviceFenceBinding,
|
||||
DeviceFenceState,
|
||||
FenceConfig,
|
||||
@@ -159,6 +159,31 @@ def _get_tolerance_for_location_type(location_type: str) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daily dedup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _has_attendance_today(
|
||||
session: AsyncSession,
|
||||
device_id: int,
|
||||
attendance_type: str,
|
||||
) -> bool:
|
||||
"""Check if device already has an attendance record of given type today (CST)."""
|
||||
cst_now = datetime.now(timezone(timedelta(hours=8)))
|
||||
day_start = cst_now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(AttendanceRecord).where(
|
||||
AttendanceRecord.device_id == device_id,
|
||||
AttendanceRecord.attendance_type == attendance_type,
|
||||
AttendanceRecord.recorded_at >= day_start,
|
||||
AttendanceRecord.recorded_at < day_end,
|
||||
)
|
||||
)
|
||||
return (result.scalar() or 0) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main fence check entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -173,6 +198,11 @@ async def check_device_fences(
|
||||
location_type: str,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
*,
|
||||
mcc: Optional[int] = None,
|
||||
mnc: Optional[int] = None,
|
||||
lac: Optional[int] = None,
|
||||
cell_id: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
"""Check all bound active fences for a device. Returns attendance events.
|
||||
|
||||
@@ -192,6 +222,17 @@ async def check_device_fences(
|
||||
if not fences:
|
||||
return []
|
||||
|
||||
# Query device for latest battery/signal info (from heartbeats)
|
||||
device = await session.get(Device, device_id)
|
||||
device_info = {
|
||||
"battery_level": device.battery_level if device else None,
|
||||
"gsm_signal": device.gsm_signal if device else None,
|
||||
"mcc": mcc,
|
||||
"mnc": mnc,
|
||||
"lac": lac,
|
||||
"cell_id": cell_id,
|
||||
}
|
||||
|
||||
tolerance = _get_tolerance_for_location_type(location_type)
|
||||
events: list[dict] = []
|
||||
now = now_cst()
|
||||
@@ -224,21 +265,28 @@ async def check_device_fences(
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_in", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
# Daily dedup: only one clock_in per device per day
|
||||
if await _has_attendance_today(session, device_id, "clock_in"):
|
||||
logger.info(
|
||||
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
else:
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_in", latitude, longitude,
|
||||
address, recorded_at, fence, device_info,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_in",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_in: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_in",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_in: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
elif not currently_inside and was_inside:
|
||||
# EXIT: inside -> outside = clock_out
|
||||
@@ -252,21 +300,28 @@ async def check_device_fences(
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_out", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
# Daily dedup: only one clock_out per device per day
|
||||
if await _has_attendance_today(session, device_id, "clock_out"):
|
||||
logger.info(
|
||||
"Fence skip clock_out: device=%d fence=%d(%s) already clocked out today",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
else:
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_out", latitude, longitude,
|
||||
address, recorded_at, fence, device_info,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_out",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_out: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_out",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_out: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
# 4. Update state
|
||||
if state is None:
|
||||
@@ -315,18 +370,27 @@ def _create_attendance(
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
fence: FenceConfig,
|
||||
device_info: Optional[dict] = None,
|
||||
) -> AttendanceRecord:
|
||||
"""Create an auto-generated fence attendance record."""
|
||||
info = device_info or {}
|
||||
return AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=imei,
|
||||
attendance_type=attendance_type,
|
||||
attendance_source="fence",
|
||||
protocol_number=0, # synthetic, not from device protocol
|
||||
gps_positioned=True,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
address=address,
|
||||
recorded_at=recorded_at,
|
||||
battery_level=info.get("battery_level"),
|
||||
gsm_signal=info.get("gsm_signal"),
|
||||
mcc=info.get("mcc"),
|
||||
mnc=info.get("mnc"),
|
||||
lac=info.get("lac"),
|
||||
cell_id=info.get("cell_id"),
|
||||
lbs_data={
|
||||
"source": "fence_auto",
|
||||
"fence_id": fence.id,
|
||||
|
||||
Reference in New Issue
Block a user