feat: 信标设备绑定 + 蓝牙模式管理 + 系统管理增强 + 数据导出
- 新增 DeviceBeaconBinding 模型,信标-设备多对多绑定 CRUD - 蓝牙打卡模式批量配置/恢复正常模式 API - 反向同步: 查询设备 BTMACSET 配置更新数据库绑定 (独立 session 解决事务隔离) - 设备列表快捷操作弹窗修复 (fire-and-forget IIFE 替代阻塞轮询) - 保存按钮防抖: 围栏/信标绑定保存点击后 disabled + 转圈防重复提交 - 审计日志中间件 + 系统配置/备份/固件 API - 设备分组管理 + 告警规则配置 - 5个数据导出 API (CSV UTF-8 BOM) - 位置热力图 + 告警条件删除 + 位置清理 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -7,11 +7,13 @@ import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AttendanceRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AttendanceRecordResponse,
|
||||
@@ -83,6 +85,46 @@ async def list_attendance(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出考勤记录 CSV / Export attendance records CSV",
|
||||
)
|
||||
async def export_attendance(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
attendance_type: str | None = Query(default=None, description="考勤类型 (clock_in/clock_out)"),
|
||||
attendance_source: str | None = Query(default=None, description="来源 (device/bluetooth/fence)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出考勤记录为 CSV,支持设备/类型/来源/时间筛选。最多导出 50000 条。"""
|
||||
query = select(AttendanceRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(AttendanceRecord.device_id == device_id)
|
||||
if attendance_type:
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
if attendance_source:
|
||||
query = query.where(AttendanceRecord.attendance_source == attendance_source)
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
query = query.order_by(AttendanceRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "考勤类型", "来源", "纬度", "经度", "电量", "信号", "地址", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "attendance_type", "attendance_source", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
@@ -252,6 +294,71 @@ async def attendance_report(
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/report/export",
|
||||
summary="导出考勤报表 CSV / Export attendance report CSV",
|
||||
)
|
||||
async def export_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),
|
||||
):
|
||||
"""导出考勤日报表 CSV(每设备每天汇总)。"""
|
||||
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()
|
||||
|
||||
headers = ["设备ID", "IMEI", "日期", "打卡次数", "首次打卡", "末次打卡", "来源"]
|
||||
extractors = [
|
||||
lambda r: r[0],
|
||||
lambda r: r[1],
|
||||
lambda r: str(r[2]),
|
||||
lambda r: r[3],
|
||||
lambda r: r[4],
|
||||
lambda r: r[5],
|
||||
lambda r: ",".join(set(r[6].split(","))) if r[6] else "",
|
||||
]
|
||||
|
||||
content = build_csv_content(headers, rows, extractors)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance_report')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
|
||||
Reference in New Issue
Block a user