Files
desungongpai/app/routers/fences.py
default 8157f9cb52 feat: 13个统计/聚合API + 前端同步 + 待完成功能文档
API新增:
- GET /api/system/overview 系统总览(在线率/今日统计/表大小)
- GET /api/locations/stats 位置统计(类型分布/小时趋势)
- GET /api/locations/track-summary/{id} 轨迹摘要(距离/时长/速度)
- POST /api/alarms/batch-acknowledge 批量确认告警
- GET /api/attendance/report 考勤日报表(每设备每天汇总)
- GET /api/bluetooth/stats 蓝牙统计(类型/TOP信标/RSSI分布)
- GET /api/heartbeats/stats 心跳统计(活跃设备/电量/间隔分析)
- GET /api/fences/stats 围栏统计(绑定/进出状态/今日事件)
- GET /api/fences/{id}/events 围栏进出事件历史
- GET /api/commands/stats 指令统计(成功率/类型/趋势)

API增强:
- devices/stats: 新增by_type/battery_distribution/signal_distribution
- alarms/stats: 新增today/by_source/daily_trend/top_devices
- attendance/stats: 新增today/by_source/daily_trend/by_device

前端同步:
- 仪表盘: 今日告警/考勤/定位卡片 + 在线率
- 告警页: 批量确认按钮 + 今日计数
- 考勤页: 今日计数
- 轨迹: 加载后显示距离/时长/速度摘要
- 蓝牙/围栏/指令页: 统计面板

文档: CLAUDE.md待完成功能按优先级重新规划

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 10:11:33 +00:00

257 lines
9.4 KiB
Python

"""Fences Router - geofence management API endpoints."""
import math
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.models import AttendanceRecord, DeviceFenceBinding, DeviceFenceState, FenceConfig
from app.schemas import (
APIResponse,
DeviceFenceBindRequest,
FenceConfigCreate,
FenceConfigResponse,
FenceConfigUpdate,
FenceDeviceDetail,
PaginatedList,
)
from app.services import fence_service
router = APIRouter(prefix="/api/fences", tags=["Fences"])
@router.get("", response_model=APIResponse[PaginatedList[FenceConfigResponse]])
async def list_fences(
is_active: bool | None = Query(default=None),
search: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
records, total = await fence_service.get_fences(db, page, page_size, is_active, search)
return APIResponse(
data=PaginatedList(
items=[FenceConfigResponse.model_validate(r) for r in records],
total=total, page=page, page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="围栏统计 / Fence statistics",
)
async def fence_stats(db: AsyncSession = Depends(get_db)):
"""
围栏统计:总数、活跃数、绑定设备总数、各围栏当前在内设备数、今日进出事件数。
Fence stats: totals, bindings, devices currently inside, today's events.
"""
from app.config import now_cst
now = now_cst()
total = (await db.execute(select(func.count(FenceConfig.id)))).scalar() or 0
active = (await db.execute(
select(func.count(FenceConfig.id)).where(FenceConfig.is_active == True) # noqa: E712
)).scalar() or 0
total_bindings = (await db.execute(select(func.count(DeviceFenceBinding.id)))).scalar() or 0
# Devices currently inside fences
inside_result = await db.execute(
select(
DeviceFenceState.fence_id,
func.count(DeviceFenceState.id),
)
.where(DeviceFenceState.is_inside == True) # noqa: E712
.group_by(DeviceFenceState.fence_id)
)
devices_inside = {row[0]: row[1] for row in inside_result.all()}
total_inside = sum(devices_inside.values())
# Today's fence attendance events
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_events = (await db.execute(
select(func.count(AttendanceRecord.id))
.where(
AttendanceRecord.attendance_source == "fence",
AttendanceRecord.recorded_at >= today_start,
)
)).scalar() or 0
# Per-fence summary
fence_result = await db.execute(
select(FenceConfig.id, FenceConfig.name, FenceConfig.fence_type, FenceConfig.is_active)
)
fence_summary = []
for row in fence_result.all():
fid = row[0]
fence_summary.append({
"fence_id": fid,
"name": row[1],
"type": row[2],
"is_active": bool(row[3]),
"devices_inside": devices_inside.get(fid, 0),
})
return APIResponse(data={
"total": total,
"active": active,
"inactive": total - active,
"total_bindings": total_bindings,
"total_devices_inside": total_inside,
"today_events": today_events,
"fences": fence_summary,
})
@router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]])
async def get_all_active(db: AsyncSession = Depends(get_db)):
fences = await fence_service.get_all_active_fences(db)
return APIResponse(data=[FenceConfigResponse.model_validate(f) for f in fences])
@router.get("/{fence_id}", response_model=APIResponse[FenceConfigResponse])
async def get_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
fence = await fence_service.get_fence(db, fence_id)
if fence is None:
raise HTTPException(status_code=404, detail="Fence not found")
return APIResponse(data=FenceConfigResponse.model_validate(fence))
@router.post("", response_model=APIResponse[FenceConfigResponse], status_code=201, dependencies=[Depends(require_write)])
async def create_fence(body: FenceConfigCreate, db: AsyncSession = Depends(get_db)):
fence = await fence_service.create_fence(db, body)
return APIResponse(message="Fence created", data=FenceConfigResponse.model_validate(fence))
@router.put("/{fence_id}", response_model=APIResponse[FenceConfigResponse], dependencies=[Depends(require_write)])
async def update_fence(fence_id: int, body: FenceConfigUpdate, db: AsyncSession = Depends(get_db)):
fence = await fence_service.update_fence(db, fence_id, body)
if fence is None:
raise HTTPException(status_code=404, detail="Fence not found")
return APIResponse(message="Fence updated", data=FenceConfigResponse.model_validate(fence))
@router.delete("/{fence_id}", response_model=APIResponse, dependencies=[Depends(require_write)])
async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
if not await fence_service.delete_fence(db, fence_id):
raise HTTPException(status_code=404, detail="Fence not found")
return APIResponse(message="Fence deleted")
@router.get(
"/{fence_id}/events",
response_model=APIResponse[PaginatedList[dict]],
summary="围栏进出事件历史 / Fence events history",
)
async def fence_events(
fence_id: int,
start_time: datetime | None = Query(default=None, description="开始时间"),
end_time: datetime | None = Query(default=None, description="结束时间"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""
获取围栏的进出事件历史(来源为 fence 的考勤记录)。
Get fence entry/exit events (attendance records with source=fence).
"""
fence = await fence_service.get_fence(db, fence_id)
if fence is None:
raise HTTPException(status_code=404, detail="Fence not found")
from sqlalchemy.dialects.sqlite import JSON as _JSON
filters = [
AttendanceRecord.attendance_source == "fence",
]
if start_time:
filters.append(AttendanceRecord.recorded_at >= start_time)
if end_time:
filters.append(AttendanceRecord.recorded_at <= end_time)
# Filter by fence_id in lbs_data JSON (fence_auto records store fence_id there)
# Since SQLite JSON support is limited, we use LIKE for the fence_id match
filters.append(AttendanceRecord.lbs_data.like(f'%"fence_id": {fence_id}%'))
count_q = select(func.count(AttendanceRecord.id)).where(*filters)
total = (await db.execute(count_q)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(AttendanceRecord)
.where(*filters)
.order_by(AttendanceRecord.recorded_at.desc())
.offset(offset).limit(page_size)
)
records = result.scalars().all()
items = []
for r in records:
items.append({
"id": r.id,
"device_id": r.device_id,
"imei": r.imei,
"event_type": r.attendance_type,
"latitude": r.latitude,
"longitude": r.longitude,
"address": r.address,
"recorded_at": str(r.recorded_at),
})
return APIResponse(data=PaginatedList(
items=items,
total=total, page=page, page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
))
# ---------------------------------------------------------------------------
# Device-Fence Binding endpoints
# ---------------------------------------------------------------------------
@router.get(
"/{fence_id}/devices",
response_model=APIResponse[list[FenceDeviceDetail]],
summary="获取围栏绑定的设备列表",
)
async def get_fence_devices(fence_id: int, db: AsyncSession = Depends(get_db)):
fence = await fence_service.get_fence(db, fence_id)
if fence is None:
raise HTTPException(status_code=404, detail="Fence not found")
items = await fence_service.get_fence_devices(db, fence_id)
return APIResponse(data=[FenceDeviceDetail(**item) for item in items])
@router.post(
"/{fence_id}/devices",
response_model=APIResponse,
dependencies=[Depends(require_write)],
summary="绑定设备到围栏",
)
async def bind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
result = await fence_service.bind_devices_to_fence(db, fence_id, body.device_ids)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return APIResponse(
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
data=result,
)
@router.delete(
"/{fence_id}/devices",
response_model=APIResponse,
dependencies=[Depends(require_write)],
summary="解绑设备与围栏",
)
async def unbind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
count = await fence_service.unbind_devices_from_fence(db, fence_id, body.device_ids)
return APIResponse(message=f"已解绑 {count} 个设备")