""" 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, ) ) @router.post( "/batch-delete", response_model=APIResponse[dict], summary="批量删除考勤记录 / Batch delete attendance records", ) async def batch_delete_attendance( body: dict, db: AsyncSession = Depends(get_db), ): """ 批量删除考勤记录,通过 body 传递 attendance_ids 列表,最多 500 条。 Batch delete attendance records by IDs (max 500). """ attendance_ids = body.get("attendance_ids", []) if not attendance_ids: raise HTTPException(status_code=400, detail="attendance_ids is required") if len(attendance_ids) > 500: raise HTTPException(status_code=400, detail="Max 500 records per request") result = await db.execute( select(AttendanceRecord).where(AttendanceRecord.id.in_(attendance_ids)) ) records = list(result.scalars().all()) for r in records: await db.delete(r) await db.flush() return APIResponse(data={"deleted": len(records)}) # 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)) @router.delete( "/{attendance_id}", response_model=APIResponse[dict], summary="删除单条考勤记录 / Delete attendance record", ) async def delete_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)): """按ID删除单条考勤记录 / Delete a single attendance record 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") await db.delete(record) await db.flush() return APIResponse(data={"deleted": 1})