feat(reports): stabilize on-demand generation, live presence and 1C fallback
This commit is contained in:
@@ -12,11 +12,13 @@ class ExceptionItem(BaseModel):
|
||||
comment: Optional[str] = ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("/")
|
||||
def api_get_exceptions():
|
||||
return get_all_exceptions_from_db()
|
||||
|
||||
|
||||
@router.post("")
|
||||
@router.post("/")
|
||||
def api_add_exception(item: ExceptionItem):
|
||||
if not add_exception_to_db(item.category, item.value, item.comment):
|
||||
@@ -24,6 +26,7 @@ def api_add_exception(item: ExceptionItem):
|
||||
return {"status": "success", "data": item}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
@router.delete("/")
|
||||
def api_delete_exception(category: str, value: str):
|
||||
if not remove_exception_from_db(category, value):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/files.py
|
||||
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
|
||||
через изолированные UUID-директории инструментов.
|
||||
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -10,6 +9,7 @@ import os
|
||||
import time
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
@@ -17,13 +17,17 @@ router = APIRouter(prefix="/api/v1/files", tags=["Files"])
|
||||
|
||||
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
||||
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
|
||||
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
|
||||
REPORTS_DIR = os.path.join(BASE_ROOT, "output", "reports")
|
||||
TEMP_REPORTS_DIR = "/tmp/scud_reports"
|
||||
|
||||
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
|
||||
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||
os.makedirs(TEMP_REPORTS_DIR, exist_ok=True)
|
||||
|
||||
SESSION_TTL_HOURS = 24
|
||||
|
||||
|
||||
def purge_old_tool_sessions(tool_dir_path: str):
|
||||
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
|
||||
if not os.path.exists(tool_dir_path):
|
||||
return
|
||||
now = time.time()
|
||||
@@ -40,9 +44,6 @@ def purge_old_tool_sessions(tool_dir_path: str):
|
||||
|
||||
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
|
||||
async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||||
"""
|
||||
Безопасная отдача файла с каноническим именем из изолированной директории.
|
||||
"""
|
||||
safe_tool = os.path.basename(tool_name)
|
||||
safe_uuid = os.path.basename(session_uuid)
|
||||
safe_filename = os.path.basename(filename)
|
||||
@@ -52,16 +53,14 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
|
||||
|
||||
# Определение MIME-типа
|
||||
media_type = "application/octet-stream"
|
||||
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
|
||||
if safe_filename.endswith((".md", ".txt")):
|
||||
media_type = "text/markdown; charset=utf-8"
|
||||
elif safe_filename.endswith(".xlsx"):
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
elif safe_filename.endswith(".pdf"):
|
||||
media_type = "application/pdf"
|
||||
|
||||
# Корректная кодировка для кириллических имен файлов
|
||||
encoded_filename = urllib.parse.quote(safe_filename)
|
||||
|
||||
return FileResponse(
|
||||
@@ -70,4 +69,47 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _find_report_recursively(target_filename: str) -> Optional[str]:
|
||||
"""Рекурсивный поиск файла отчета по имени в /tmp и во всех подпапках output/reports/."""
|
||||
# 1. Проверяем /tmp/scud_reports
|
||||
tmp_path = os.path.join(TEMP_REPORTS_DIR, target_filename)
|
||||
if os.path.exists(tmp_path) and os.path.isfile(tmp_path):
|
||||
return tmp_path
|
||||
|
||||
# 2. Проверяем прямой путь в output/reports
|
||||
direct_path = os.path.join(REPORTS_DIR, target_filename)
|
||||
if os.path.exists(direct_path) and os.path.isfile(direct_path):
|
||||
return direct_path
|
||||
|
||||
# 3. Рекурсивный поиск по подкаталогам (год/месяц)
|
||||
for root, _, files in os.walk(REPORTS_DIR):
|
||||
if target_filename in files:
|
||||
return os.path.join(root, target_filename)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/download/reports/{filename:path}")
|
||||
async def download_report_direct(filename: str):
|
||||
"""
|
||||
Прямое скачивание отчетов:
|
||||
- Декодирует UTF-8 URL (%20 -> пробел).
|
||||
- Ищет файл в output/reports/{YEAR}/{MONTH} и во временном буфере /tmp/scud_reports.
|
||||
"""
|
||||
decoded_name = urllib.parse.unquote(filename).strip()
|
||||
safe_filename = os.path.basename(decoded_name)
|
||||
|
||||
target_path = _find_report_recursively(safe_filename)
|
||||
|
||||
if not target_path or not os.path.exists(target_path):
|
||||
raise HTTPException(status_code=404, detail="Отчет не найден")
|
||||
|
||||
encoded_filename = urllib.parse.quote(safe_filename)
|
||||
return FileResponse(
|
||||
path=target_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
|
||||
)
|
||||
@@ -68,4 +68,21 @@ def api_add_manual_absence(req: AddAbsenceRequest):
|
||||
def api_delete_manual_absence(item_id: int):
|
||||
if not delete_manual_absence(item_id):
|
||||
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||||
return {"status": "success"}
|
||||
|
||||
class UpdateAbsenceDatesRequest(BaseModel):
|
||||
id: int
|
||||
date_start: Optional[str] = None
|
||||
date_end: Optional[str] = None
|
||||
|
||||
@router.put("/{item_id}")
|
||||
def api_update_manual_absence(item_id: int, req: UpdateAbsenceDatesRequest):
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE manual_absences
|
||||
SET date_start = ?, date_end = ?
|
||||
WHERE id = ?
|
||||
""", (req.date_start, req.date_end, item_id))
|
||||
conn.commit()
|
||||
return {"status": "success"}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/presence.py
|
||||
ROLE: REST API оперативного статуса присутствия сотрудников в здании.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from services.presence_service import get_live_presence
|
||||
|
||||
router = APIRouter(prefix="/api/v1/presence", tags=["Presence"])
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
def api_get_live_presence(
|
||||
date_str: Optional[str] = Query(None, description="Дата в формате ДД.ММ.ГГГГ"),
|
||||
force_refresh: bool = Query(False, description="Принудительный опрос MS SQL Орион")
|
||||
):
|
||||
"""
|
||||
Возвращает оперативный статус сотрудников («Кто в здании»).
|
||||
По умолчанию возвращает срез моментально из локальной базы SQLite.
|
||||
При force_refresh=true выполняет опрос турникетов в MS SQL Орион.
|
||||
"""
|
||||
return get_live_presence(date_str=date_str, force_refresh=force_refresh)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/reports.py
|
||||
ROLE: REST API On-Demand генерации отчетов:
|
||||
- Сводка: за СЕГОДНЯ (оперативный контроль, текущий срез).
|
||||
- Детальный и Упрощенный отчет: строго за ВЧЕРА по финальному срезу Y.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from config import DATE_TODAY, DATE_YESTERDAY
|
||||
from services.scud_etl.svodka_generator import generate_svodka_service
|
||||
from services.scud_etl.otchet_generator import generate_otchet_service
|
||||
from services.reports.simplified_builder import generate_simplified_excel
|
||||
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
|
||||
from services.scud_etl.merger import merge_scud_and_1c
|
||||
|
||||
logger = logging.getLogger("REPORTS_API")
|
||||
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
|
||||
|
||||
|
||||
def get_previous_workday(target_date_str: str) -> str:
|
||||
"""Вычисляет дату предыдущей рабочей смены (в понедельник возвращает пятницу)."""
|
||||
clean_date = target_date_str.replace('_', '.')
|
||||
try:
|
||||
dt = datetime.strptime(clean_date, "%d.%m.%Y")
|
||||
days_back = 3 if dt.weekday() == 0 else 1
|
||||
return (dt - timedelta(days=days_back)).strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
return DATE_YESTERDAY
|
||||
|
||||
|
||||
class GenerateReportRequest(BaseModel):
|
||||
date: Optional[str] = None # ДД.ММ.ГГГГ
|
||||
time: Optional[str] = None # ЧЧ:ММ
|
||||
report_type: str # 'SVODKA', 'DETAILED', 'SIMPLIFIED', 'ALL'
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def api_generate_report(req: GenerateReportRequest):
|
||||
"""
|
||||
Генерирует выбранный отчет:
|
||||
- SVODKA: на текущую дату (date).
|
||||
- DETAILED / SIMPLIFIED: строго за вчерашний рабочий день по финальному срезу Y.
|
||||
"""
|
||||
target_date = (req.date or DATE_TODAY).replace('_', '.')
|
||||
yesterday_date = get_previous_workday(target_date)
|
||||
r_type = req.report_type.upper()
|
||||
results = []
|
||||
|
||||
# 1. Ежедневная сводка (за СЕГОДНЯ)
|
||||
if r_type in ["SVODKA", "ALL"]:
|
||||
res_svodka = generate_svodka_service(
|
||||
target_date=target_date,
|
||||
target_time=req.time
|
||||
)
|
||||
if res_svodka.get("status") == "success":
|
||||
results.append({
|
||||
"type": "Сводка",
|
||||
"filename": res_svodka.get("filename"),
|
||||
"download_url": res_svodka.get("download_url"),
|
||||
"status": "success"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"type": "Сводка",
|
||||
"error": res_svodka.get("message"),
|
||||
"status": "error"
|
||||
})
|
||||
|
||||
# 2. Детальный отчет (строго за ВЧЕРА по финальному срезу Y)
|
||||
if r_type in ["DETAILED", "ALL"]:
|
||||
res_det = generate_otchet_service(target_date=yesterday_date)
|
||||
if res_det.get("status") == "success":
|
||||
results.append({
|
||||
"type": "Детальный отчет",
|
||||
"filename": res_det.get("filename"),
|
||||
"download_url": res_det.get("download_url"),
|
||||
"status": "success"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"type": "Детальный отчет",
|
||||
"error": res_det.get("message"),
|
||||
"status": "error"
|
||||
})
|
||||
|
||||
# 3. Упрощенный отчет (строго за ВЧЕРА по финальному срезу Y)
|
||||
if r_type in ["SIMPLIFIED", "ALL"]:
|
||||
try:
|
||||
df_scud_y = load_best_snapshot_for_date(yesterday_date, prefer_final_y=True)
|
||||
if df_scud_y is not None and not df_scud_y.empty:
|
||||
df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_date)
|
||||
df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y)
|
||||
out_path = generate_simplified_excel(df_merged_y, date_str=yesterday_date)
|
||||
filename = os.path.basename(out_path)
|
||||
results.append({
|
||||
"type": "Упрощенный отчет",
|
||||
"filename": filename,
|
||||
"download_url": f"/api/v1/files/download/reports/{filename}",
|
||||
"status": "success"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"type": "Упрощенный отчет",
|
||||
"error": f"Финальный срез СКУД (Y) за {yesterday_date} не найден.",
|
||||
"status": "error"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка формирования упрощенного отчета: {e}")
|
||||
results.append({
|
||||
"type": "Упрощенный отчет",
|
||||
"error": str(e),
|
||||
"status": "error"
|
||||
})
|
||||
|
||||
return {
|
||||
"date": target_date,
|
||||
"yesterday_date": yesterday_date,
|
||||
"requested_type": r_type,
|
||||
"reports": results
|
||||
}
|
||||
@@ -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