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>
This commit is contained in:
2026-03-31 10:11:33 +00:00
parent b25eafc483
commit 8157f9cb52
10 changed files with 1044 additions and 51 deletions

View File

@@ -4,10 +4,10 @@ API endpoints for attendance record queries and statistics.
"""
import math
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, select, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -86,18 +86,22 @@ async def list_attendance(
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取考勤统计 / Get attendance statistics",
summary="获取考勤统计(增强版)/ Get enhanced attendance statistics",
)
async def attendance_stats(
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
db: AsyncSession = Depends(get_db),
):
"""
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
Get attendance statistics: total records, breakdown by type and by device.
增强版考勤统计:总数、按类型/来源/设备分组、按天趋势、今日统计。
Enhanced: total, by type/source/device, daily trend, today count.
"""
from app.config import now_cst
now = now_cst()
base_filter = []
if device_id is not None:
base_filter.append(AttendanceRecord.device_id == device_id)
@@ -106,38 +110,146 @@ async def attendance_stats(
if end_time:
base_filter.append(AttendanceRecord.recorded_at <= end_time)
# Total count
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id))
total_result = await db.execute(total_q)
total = total_result.scalar() or 0
def _where(q):
return q.where(*base_filter) if base_filter else q
# Total
total = (await db.execute(_where(select(func.count(AttendanceRecord.id))))).scalar() or 0
# By type
type_q = select(
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.attendance_type)
if base_filter:
type_q = type_q.where(*base_filter)
type_result = await db.execute(type_q)
type_result = await db.execute(_where(
select(AttendanceRecord.attendance_type, func.count(AttendanceRecord.id))
.group_by(AttendanceRecord.attendance_type)
))
by_type = {row[0]: row[1] for row in type_result.all()}
# By device (top 20)
device_q = select(
AttendanceRecord.device_id, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.device_id).order_by(
func.count(AttendanceRecord.id).desc()
).limit(20)
if base_filter:
device_q = device_q.where(*base_filter)
device_result = await db.execute(device_q)
by_device = {str(row[0]): row[1] for row in device_result.all()}
# By source
source_result = await db.execute(_where(
select(AttendanceRecord.attendance_source, func.count(AttendanceRecord.id))
.group_by(AttendanceRecord.attendance_source)
))
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
return APIResponse(
data={
"total": total,
"by_type": by_type,
"by_device": by_device,
}
# By device (top 20)
device_result = await db.execute(_where(
select(AttendanceRecord.device_id, AttendanceRecord.imei, func.count(AttendanceRecord.id))
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei)
.order_by(func.count(AttendanceRecord.id).desc())
.limit(20)
))
by_device = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in device_result.all()]
# Daily trend (last N days)
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(AttendanceRecord.recorded_at).label("day"),
func.count(AttendanceRecord.id),
)
.where(AttendanceRecord.recorded_at >= cutoff)
.group_by("day").order_by("day")
)
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = (await db.execute(
select(func.count(AttendanceRecord.id)).where(AttendanceRecord.recorded_at >= today_start)
)).scalar() or 0
return APIResponse(data={
"total": total,
"today": today_count,
"by_type": by_type,
"by_source": by_source,
"by_device": by_device,
"daily_trend": daily_trend,
})
@router.get(
"/report",
response_model=APIResponse[dict],
summary="考勤报表 / Attendance report",
)
async def attendance_report(
device_id: int | None = Query(default=None, description="设备ID (可选,不传则所有设备)"),
start_date: str = Query(..., description="开始日期 YYYY-MM-DD"),
end_date: str = Query(..., description="结束日期 YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
):
"""
考勤报表:按设备+日期聚合,返回每个设备每天的签到次数、首次签到时间、末次签到时间。
Attendance report: per device per day aggregation.
"""
from datetime import datetime as dt
try:
s_date = dt.strptime(start_date, "%Y-%m-%d")
e_date = dt.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
except ValueError:
raise HTTPException(status_code=400, detail="日期格式需为 YYYY-MM-DD")
if s_date > e_date:
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
filters = [
AttendanceRecord.recorded_at >= s_date,
AttendanceRecord.recorded_at <= e_date,
]
if device_id is not None:
filters.append(AttendanceRecord.device_id == device_id)
result = await db.execute(
select(
AttendanceRecord.device_id,
AttendanceRecord.imei,
func.date(AttendanceRecord.recorded_at).label("day"),
func.count(AttendanceRecord.id).label("punch_count"),
func.min(AttendanceRecord.recorded_at).label("first_punch"),
func.max(AttendanceRecord.recorded_at).label("last_punch"),
func.group_concat(AttendanceRecord.attendance_source).label("sources"),
)
.where(*filters)
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei, "day")
.order_by(AttendanceRecord.device_id, "day")
)
rows = result.all()
report = []
for row in rows:
report.append({
"device_id": row[0],
"imei": row[1],
"date": str(row[2]),
"punch_count": row[3],
"first_punch": str(row[4]) if row[4] else None,
"last_punch": str(row[5]) if row[5] else None,
"sources": list(set(row[6].split(","))) if row[6] else [],
})
# Summary: total days in range, devices with records, attendance rate
total_days = (e_date - s_date).days + 1
unique_devices = len({r["device_id"] for r in report})
device_days = len(report)
from app.models import Device
if device_id is not None:
total_device_count = 1
else:
total_device_count = (await db.execute(select(func.count(Device.id)))).scalar() or 1
expected_device_days = total_days * total_device_count
attendance_rate = round(device_days / expected_device_days * 100, 1) if expected_device_days else 0
return APIResponse(data={
"start_date": start_date,
"end_date": end_date,
"total_days": total_days,
"total_devices": unique_devices,
"attendance_rate": attendance_rate,
"records": report,
})
@router.get(