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:
2026-03-30 04:26:29 +00:00
parent 1d06cc5415
commit 891344bfa0
8 changed files with 598 additions and 100 deletions

View File

@@ -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,