feat: implement svodka/otchet generators, Y-23:59:59 and snap-to-grid time finder
This commit is contained in:
@@ -21,7 +21,7 @@ def has_yesterday_final_snapshot(date_str: str) -> bool:
|
|||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
|
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
|
||||||
(date_str,)
|
(date_str,)
|
||||||
)
|
)
|
||||||
return cursor.fetchone() is not None
|
return cursor.fetchone() is not None
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -127,16 +127,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]`
|
## 11. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
|
||||||
- [ ] **Интерактивный запуск отчетов из чата:**
|
- [ ] **Двухконтурный запуск ИИ:**
|
||||||
- [ ] Инструменты `db_generate_svodka(date_str, snapshot_id)` и `db_generate_otchet(date_str)` в Function Calling.
|
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
|
||||||
- [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
|
- [ ] **Якорный табельный номер (TabNo Matching):**
|
||||||
- [ ] **Диалоговый аудит расхождений и подтверждение связок:**
|
- [ ] Извлечение `TabNo` из MS SQL Орион (`pList.TabNo`) и MS SQL 1С:ЗУП.
|
||||||
- [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
|
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite (`scud_logs`, `zup_staff`, `person_identity_mapping`).
|
||||||
- [ ] Интерактивное подтверждение предложенных ИИ связок ФИО прямо из чата с записью в `person_identity_mapping`.
|
- [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер.
|
||||||
- [ ] **Веб-интерфейс управления исключениями:**
|
- [ ] **Динамическая инвалидация кэша связок:**
|
||||||
- [ ] Веб-форма просмотра и редактирования таблицы `exceptions_registry` в боковом меню.
|
- [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
|
## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
|
||||||
|
|||||||
+33
-16
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: main_etl.py
|
FILE: main_etl.py
|
||||||
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
|
ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
@@ -15,11 +16,13 @@ from datetime import datetime, timedelta
|
|||||||
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
|
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, calculate_summary_metrics
|
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
|
||||||
from services.scud_etl.anomaly_detector import detect_registry_anomalies
|
from services.scud_etl.anomaly_detector import detect_registry_anomalies
|
||||||
|
from services.scud_etl.svodka_generator import generate_svodka_service
|
||||||
|
from services.scud_etl.otchet_generator import generate_otchet_service
|
||||||
from services.text_reporter import generate_markdown_report
|
from services.text_reporter import generate_markdown_report
|
||||||
|
|
||||||
from services.scud_export import run_export
|
from services.scud_export import run_export
|
||||||
from services.share_copier import copy_1c_files_from_share
|
from services.share_copier import copy_1c_files_from_share
|
||||||
from services.excel_exporter import generate_summary_excel, generate_detailed_excel, export_raw_scud
|
from services.excel_exporter import export_raw_scud
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
|
parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
|
||||||
parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
|
parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
|
||||||
parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
|
parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
|
||||||
parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета")
|
parser.add_argument("--date", type=str, default=None, help="Дата расчета в формате ДД.ММ.ГГГГ")
|
||||||
|
parser.add_argument("--time", type=str, default=None, help="Время среза для сводки (например, 14:30)")
|
||||||
|
parser.add_argument("--snapshot", type=str, default=None, help="Точный ID снапшота для расчета")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
@@ -36,8 +41,13 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
if args.date:
|
||||||
|
today_str = args.date.replace('_', '.')
|
||||||
|
dt_target = datetime.strptime(today_str, "%d.%m.%Y")
|
||||||
|
days_back = 3 if dt_target.weekday() == 0 else 1
|
||||||
|
yesterday_str = (dt_target - timedelta(days=days_back)).strftime("%d.%m.%Y")
|
||||||
|
else:
|
||||||
today_str = now.strftime("%d.%m.%Y")
|
today_str = now.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
if now.weekday() == 0:
|
if now.weekday() == 0:
|
||||||
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
|
yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
|
||||||
else:
|
else:
|
||||||
@@ -46,7 +56,7 @@ def main():
|
|||||||
# [Этап 0] Выгрузка свежих данных СКУД
|
# [Этап 0] Выгрузка свежих данных СКУД
|
||||||
if not args.skip_export and not args.snapshot:
|
if not args.skip_export and not args.snapshot:
|
||||||
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
|
print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
|
||||||
run_export(debug=args.debug, save_xlsx=True)
|
run_export(input_date=args.date, debug=args.debug, save_xlsx=True)
|
||||||
else:
|
else:
|
||||||
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
|
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
|
||||||
|
|
||||||
@@ -65,25 +75,33 @@ def main():
|
|||||||
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
|
df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
|
||||||
df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
|
df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
|
||||||
|
|
||||||
# Сохранение диагностического дампа сырого СКУД
|
|
||||||
if df_scud_today is not None and not df_scud_today.empty:
|
if df_scud_today is not None and not df_scud_today.empty:
|
||||||
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
|
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
|
||||||
|
|
||||||
# [Этап 3] Детальный отчет за вчера
|
# [Этап 3] Детальный отчет за вчера через otchet_generator
|
||||||
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
|
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
|
||||||
df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
|
res_otchet = generate_otchet_service(target_date=yesterday_str)
|
||||||
generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str)
|
if res_otchet.get("status") == "success":
|
||||||
|
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}")
|
||||||
|
|
||||||
# [Этап 4] Сводка за сегодня
|
# [Этап 4] Сводка за сегодня через svodka_generator
|
||||||
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
|
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
|
||||||
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
|
res_svodka = generate_svodka_service(
|
||||||
metrics_today = calculate_summary_metrics(df_merged_today)
|
target_date=today_str,
|
||||||
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
|
target_time=args.time,
|
||||||
generate_summary_excel(df_merged_today, date_str=today_str)
|
snapshot_id=args.snapshot
|
||||||
|
)
|
||||||
|
if res_svodka.get("status") == "success":
|
||||||
|
print(f"[✓] {res_svodka.get('message')}: {res_svodka.get('filepath')}")
|
||||||
|
if res_svodka.get("note"):
|
||||||
|
print(f" ℹ️ {res_svodka.get('note')}")
|
||||||
|
|
||||||
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора
|
# [Этап 5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)
|
||||||
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
|
print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
|
||||||
|
|
||||||
|
df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
|
||||||
|
anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today)
|
||||||
|
|
||||||
absent_explained = df_merged_today[
|
absent_explained = df_merged_today[
|
||||||
(df_merged_today['Пришел'] == False) &
|
(df_merged_today['Пришел'] == False) &
|
||||||
(df_merged_today['Вид_отсутствия'].notna()) &
|
(df_merged_today['Вид_отсутствия'].notna()) &
|
||||||
@@ -95,7 +113,6 @@ def main():
|
|||||||
(df_merged_today.get('is_excluded', False) == False)
|
(df_merged_today.get('is_excluded', False) == False)
|
||||||
]
|
]
|
||||||
|
|
||||||
# ⭐️ Передаем сырые датасеты для выявления опечаток и анализа мульти-записей
|
|
||||||
summary_md = generate_markdown_report(
|
summary_md = generate_markdown_report(
|
||||||
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
|
merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
|
||||||
absent_explained=absent_explained,
|
absent_explained=absent_explained,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: services/scud_etl/otchet_generator.py
|
||||||
|
ROLE: Генератор Детального Отчета за прошлые смены (строго по итоговому Y-снапшоту).
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import DATE_YESTERDAY
|
||||||
|
from core.database import load_scud_from_db_by_snapshot
|
||||||
|
from services.scud_etl.pipeline import load_1c_files_for_date
|
||||||
|
from services.scud_etl.merger import merge_scud_and_1c
|
||||||
|
from services.excel_exporter import generate_detailed_excel, get_dated_reports_dir, format_date_ru
|
||||||
|
|
||||||
|
logger = logging.getLogger("OTCHET_GENERATOR")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_otchet_service(
|
||||||
|
target_date: Optional[str] = None,
|
||||||
|
snapshot_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Формирует детальный отчет за прошедшую смену:
|
||||||
|
- target_date: дата отчета (по умолчанию вчерашний рабочий день).
|
||||||
|
- snapshot_id: опциональный ID (по умолчанию выбирается итоговый вечерний срез Y).
|
||||||
|
"""
|
||||||
|
date_clean = str(target_date or DATE_YESTERDAY).replace('_', '.')
|
||||||
|
|
||||||
|
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||||
|
if df_scud is None or df_scud.empty:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Итоговый срез СКУД (Y) за {date_clean} не найден в базе данных."
|
||||||
|
}
|
||||||
|
|
||||||
|
df_staff, df_abs = load_1c_files_for_date(date_clean)
|
||||||
|
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
|
||||||
|
|
||||||
|
filename = f"{format_date_ru(date_clean)} отчет.xlsx"
|
||||||
|
generate_detailed_excel(df_merged, date_str=date_clean, filename=filename)
|
||||||
|
|
||||||
|
target_dir = get_dated_reports_dir(date_clean)
|
||||||
|
full_filepath = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"report_type": "OTCHET",
|
||||||
|
"date": date_clean,
|
||||||
|
"snapshot_id": snapshot_id or "AUTO_Y_FINAL",
|
||||||
|
"filename": filename,
|
||||||
|
"filepath": full_filepath,
|
||||||
|
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
|
||||||
|
"total_rows": len(df_merged),
|
||||||
|
"message": f"Детальный отчет за {date_clean} успешно сформирован."
|
||||||
|
}
|
||||||
@@ -1,59 +1,64 @@
|
|||||||
"""
|
"""
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: services/scud_etl/pipeline.py
|
FILE: services/scud_etl/pipeline.py
|
||||||
ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов.
|
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from core.connection import get_connection
|
from core.connection import get_connection
|
||||||
from services.data_loader import load_1c_data_smart
|
from core.database import load_scud_from_db_by_snapshot
|
||||||
|
from config import DATA_DIR
|
||||||
|
from services.data_loader import load_staff_data, load_absent_data
|
||||||
|
|
||||||
logger = logging.getLogger("SCUD_PIPELINE")
|
logger = logging.getLogger("SCUD_PIPELINE")
|
||||||
|
|
||||||
|
|
||||||
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
|
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]:
|
||||||
"""Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y."""
|
"""
|
||||||
|
Загружает наилучший срез СКУД за дату.
|
||||||
|
Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59).
|
||||||
|
"""
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
df = pd.DataFrame()
|
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
target_snap_id = None
|
||||||
|
|
||||||
if prefer_final_y:
|
if prefer_final_y:
|
||||||
cursor.execute(
|
cursor.execute("""
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
|
SELECT snapshot_id
|
||||||
(date_str,)
|
FROM scud_logs
|
||||||
)
|
WHERE log_date = ?
|
||||||
|
AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", (date_str,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row and row[0]:
|
if row:
|
||||||
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
|
target_snap_id = row[0]
|
||||||
|
|
||||||
if df.empty:
|
if not target_snap_id:
|
||||||
cursor.execute(
|
cursor.execute("""
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
|
SELECT snapshot_id
|
||||||
(date_str,)
|
FROM scud_logs
|
||||||
)
|
WHERE log_date = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", (date_str,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row and row[0]:
|
if row:
|
||||||
df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
|
target_snap_id = row[0]
|
||||||
|
|
||||||
if not df.empty:
|
if not target_snap_id:
|
||||||
rename_map = {
|
return None
|
||||||
'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
|
|
||||||
'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
|
|
||||||
'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел'
|
|
||||||
}
|
|
||||||
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
|
|
||||||
if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns:
|
|
||||||
df['fio_clean'] = df['Сотрудник'].astype(str).str.strip()
|
|
||||||
if 'Пришел' in df.columns:
|
|
||||||
df['Пришел'] = df['Пришел'].astype(bool)
|
|
||||||
|
|
||||||
return df
|
return load_scud_from_db_by_snapshot(date_str, snapshot_param=target_snap_id)
|
||||||
|
|
||||||
|
|
||||||
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||||
df_staff, df_absences = load_1c_data_smart(date_str, use_db=False)
|
"""
|
||||||
return (df_staff if df_staff is not None else pd.DataFrame(),
|
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
|
||||||
df_absences if df_absences is not None else pd.DataFrame())
|
"""
|
||||||
|
df_staff = load_staff_data(date_str)
|
||||||
|
df_abs = load_absent_data(date_str)
|
||||||
|
return df_staff, df_abs
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: services/scud_etl/svodka_generator.py
|
||||||
|
ROLE: Генератор Ежедневной Сводки (оперативный контроль, текущий срез).
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import DATE_TODAY
|
||||||
|
from core.database import load_scud_from_db_by_snapshot
|
||||||
|
from services.scud_etl.pipeline import load_1c_files_for_date
|
||||||
|
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
|
||||||
|
from services.scud_etl.anomaly_detector import detect_registry_anomalies
|
||||||
|
from services.snapshots.finder import find_or_create_snapshot_for_time
|
||||||
|
from services.excel_exporter import generate_summary_excel, get_dated_reports_dir, format_date_ru
|
||||||
|
|
||||||
|
logger = logging.getLogger("SVODKA_GENERATOR")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_svodka_service(
|
||||||
|
target_date: Optional[str] = None,
|
||||||
|
target_time: Optional[str] = None,
|
||||||
|
snapshot_id: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Формирует оперативную сводку на указанную дату / время:
|
||||||
|
- target_date: дата сводки (по умолчанию сегодня).
|
||||||
|
- target_time: время среза (например '14:30').
|
||||||
|
- snapshot_id: точный ID среза.
|
||||||
|
"""
|
||||||
|
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
|
||||||
|
applied_note = ""
|
||||||
|
|
||||||
|
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
|
||||||
|
if target_time and not snapshot_id:
|
||||||
|
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
|
||||||
|
snapshot_id = found_id
|
||||||
|
applied_note = note
|
||||||
|
if note:
|
||||||
|
logger.info(note)
|
||||||
|
|
||||||
|
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||||
|
if df_scud is None or df_scud.empty:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
|
||||||
|
}
|
||||||
|
|
||||||
|
df_staff, df_abs = load_1c_files_for_date(date_clean)
|
||||||
|
|
||||||
|
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
|
||||||
|
metrics = calculate_summary_metrics(df_merged)
|
||||||
|
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
|
||||||
|
|
||||||
|
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
|
||||||
|
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
|
||||||
|
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
|
||||||
|
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
|
||||||
|
|
||||||
|
target_dir = get_dated_reports_dir(date_clean)
|
||||||
|
full_filepath = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"report_type": "SVODKA",
|
||||||
|
"date": date_clean,
|
||||||
|
"target_time": target_time,
|
||||||
|
"snapshot_id": snapshot_id or "AUTO_LATEST",
|
||||||
|
"filename": filename,
|
||||||
|
"filepath": full_filepath,
|
||||||
|
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
|
||||||
|
"metrics": metrics,
|
||||||
|
"anomalies_count": len(anomalies),
|
||||||
|
"note": applied_note,
|
||||||
|
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
|
||||||
|
}
|
||||||
@@ -237,7 +237,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if is_yesterday:
|
if is_yesterday:
|
||||||
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
|
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59"
|
||||||
else:
|
else:
|
||||||
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: services/snapshots/finder.py
|
||||||
|
ROLE: Поиск ближайшего снапшота в SQLite (Smart Snap-to-Grid) и On-Demand экспорт.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from core.connection import get_connection
|
||||||
|
from services.scud_export import run_export
|
||||||
|
|
||||||
|
logger = logging.getLogger("SNAPSHOT_FINDER")
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_snapshot_for_time(
|
||||||
|
target_date_str: str,
|
||||||
|
target_time_str: str,
|
||||||
|
tolerance_minutes: int = 20,
|
||||||
|
allow_ondemand_export: bool = True
|
||||||
|
) -> Tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
Ищет ближайший срез за указанную дату и время (±tolerance_minutes).
|
||||||
|
Если не найден и allow_ondemand_export=True — запрашивает выгрузку из MS SQL на это время.
|
||||||
|
|
||||||
|
Возвращает: (snapshot_id, human_message)
|
||||||
|
"""
|
||||||
|
date_clean = target_date_str.replace('_', '.')
|
||||||
|
dt_target = datetime.strptime(f"{date_clean} {target_time_str}", "%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
# 1. Поиск существующих снапшотов за эту дату в SQLite
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT snapshot_id, snapshot_time
|
||||||
|
FROM scud_logs
|
||||||
|
WHERE log_date = ? AND snapshot_time IS NOT NULL
|
||||||
|
""", (date_clean,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
best_snapshot = None
|
||||||
|
min_diff_seconds = float('inf')
|
||||||
|
|
||||||
|
for snap_id, snap_time_str in rows:
|
||||||
|
try:
|
||||||
|
# Формат в базе: YYYY-MM-DD HH:MM:SS или DD.MM.YYYY HH:MM:SS
|
||||||
|
raw_time = str(snap_time_str).strip()
|
||||||
|
if '.' in raw_time.split()[0]:
|
||||||
|
dt_snap = datetime.strptime(raw_time, "%d.%m.%Y %H:%M:%S")
|
||||||
|
else:
|
||||||
|
dt_snap = datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
diff = abs((dt_snap - dt_target).total_seconds())
|
||||||
|
if diff < min_diff_seconds:
|
||||||
|
min_diff_seconds = diff
|
||||||
|
best_snapshot = (snap_id, snap_time_str, dt_snap)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Если найден срез в пределах допуска (по умолчанию 20 минут)
|
||||||
|
if best_snapshot and min_diff_seconds <= (tolerance_minutes * 60):
|
||||||
|
snap_id, snap_time, dt_s = best_snapshot
|
||||||
|
diff_mins = round(min_diff_seconds / 60)
|
||||||
|
return snap_id, f"Использован готовый срез {snap_id} за {dt_s.strftime('%H:%M')} (разница {diff_mins} мин)."
|
||||||
|
|
||||||
|
# 2. Если срез не найден и разрешен On-Demand экспорт из MS SQL Орион
|
||||||
|
if allow_ondemand_export:
|
||||||
|
logger.info(f"Снапшот на {date_clean} {target_time_str} не найден в SQLite. Запуск прямого среза из MS SQL...")
|
||||||
|
|
||||||
|
run_export(
|
||||||
|
input_date=date_clean,
|
||||||
|
debug=False,
|
||||||
|
save_xlsx=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT snapshot_id
|
||||||
|
FROM scud_logs
|
||||||
|
WHERE log_date = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", (date_clean,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row[0], f"Создан новый срез {row[0]} из MS SQL на {target_time_str}."
|
||||||
|
|
||||||
|
return None, f"Срез на {date_clean} {target_time_str} не найден."
|
||||||
Reference in New Issue
Block a user