400 lines
16 KiB
Python
400 lines
16 KiB
Python
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/snapshots.py
|
||
ROLE: Роутер срезов СКУД:
|
||
- Список срезов за период дат (/api/v1/snapshots)
|
||
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
|
||
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
|
||
- Ручное создание среза (/create)
|
||
- Удаление срезов (пакетное через snapshot_ids)
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import io
|
||
import urllib.parse
|
||
import logging
|
||
from typing import Optional, List, Dict, Any
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from fastapi.responses import StreamingResponse, FileResponse
|
||
from pydantic import BaseModel
|
||
import pandas as pd
|
||
|
||
from core.connection import get_connection
|
||
import config
|
||
|
||
logger = logging.getLogger("SNAPSHOTS_ROUTER")
|
||
|
||
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
|
||
|
||
BASE_DIR = getattr(config, "BASE_DIR", Path(__file__).resolve().parent.parent.parent.parent)
|
||
SNAPSHOTS_DIR = getattr(config, "SNAPSHOTS_DIR", os.path.join(str(BASE_DIR), "exports", "snapshots"))
|
||
DATE_TODAY = getattr(config, "DATE_TODAY", datetime.now().strftime("%d.%m.%Y"))
|
||
|
||
|
||
class CreateSnapshotRequest(BaseModel):
|
||
date_str: Optional[str] = None
|
||
time_str: Optional[str] = None
|
||
|
||
|
||
class DeleteSnapshotsRequest(BaseModel):
|
||
snapshot_ids: List[str]
|
||
|
||
|
||
# =============================================================================
|
||
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ РАСЧЕТА СТАТУСА И ВРЕМЕНИ
|
||
# =============================================================================
|
||
|
||
def parse_time_str(t_str: str) -> Optional[datetime]:
|
||
"""Парсит строку времени HH:MM[:SS] в datetime объект."""
|
||
if not t_str or str(t_str).strip() in ("Нет входа", "Нет выхода", "—", "-", "None", "nan"):
|
||
return None
|
||
for fmt in ("%H:%M:%S", "%H:%M"):
|
||
try:
|
||
return datetime.strptime(str(t_str).strip(), fmt)
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def calculate_row_presence(d: dict, snapshot_time_str: str) -> dict:
|
||
"""
|
||
Определяет реальный статус сотрудника и время нахождения в здании.
|
||
"""
|
||
fio = d.get("fio") or d.get("Сотрудник") or "—"
|
||
dept = d.get("department") or d.get("Подразделение") or "—"
|
||
|
||
t_in = d.get("time_in") or d.get("Начало_дня") or d.get("first_in") or "Нет входа"
|
||
first_act = d.get("first_activity") or d.get("Первая_активность") or "—"
|
||
t_out = d.get("time_out") or d.get("Конец_дня") or d.get("last_out") or "Нет выхода"
|
||
|
||
db_in_bld = d.get("in_building") or d.get("Находился_в_здании")
|
||
db_status = d.get("status") or d.get("Статус") or d.get("Пришел")
|
||
|
||
has_in = t_in not in ("Нет входа", "—", "-", "", None, "None")
|
||
has_act = first_act not in ("—", "-", "", None, "None")
|
||
has_out = t_out not in ("Нет выхода", "—", "-", "", None, "None")
|
||
|
||
# 1. Если нет отметок прохода — сотрудник отсутствовал
|
||
if not has_in and not has_act and not has_out:
|
||
final_status = "Отсутствовал (Нет событий)"
|
||
final_in_bld = "00:00"
|
||
else:
|
||
# Сотрудник присутствовал
|
||
final_status = "Присутствовал"
|
||
|
||
# Расчет времени нахождения в здании
|
||
dt_in = parse_time_str(t_in) or parse_time_str(first_act)
|
||
dt_out = parse_time_str(t_out)
|
||
|
||
if db_in_bld and db_in_bld not in ("00:00", "—", "", "None"):
|
||
final_in_bld = db_in_bld
|
||
elif dt_in:
|
||
if dt_out and dt_out >= dt_in:
|
||
diff = dt_out - dt_in
|
||
else:
|
||
# Если выхода еще нет — считаем до момента фиксации среза
|
||
dt_snap = parse_time_str(snapshot_time_str) or datetime.now()
|
||
if dt_snap >= dt_in:
|
||
diff = dt_snap - dt_in
|
||
else:
|
||
diff = timedelta(0)
|
||
|
||
total_minutes = int(diff.total_seconds() // 60)
|
||
hh = total_minutes // 60
|
||
mm = total_minutes % 60
|
||
final_in_bld = f"{hh:02d}:{mm:02d}"
|
||
else:
|
||
final_in_bld = "00:00"
|
||
|
||
# Сохраняем специальные статусы, если они зафиксированы в БД
|
||
if db_status and "Отсутств" in str(db_status):
|
||
final_status = db_status
|
||
|
||
return {
|
||
"fio": fio,
|
||
"department": dept,
|
||
"time_in": t_in,
|
||
"first_activity": first_act,
|
||
"time_out": t_out,
|
||
"in_building": final_in_bld,
|
||
"status": final_status
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 1. СПИСОК СРЕЗОВ (С ПОДДЕРЖКОЙ ДИАПАЗОНА ДАТ)
|
||
# =============================================================================
|
||
|
||
@router.get("", include_in_schema=False)
|
||
@router.get("/")
|
||
def list_snapshots(
|
||
date: Optional[str] = None,
|
||
date_from: Optional[str] = None,
|
||
date_to: Optional[str] = None
|
||
):
|
||
"""
|
||
Возвращает список срезов за диапазон дат со всеми полями для интерфейса.
|
||
"""
|
||
d_from = (date_from or date or DATE_TODAY).replace('_', '.')
|
||
d_to = (date_to or date or DATE_TODAY).replace('_', '.')
|
||
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT snapshot_id, COUNT(*) as cnt, MAX(created_at) as created_at, log_date
|
||
FROM scud_logs
|
||
WHERE (
|
||
substr(log_date, 7, 4) || '-' || substr(log_date, 4, 2) || '-' || substr(log_date, 1, 2)
|
||
BETWEEN
|
||
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
|
||
AND
|
||
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
|
||
)
|
||
AND snapshot_id IS NOT NULL AND snapshot_id != ''
|
||
GROUP BY snapshot_id
|
||
ORDER BY snapshot_id DESC
|
||
""", (d_from, d_from, d_from, d_to, d_to, d_to))
|
||
rows = cursor.fetchall()
|
||
|
||
items = []
|
||
for r in rows:
|
||
raw_id = r["snapshot_id"]
|
||
clean_id = str(raw_id).lstrip("#").strip()
|
||
total_cnt = r["cnt"]
|
||
|
||
time_str = "—"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
time_str = f"{parts[1][:2]}:{parts[1][2:4]}"
|
||
|
||
is_final = "FINAL" in clean_id.upper()
|
||
|
||
items.append({
|
||
"id": clean_id,
|
||
"snapshot_id": clean_id,
|
||
"label": clean_id,
|
||
"snapshot_time": time_str,
|
||
"time": time_str,
|
||
"record_count": total_cnt,
|
||
"count": total_cnt,
|
||
"records_count": total_cnt,
|
||
"is_final": is_final,
|
||
"date": r["log_date"],
|
||
"created_at": r["created_at"] or r["log_date"]
|
||
})
|
||
|
||
return {
|
||
"date_from": d_from,
|
||
"date_to": d_to,
|
||
"total_snapshots": len(items),
|
||
"snapshots": items
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 2. ИНСПЕКЦИЯ СРЕЗА (ДЛЯ МОДАЛЬНОГО ОКНА)
|
||
# Поддерживает оба пути: /details и /inspect
|
||
# =============================================================================
|
||
|
||
@router.get("/{snapshot_id}/details")
|
||
@router.get("/{snapshot_id}/inspect")
|
||
def inspect_snapshot(snapshot_id: str):
|
||
clean_id = snapshot_id.lstrip("#").strip()
|
||
|
||
target_date = ""
|
||
snapshot_time = "23:59:59"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts[0]) == 8 and parts[0].isdigit():
|
||
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
|
||
|
||
rows = []
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# Безопасная выборка всех полей строки среза
|
||
cursor.execute("""
|
||
SELECT * FROM scud_logs
|
||
WHERE snapshot_id IN (?, ?, ?, ?)
|
||
ORDER BY id ASC
|
||
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
|
||
rows = cursor.fetchall()
|
||
|
||
# Резервный сбор на лету из scud_events_raw, если среза нет в scud_logs
|
||
if not rows and target_date:
|
||
cursor.execute("""
|
||
SELECT
|
||
fio,
|
||
department,
|
||
MIN(CASE WHEN direction = 'IN' THEN time_val END) as time_in,
|
||
NULL as first_activity,
|
||
MAX(CASE WHEN direction = 'OUT' THEN time_val END) as time_out,
|
||
'00:00' as in_building,
|
||
'Присутствовал' as status
|
||
FROM scud_events_raw
|
||
WHERE log_date = ?
|
||
GROUP BY fio_clean
|
||
ORDER BY fio ASC
|
||
""", (target_date,))
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
|
||
|
||
records = []
|
||
for r in rows:
|
||
calc = calculate_row_presence(dict(r), snapshot_time)
|
||
records.append({
|
||
"hoz_organ": dict(r).get("hoz_organ") or dict(r).get("tab_num") or "",
|
||
"fio": calc["fio"],
|
||
"Сотрудник": calc["fio"],
|
||
"department": calc["department"],
|
||
"Подразделение": calc["department"],
|
||
"time_in": calc["time_in"],
|
||
"Вход": calc["time_in"],
|
||
"first_activity": calc["first_activity"],
|
||
"time_out": calc["time_out"],
|
||
"Выход": calc["time_out"],
|
||
"in_building": calc["in_building"],
|
||
"Находился_в_здании": calc["in_building"],
|
||
"status": calc["status"],
|
||
"Пришел": calc["status"]
|
||
})
|
||
|
||
return {
|
||
"status": "ok",
|
||
"snapshot_id": clean_id,
|
||
"date": target_date or DATE_TODAY,
|
||
"total_records": len(records),
|
||
"count": len(records),
|
||
"records": records,
|
||
"data": records
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 3. ЭКСПОРТ ДАННЫХ ИНСПЕКЦИИ СРЕЗА (EXCEL / CSV)
|
||
# =============================================================================
|
||
|
||
@router.get("/{snapshot_id}/export")
|
||
def api_export_snapshot(snapshot_id: str, format: str = Query("xlsx")):
|
||
clean_id = snapshot_id.lstrip("#").strip()
|
||
|
||
target_date = ""
|
||
snapshot_time = "23:59:59"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts[0]) == 8 and parts[0].isdigit():
|
||
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
|
||
|
||
rows = []
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT * FROM scud_logs
|
||
WHERE snapshot_id IN (?, ?, ?, ?)
|
||
ORDER BY id ASC
|
||
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
|
||
|
||
export_list = []
|
||
for r in rows:
|
||
calc = calculate_row_presence(dict(r), snapshot_time)
|
||
export_list.append({
|
||
"Сотрудник": calc["fio"],
|
||
"Подразделение": calc["department"],
|
||
"Вход": calc["time_in"],
|
||
"Первая активность": calc["first_activity"],
|
||
"Выход": calc["time_out"],
|
||
"В здании": calc["in_building"],
|
||
"Статус": calc["status"]
|
||
})
|
||
|
||
out_df = pd.DataFrame(export_list)
|
||
filename_base = f"Инспекция_{clean_id}"
|
||
|
||
if format.lower() == "csv":
|
||
csv_bytes = out_df.to_csv(index=False, sep=";", encoding="utf-8-sig").encode("utf-8-sig")
|
||
filename = f"{filename_base}.csv"
|
||
encoded = urllib.parse.quote(filename)
|
||
return StreamingResponse(
|
||
io.BytesIO(csv_bytes),
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
|
||
)
|
||
else:
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
||
out_df.to_excel(writer, index=False, sheet_name="Срез")
|
||
output.seek(0)
|
||
filename = f"{filename_base}.xlsx"
|
||
encoded = urllib.parse.quote(filename)
|
||
return StreamingResponse(
|
||
output,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 4. РУЧНОЕ СОЗДАНИЕ И ПАКЕТНОЕ УДАЛЕНИЕ СРЕЗОВ
|
||
# =============================================================================
|
||
|
||
@router.post("/create")
|
||
def create_snapshot(req: CreateSnapshotRequest):
|
||
from services.scud_export import run_export
|
||
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
|
||
try:
|
||
run_export(input_date=target_date, save_xlsx=True, debug=False)
|
||
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания среза: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
|
||
|
||
|
||
@router.delete("", include_in_schema=False)
|
||
@router.delete("/")
|
||
def delete_snapshots(req: DeleteSnapshotsRequest):
|
||
if not req.snapshot_ids:
|
||
return {"status": "ok", "deleted": 0}
|
||
|
||
with get_connection() as conn:
|
||
cursor = conn.cursor()
|
||
for sid in req.snapshot_ids:
|
||
clean_id = sid.lstrip("#").strip()
|
||
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id IN (?, ?)", (clean_id, f"#{clean_id}"))
|
||
conn.commit()
|
||
|
||
return {"status": "ok", "deleted": len(req.snapshot_ids)}
|
||
|
||
|
||
# =============================================================================
|
||
# 5. СКАЧИВАНИЕ ФИЗИЧЕСКИХ ФАЙЛОВ .XLSX (FALLBACK)
|
||
# =============================================================================
|
||
|
||
@router.get("/{filename}")
|
||
def download_snapshot_file(filename: str):
|
||
safe_filename = os.path.basename(filename)
|
||
file_path = os.path.join(SNAPSHOTS_DIR, safe_filename)
|
||
|
||
if not os.path.exists(file_path):
|
||
raise HTTPException(status_code=404, detail="Файл среза не найден на диске")
|
||
|
||
return FileResponse(
|
||
path=file_path,
|
||
filename=safe_filename,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
) |