diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py index c284742..e2df72c 100644 --- a/core/repositories/scud_repo.py +++ b/core/repositories/scud_repo.py @@ -21,7 +21,7 @@ def has_yesterday_final_snapshot(date_str: str) -> bool: with get_connection() as conn: cursor = conn.cursor() 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,) ) return cursor.fetchone() is not None diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index a4511ea..fb11585 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000..d3ce252 Binary files /dev/null and b/data/scud_orion_ai.db-shm differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal new file mode 100644 index 0000000..97d3fa6 Binary files /dev/null and b/data/scud_orion_ai.db-wal differ diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index 67762c7..29dd1e6 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -127,16 +127,15 @@ --- -## 11. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]` -- [ ] **Интерактивный запуск отчетов из чата:** - - [ ] Инструменты `db_generate_svodka(date_str, snapshot_id)` и `db_generate_otchet(date_str)` в Function Calling. - - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`). -- [ ] **Диалоговый аудит расхождений и подтверждение связок:** - - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера». - - [ ] Интерактивное подтверждение предложенных ИИ связок ФИО прямо из чата с записью в `person_identity_mapping`. -- [ ] **Веб-интерфейс управления исключениями:** - - [ ] Веб-форма просмотра и редактирования таблицы `exceptions_registry` в боковом меню. - +## 11. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` +- [ ] **Двухконтурный запуск ИИ:** + - [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`. +- [ ] **Якорный табельный номер (TabNo Matching):** + - [ ] Извлечение `TabNo` из MS SQL Орион (`pList.TabNo`) и MS SQL 1С:ЗУП. + - [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite (`scud_logs`, `zup_staff`, `person_identity_mapping`). + - [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер. +- [ ] **Динамическая инвалидация кэша связок:** + - [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения. --- ## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` diff --git a/main_etl.py b/main_etl.py index 1a4bc3b..0a8fa8d 100644 --- a/main_etl.py +++ b/main_etl.py @@ -1,6 +1,7 @@ """ =============================================================================== FILE: main_etl.py +PROJECT: SCUD Orion AI (Unified Architecture) 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.merger import merge_scud_and_1c, calculate_summary_metrics 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.scud_export import run_export 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") @@ -28,7 +31,9 @@ def main(): parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") 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() print("=" * 60) @@ -36,17 +41,22 @@ def main(): print("=" * 60) now = datetime.now() - today_str = now.strftime("%d.%m.%Y") - - if now.weekday() == 0: - yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") + 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: - yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") + today_str = now.strftime("%d.%m.%Y") + if now.weekday() == 0: + yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") + else: + yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") # [Этап 0] Выгрузка свежих данных СКУД if not args.skip_export and not args.snapshot: 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: 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_today, df_abs_today = load_1c_files_for_date(today_str) - # Сохранение диагностического дампа сырого СКУД if df_scud_today is not None and not df_scud_today.empty: export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx") - # [Этап 3] Детальный отчет за вчера + # [Этап 3] Детальный отчет за вчера через otchet_generator print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") - df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) - generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) + res_otchet = generate_otchet_service(target_date=yesterday_str) + if res_otchet.get("status") == "success": + print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}") - # [Этап 4] Сводка за сегодня - print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") - df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) - metrics_today = calculate_summary_metrics(df_merged_today) - anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today) - generate_summary_excel(df_merged_today, date_str=today_str) + # [Этап 4] Сводка за сегодня через svodka_generator + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...") + res_svodka = generate_svodka_service( + target_date=today_str, + target_time=args.time, + 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)...") + 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[ (df_merged_today['Пришел'] == False) & (df_merged_today['Вид_отсутствия'].notna()) & @@ -95,7 +113,6 @@ def main(): (df_merged_today.get('is_excluded', False) == False) ] - # ⭐️ Передаем сырые датасеты для выявления опечаток и анализа мульти-записей summary_md = generate_markdown_report( merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False], absent_explained=absent_explained, diff --git a/services/scud_etl/otchet_generator.py b/services/scud_etl/otchet_generator.py new file mode 100644 index 0000000..88e7a96 --- /dev/null +++ b/services/scud_etl/otchet_generator.py @@ -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} успешно сформирован." + } \ No newline at end of file diff --git a/services/scud_etl/pipeline.py b/services/scud_etl/pipeline.py index f5d0b42..adebb6a 100644 --- a/services/scud_etl/pipeline.py +++ b/services/scud_etl/pipeline.py @@ -1,59 +1,64 @@ """ =============================================================================== FILE: services/scud_etl/pipeline.py -ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов. =============================================================================== """ import os import logging +from typing import Optional, Dict, Any, Tuple import pandas as pd + 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") -def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: - """Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y.""" +def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) -> Optional[pd.DataFrame]: + """ + Загружает наилучший срез СКУД за дату. + Если prefer_final_y=True — отдает предпочтение финишному Y (23:59:59). + """ with get_connection() as conn: - df = pd.DataFrame() cursor = conn.cursor() + target_snap_id = None if prefer_final_y: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", - (date_str,) - ) + cursor.execute(""" + SELECT snapshot_id + 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() - if row and row[0]: - df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + if row: + target_snap_id = row[0] - if df.empty: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", - (date_str,) - ) + if not target_snap_id: + cursor.execute(""" + SELECT snapshot_id + FROM scud_logs + WHERE log_date = ? + ORDER BY id DESC LIMIT 1 + """, (date_str,)) row = cursor.fetchone() - if row and row[0]: - df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + if row: + target_snap_id = row[0] - if not df.empty: - rename_map = { - '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) + if not target_snap_id: + return None - 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]: - 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(), - df_absences if df_absences is not None else pd.DataFrame()) \ No newline at end of file +def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]: + """ + Загружает реестры штата и отсутствий 1С на указанную дату через data_loader. + """ + df_staff = load_staff_data(date_str) + df_abs = load_absent_data(date_str) + return df_staff, df_abs \ No newline at end of file diff --git a/services/scud_etl/svodka_generator.py b/services/scud_etl/svodka_generator.py new file mode 100644 index 0000000..c203fc3 --- /dev/null +++ b/services/scud_etl/svodka_generator.py @@ -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 ''} успешно сформирована." + } \ No newline at end of file diff --git a/services/scud_export.py b/services/scud_export.py index cf413ca..c95c9d4 100644 --- a/services/scud_export.py +++ b/services/scud_export.py @@ -237,7 +237,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo continue 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: snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") diff --git a/services/snapshots/finder.py b/services/snapshots/finder.py new file mode 100644 index 0000000..6626fd7 --- /dev/null +++ b/services/snapshots/finder.py @@ -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} не найден." \ No newline at end of file