""" =============================================================================== 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" )