feat(reports): stabilize on-demand generation, live presence and 1C fallback
This commit is contained in:
@@ -1,53 +1,400 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/snapshots.py
|
||||
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
|
||||
ROLE: Роутер срезов СКУД:
|
||||
- Список срезов за период дат (/api/v1/snapshots)
|
||||
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
|
||||
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
|
||||
- Ручное создание среза (/create)
|
||||
- Удаление срезов (пакетное через snapshot_ids)
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
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 routers.auth import get_current_user
|
||||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||||
from services.scud_export import run_export
|
||||
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]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
|
||||
return get_snapshots_registry(date_str=date_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 api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
|
||||
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
|
||||
def create_snapshot(req: CreateSnapshotRequest):
|
||||
from services.scud_export import run_export
|
||||
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
|
||||
try:
|
||||
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
|
||||
|
||||
fresh_data = get_snapshots_registry(date_str=req.date_str)
|
||||
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
|
||||
run_export(input_date=target_date, save_xlsx=True, debug=False)
|
||||
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
|
||||
logger.error(f"Ошибка создания среза: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
|
||||
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
|
||||
if not safe_ids:
|
||||
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
|
||||
@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)
|
||||
|
||||
res = delete_snapshots_safely(snapshot_ids=safe_ids)
|
||||
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user