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