- 地图轨迹点按定位类型区分颜色 (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>
210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
"""
|
|
Attendance Router - 考勤管理接口
|
|
API endpoints for attendance record queries and statistics.
|
|
"""
|
|
|
|
import math
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models import AttendanceRecord
|
|
from app.schemas import (
|
|
APIResponse,
|
|
AttendanceRecordResponse,
|
|
PaginatedList,
|
|
)
|
|
from app.services import device_service
|
|
|
|
router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
|
summary="获取考勤记录列表 / List attendance records",
|
|
)
|
|
async def list_attendance(
|
|
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
|
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
|
|
attendance_source: str | None = Query(default=None, description="考勤来源 / Source (device/bluetooth/fence)"),
|
|
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
|
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
|
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
|
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取考勤记录列表,支持按设备、考勤类型、来源、时间范围过滤。
|
|
List attendance records with filters for device, type, source, and time range.
|
|
"""
|
|
query = select(AttendanceRecord)
|
|
count_query = select(func.count(AttendanceRecord.id))
|
|
|
|
if device_id is not None:
|
|
query = query.where(AttendanceRecord.device_id == device_id)
|
|
count_query = count_query.where(AttendanceRecord.device_id == device_id)
|
|
|
|
if attendance_type:
|
|
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
|
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
|
|
|
|
if attendance_source:
|
|
query = query.where(AttendanceRecord.attendance_source == attendance_source)
|
|
count_query = count_query.where(AttendanceRecord.attendance_source == attendance_source)
|
|
|
|
if start_time:
|
|
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
|
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
|
|
|
if end_time:
|
|
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
|
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
|
|
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
offset = (page - 1) * page_size
|
|
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
|
result = await db.execute(query)
|
|
records = list(result.scalars().all())
|
|
|
|
return APIResponse(
|
|
data=PaginatedList(
|
|
items=[AttendanceRecordResponse.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="获取考勤统计 / Get 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"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
|
|
Get attendance statistics: total records, breakdown by type and by device.
|
|
"""
|
|
base_filter = []
|
|
if device_id is not None:
|
|
base_filter.append(AttendanceRecord.device_id == device_id)
|
|
if start_time:
|
|
base_filter.append(AttendanceRecord.recorded_at >= start_time)
|
|
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
|
|
|
|
# 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)
|
|
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()}
|
|
|
|
return APIResponse(
|
|
data={
|
|
"total": total,
|
|
"by_type": by_type,
|
|
"by_device": by_device,
|
|
}
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/device/{device_id}",
|
|
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
|
summary="获取设备考勤记录 / Get device attendance records",
|
|
)
|
|
async def device_attendance(
|
|
device_id: int,
|
|
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
|
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
|
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
|
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取指定设备的考勤记录。
|
|
Get attendance records for a specific device.
|
|
"""
|
|
# Verify device exists
|
|
device = await device_service.get_device(db, device_id)
|
|
if device is None:
|
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
|
|
|
query = select(AttendanceRecord).where(AttendanceRecord.device_id == device_id)
|
|
count_query = select(func.count(AttendanceRecord.id)).where(AttendanceRecord.device_id == device_id)
|
|
|
|
if start_time:
|
|
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
|
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
|
|
|
if end_time:
|
|
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
|
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
|
|
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
offset = (page - 1) * page_size
|
|
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
|
result = await db.execute(query)
|
|
records = list(result.scalars().all())
|
|
|
|
return APIResponse(
|
|
data=PaginatedList(
|
|
items=[AttendanceRecordResponse.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,
|
|
)
|
|
)
|
|
|
|
|
|
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
|
@router.get(
|
|
"/{attendance_id}",
|
|
response_model=APIResponse[AttendanceRecordResponse],
|
|
summary="获取考勤记录详情 / Get attendance record",
|
|
)
|
|
async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""按ID获取考勤记录详情 / Get attendance record details by ID."""
|
|
result = await db.execute(
|
|
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
|
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|