перед внедрением веб интерфейса и API для взаимодействием с gemini.

This commit is contained in:
2026-08-05 23:02:35 +03:00
parent 8f611ec41a
commit 9deca9ab34
10 changed files with 495 additions and 194 deletions
+73
View File
@@ -0,0 +1,73 @@
# 🛠 HOWTO: Правила внесения изменений и передачи кода в проекте
## 📌 Зачем это нужно
Проект `scud_orion_ai_v2` активно растет (17+ модулей, СУБД SQLite, ИИ-контур). Передача полных файлов кода при каждой мелкой правке сжигает окно контекста LLM, замедляет диалог и приводит к потере ранее внесенных архитектурных фич.
---
## 🎯 Правила для разработчика и ИИ
### 1. Формат передачи изменений
При запросах и ответах используются **точечные фрагменты кода** (Diff-стиль) с указанием привязок.
Любой фрагмент должен содержать:
1. **Точный путь к файлу** (`services/excel_exporter.py`).
2. **Имя целевой функции/класса** (`generate_summary_excel`).
3. **Зацепку (Context Anchor)** — уникальную соседнюю строку или блок `try/except`.
---
## 📐 Шаблоны фрагментов
### Вариант А: Замена существующей логики
> **Файл:** `services/excel_exporter.py`
> **Функция:** `generate_summary_excel()`
> **Место:** Строка ~150, блок фильтрации `no_pass_df`
>
> ❌ **Удалить:**
> ```python
> no_pass_df = merged_df[merged_df.get('no_scud_pass', False) == True]
> ```
>
> ✅ **Вставить:**
> ```python
> if 'no_scud_pass' in merged_df.columns:
> no_pass_df = merged_df[merged_df['no_scud_pass'] == True]
> else:
> no_pass_df = pd.DataFrame()
> ```
---
### Вариант Б: Вставка нового условия/блока
> **Файл:** `main.py`
> **Функция:** `detect_all_anomalies()`
> **Место:** После цикла `for idx, row in merged_df.iterrows():`
>
> ➕ **Вставить код:**
> ```python
> # ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД
> if is_exc and not has_1c_reason:
> continue
> ```
---
### Вариант В: Git Unified Diff Format
> **Файл:** `services/data_loader.py`
> ```diff
> @@ def process_scud_anomalies(df_scud, exceptions=None): @@
> - first_act = '—'
> + # Сохраняем вычисленную ранее активность из БД/файла
> + first_act = existing_fa if existing_fa not in ['—', '', 'None'] else '—'
> ```
---
## 🚀 Чек-лист перед запуском
1. Проверить синтаксис через `python -m py_compile <файл.py>`.
2. Запустить скрипт с флагом отладки `python main.py -d`.
3. Убедиться, что снапшот записан в SQLite (`python scripts/db_cli.py snapshots`).
+21
View File
@@ -373,3 +373,24 @@ def add_rule_to_db(rule_text, added_by="Human"):
def sync_knowledge_base_to_db(): def sync_knowledge_base_to_db():
pass pass
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
# Удаляем по log_date или по дате внутри snapshot_id
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
return deleted_count
Binary file not shown.
+174 -92
View File
@@ -11,7 +11,7 @@ from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_f
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.data_validator import check_file_freshness from services.data_validator import check_file_freshness
from services.data_loader import load_all_data from services.data_loader import load_all_data, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel from services.excel_exporter import generate_summary_excel, generate_detailed_excel
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report from services.text_reporter import generate_markdown_report
@@ -46,10 +46,11 @@ def load_exceptions_config():
def apply_exceptions_from_json(df, exceptions_cfg): def apply_exceptions_from_json(df, exceptions_cfg):
""" """
Размечает флаг is_excluded=True на основе правил из exceptions.json. Размечает флаг is_excluded=True на основе правил из exceptions.json.
Сверяет отдел одновременно по полному имени из 1С и по аббревиатуре из СКУД. Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД.
""" """
if df is None or df.empty or not exceptions_cfg: if df is None or df.empty or not exceptions_cfg:
df['is_excluded'] = False if df is not None:
df['is_excluded'] = False
return df return df
deps = [d.lower() for d in exceptions_cfg.get("departments", [])] deps = [d.lower() for d in exceptions_cfg.get("departments", [])]
@@ -75,6 +76,27 @@ def apply_exceptions_from_json(df, exceptions_cfg):
return df return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
# В отчет попадают ТОЛЬКО офисные сотрудники с пропусками ИЛИ имеющие документ из 1С
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers(): def load_static_reason_workers():
"""Загружает реестр удалёнщиков и статических причин из CSV.""" """Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
@@ -90,8 +112,11 @@ def load_static_reason_workers():
return {} return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""Автоматически находит ИСТИННЫЕ аномалии.""" """
Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
Исключения (ОВК/Клининг/Охрана) НЕ попадают в аномалии СКУД, если на них нет документа из 1С.
"""
anomalies = [] anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
@@ -101,13 +126,14 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
fio = row.get('Сотрудник', row.get('fio_clean', '')) fio = row.get('Сотрудник', row.get('fio_clean', ''))
fio_clean = row.get('fio_clean', '') fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip() reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and reason_1c != 'Исключение (ОВК/Подрядчики)' has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE') anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
# АНОМАЛИЯ 1: Физическое присутствие при документе отсутствия 1С # 🚨 АНОМАЛИЯ 1: Физическое присутствие при документе отсутствия 1С
if is_present and has_1c_reason: if is_present and has_1c_reason:
reason_lower = reason_1c.lower() reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS) is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
@@ -119,7 +145,11 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
}) })
# АНОМАЛИЯ 2: Перемещение внутри здания без отметки утреннего входа # ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД
if is_exc and not has_1c_reason:
continue
# 🚨 АНОМАЛИЯ 2: Перемещение внутри здания без отметки утреннего входа
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—') first_act = row.get('Первая_активность', '—')
anomalies.append({ anomalies.append({
@@ -128,6 +158,15 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
}) })
# 🚨 АНОМАЛИЯ 3: Сотрудник есть в Штате 1С, но ПОЛНОСТЬЮ ОТСУТСТВУЕТ в базе СКУД Орион Pro
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies return anomalies
@@ -158,10 +197,8 @@ def main():
ПРИМЕРЫ ЗАПУСКА: ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета на момент ПОСЛЕДНЕГО имеющегося снапшота из SQLite python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по составному ID снапшота python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
python main.py --snapshot Y20260805-001 -- Расчет отчета по вчерашнему фиксированному снапшоту
python main.py --snapshot "2026-08-05 09:23:31" -- Расчет отчета по точной метке времени создания
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG) python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -169,9 +206,8 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter formatter_class=argparse.RawDescriptionHelpFormatter
) )
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 и построить отчет на момент последнего имеющегося снапшота из SQLite") parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID (например, '20260805-001', 'Y20260805-001') или время создания конкретного снапшота") parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/")
args = parser.parse_args() args = parser.parse_args()
DEBUG = args.debug DEBUG = args.debug
@@ -190,7 +226,7 @@ def main():
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'") print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export: elif args.skip_export:
snapshot_param = get_latest_snapshot_time() snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем самый последний снапшот из SQLite ('{snapshot_param}')") print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
else: else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
@@ -199,7 +235,7 @@ def main():
try: try:
run_export(save_xlsx=True, debug=DEBUG) run_export(save_xlsx=True, debug=DEBUG)
except Exception as e: except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к имеющимся записям в SQLite.") print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else: else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
@@ -259,84 +295,96 @@ def main():
static_reasons_dict = load_static_reason_workers() static_reasons_dict = load_static_reason_workers()
# ============================================================ # ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ГАРАНТИРОВАННЫЙ РАСЧЕТ)
# ============================================================ # ============================================================
if has_scud_logs_for_date(DATE_YESTERDAY) and not args.snapshot: print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({DATE_YESTERDAY})...")
print(f"\n[3/5] Вчерашние данные за {DATE_YESTERDAY} уже обработаны и зафиксированы в SQLite. Повторный расчет пропущен.")
else:
print("\n[3/5] Обработка данных за ВЧЕРА...")
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns: if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else [] scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y: if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply( raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
merged_yesterday = df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame()
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
) )
if df_absent_yesterday is not None and not df_absent_yesterday.empty: if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
merged_yesterday = df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame() if 'Сотрудник' not in merged_yesterday.columns:
if not merged_yesterday.empty: merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge( if static_reasons_dict:
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], for fio_clean, reason_val in static_reasons_dict.items():
on='fio_clean', how='left' mask_yesterday = (
) (merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
# 1. ПРИОРИТЕТ 1: 1С-документ merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
if df_absent_yesterday is not None and not df_absent_yesterday.empty: mask_exc_yesterday = (
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') (merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
if 'Пришел' not in merged_yesterday.columns: # Расчет флага no_scud_pass для вчера
merged_yesterday['Пришел'] = False scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
else: merged_yesterday['no_scud_pass'] = (
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False) (~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == False)
)
if 'Сотрудник' not in merged_yesterday.columns: # 🚨 Рассчитываем аномалии за ВЧЕРА
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean']) anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
save_anomalies_to_db(anomalies_yesterday_list, DATE_YESTERDAY)
# 2. ПРИОРИТЕТ 2: Реестр статических причин # ⚡️ Фильтрация: исключения НЕ попадают в вчерашний отчет (если нет отпуска из 1С)
if static_reasons_dict: filtered_yesterday = filter_report_dataframe(merged_yesterday)
for fio_clean, reason_val in static_reasons_dict.items(): generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY)
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
# 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С) # Фиксируем вчерашний снапшот строго на 22:00:00
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg) yesterday_dt_obj = datetime.strptime(DATE_YESTERDAY, "%d.%m.%Y")
mask_exc_yesterday = ( yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
(merged_yesterday['Пришел'] == False) & save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=yesterday_22_str, is_yesterday=True)
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite со снапшотом на {yesterday_22_str}")
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY)
save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=snapshot_param if not args.snapshot and not args.skip_export else None, is_yesterday=True)
# ============================================================ # ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
@@ -382,7 +430,6 @@ def main():
on='fio_clean', how='left' on='fio_clean', how='left'
) )
# 1. ПРИОРИТЕТ 1: 1С-документы
if df_absent_today is not None and not df_absent_today.empty: if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
@@ -394,7 +441,6 @@ def main():
if 'Сотрудник' not in merged_today.columns: if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean']) merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
# 2. ПРИОРИТЕТ 2: Реестр статических причин
if static_reasons_dict: if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items(): for fio_clean, reason_val in static_reasons_dict.items():
mask_today = ( mask_today = (
@@ -404,7 +450,6 @@ def main():
) )
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
# 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С)
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg) merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
mask_exc_today = ( mask_exc_today = (
(merged_today['Пришел'] == False) & (merged_today['Пришел'] == False) &
@@ -413,28 +458,65 @@ def main():
) )
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules) # Расчет флага no_scud_pass для сегодня
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
mass_failure_result = analyze_scud_mass_failure_ai(merged_today) scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
if mass_failure_result and mass_failure_result.get("is_mass_failure"): anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
# 🚨 ОПЕРАТИВНЫЙ МОНИТОРИНГ ОБОРУДОВАНИЯ СКУД ЗА СЕГОДНЯ:
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60) print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ МАССОВЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА ({mass_failure_result['anomaly_percent']}% СМЕНЫ)") print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_result["alert_text"]) print(mass_failure_today["alert_text"])
print("!" * 60 + "\n") print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, DATE_TODAY) save_anomalies_to_db(anomalies_list, DATE_TODAY)
absent_explained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].notna())] # Флаги фильтрации для ИИ-репортера
absent_unexplained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].isna())] is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
scud_present_but_absent_in_1c = merged_today[(merged_today['Пришел'] == True) & (merged_today['Вид_отсутствия'].notna())] is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
# Истинно неизвестные случаи (исключаем тех, у кого нет пропуска, и спецотделы ОВК/Охрана)
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
# Фильтруем аномалии СКУД для текстового отчета (убираем аномалии "ОТСУТСТВУЕТ В СКУД", т.к. они ушли в раздел "Нет пропуска")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report( report_text = generate_markdown_report(
merged_df=merged_today, merged_df=merged_today,
absent_explained=absent_explained, absent_explained=absent_explained,
absent_unexplained=absent_unexplained, absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=anomalies_list, anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df, raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today, raw_absent_df=df_absent_today,
date_str=DATE_TODAY date_str=DATE_TODAY
+19
View File
@@ -0,0 +1,19 @@
import sys
import os
# Добавляем корень проекта в путь поиска модулей Python
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import get_connection
rule_text = (
"Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, "
"являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска."
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,))
conn.commit()
print("✓ Правило успешно внесено в SQLite БД!")
+80 -25
View File
@@ -35,23 +35,22 @@ def print_stats():
def print_snapshots_list(date_str=None): def print_snapshots_list(date_str=None):
"""Выводит реестр снапшотов с идеальным вертикальным выравниванием дефисов и цифр.""" """Выводит реестр снапшотов с отображением даты и точного времени среза."""
rows = get_available_snapshots(date_str) rows = get_available_snapshots(date_str)
print("\n" + "=" * 95) print("\n" + "=" * 105)
print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:") print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:")
print("=" * 95) print("=" * 105)
header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата среза':<12} | {'Записей':<8}" header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}"
print(header) print(header)
print("-" * 95) print("-" * 105)
if not rows: if not rows:
print("Снапшотов пока нет.") print("Снапшотов пока нет.")
print("=" * 95 + "\n") print("=" * 105 + "\n")
return return
# Внутрипитоновская хронологическая сортировка: сначала snap_time DESC, затем seq_num DESC
def snapshot_sort_key(row): def snapshot_sort_key(row):
snap_id = row[0] or "" snap_id = row[0] or ""
snap_time = row[2] or "" snap_time = row[2] or ""
@@ -72,15 +71,43 @@ def print_snapshots_list(date_str=None):
snap_time = r[2] if r[2] else '—' snap_time = r[2] if r[2] else '—'
count = r[3] count = r[3]
# 💡 ИДЕАЛЬНОЕ ВЫРАВНИВАНИЕ: если нет префикса Y, добавляем ведущий пробел # Извлекаем время (ЧЧ:ММ:СС) из snapshot_time, если оно есть
time_part = "—"
if snap_time and " " in snap_time:
time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
if not snap_id.startswith("Y"): if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}" formatted_snap_id = f" {snap_id}"
else: else:
formatted_snap_id = snap_id formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {log_date:<12} | {count:<8}") print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
print("=" * 95 + "\n") print("=" * 105 + "\n")
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n")
return deleted_count
def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
@@ -171,28 +198,42 @@ def main():
help_text = """ help_text = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
ДОСТУПНЫЕ КОМАНДЫ:
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть список всех снапшотов (опционально за дату)
scud [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД и опциональный экспорт в Excel
anomalies -- Посмотреть все найденные аномалии СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшотов по ID или за весь день
ПРИМЕРЫ ЗАПУСКА: ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py scud -- Выгрузка СКУД за текущую дату (последний снапшот) python scripts/db_cli.py stats
python scripts/db_cli.py scud --snapshot 20260805-001 -- Выгрузка СКУД строго по ID конкретного снапшота python scripts/db_cli.py snapshots 04.08.2026
python scripts/db_cli.py scud --export-xlsx debug_today -- Выгрузить срез СКУД в форматированный Excel-файл python scripts/db_cli.py scud --snapshot Y20260805-007 --export-xlsx вчера_1
python scripts/db_cli.py snapshots -- Посмотреть список всех снапшотов с их ID (от новых к старым) python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshots 04.08.2026 -- Посмотреть снапшоты за конкретную дату python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py stats -- Общая статистика строк по всем таблицам БД
python scripts/db_cli.py anomalies -- Посмотреть все найденные аномалии СКУД ⟷ 1С
python scripts/db_cli.py rules -- Посмотреть правила Базы Знаний ИИ из SQLite
python scripts/db_cli.py dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=help_text, description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=True
) )
parser.add_argument('command', nargs='?', default='stats', choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump'], help="Команда: stats | snapshots | scud | anomalies | rules | dump") parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump', 'snapshot'], help="Основная команда")
parser.add_argument('param', nargs='?', default=None, help="Параметр команды (дата в формате ДД.ММ.ГГГГ или имя файла)") parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')")
parser.add_argument('--snapshot', type=str, default=None, help="Выбрать конкретный ID снапшота для инспекции (например, '20260805-001', 'Y20260805-001')") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспортировать выбранный срез СКУД в Excel-файл") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
# Если аргументы вообще не переданы, выводим статистику по умолчанию (или справку)
if len(sys.argv) == 1:
print_stats()
return
args = parser.parse_args() args = parser.parse_args()
# Валидация команд
if args.command == 'stats': if args.command == 'stats':
print_stats() print_stats()
elif args.command == 'snapshots': elif args.command == 'snapshots':
@@ -206,7 +247,21 @@ CLI-утилита инспекции и управления SQLite базой
elif args.command == 'dump': elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx" filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename) dump_all_to_excel(filename)
elif args.command == 'snapshot':
if args.action == 'del':
if args.day:
delete_snapshots_by_date(args.day)
elif args.param:
delete_snapshot_by_id(args.param)
else:
print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.")
print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n")
else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
else:
print(f"\n[❌] Ошибка: Неизвестная команда.")
parser.print_help()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+15 -12
View File
@@ -36,19 +36,19 @@ def ask_ollama(prompt, system_prompt=None):
def get_system_rules_context(): def get_system_rules_context():
""" """
Загружает глобальные системные правила компании прямо из Базы Данных SQLite. Загружает динамические системные правила компании напрямую из Базы Данных SQLite.
""" """
rules = get_all_rules_from_db() rules = get_all_rules_from_db()
if not rules: if not rules:
return "" return ""
rules_text = "\n".join([f"- {r}" for r in rules]) rules_text = "\n".join([f"- {r}" for r in rules])
return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ:\n{rules_text}\n" return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n"
def analyze_scud_mass_failure_ai(df_scud): def analyze_scud_mass_failure_ai(df_scud):
""" """
Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке. Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке.
Если процент аномалий превышает 5% от смены, вызывает ИИ для генерации критического алерта. Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта.
""" """
if df_scud is None or df_scud.empty: if df_scud is None or df_scud.empty:
return None return None
@@ -66,9 +66,9 @@ def analyze_scud_mass_failure_ai(df_scud):
rules_context = get_system_rules_context() rules_context = get_system_rules_context()
prompt = f""" prompt = f"""
{rules_context} {rules_context}
ВНИМАНИЕ! Проведён анализ дневной смены СКУД: ВНИМАНИЕ! Проведён анализ смены СКУД:
- Всего записей за смену: {total_records} - Всего записей за смену: {total_records}
- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью (первая активность): {anomaly_count} ({anomaly_percent}% от смены) - Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены)
Сформируй понятное предупреждение для Администратора СКУД и Руководителя. Сформируй понятное предупреждение для Администратора СКУД и Руководителя.
Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия. Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия.
@@ -90,13 +90,16 @@ def analyze_scud_mass_failure_ai(df_scud):
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
"""
ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП.
Записи из 1С считаются 100% эталоном.
"""
if not unrecognized_scud_fios or not staff_fios: if not unrecognized_scud_fios or not staff_fios:
return {} return {}
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
staff_fios_set = set(staff_fios)
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
if not real_unrecognized:
return {}
rules_context = get_system_rules_context() rules_context = get_system_rules_context()
prompt = f""" prompt = f"""
@@ -110,7 +113,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
2. В СКУД допущена опечатка. 2. В СКУД допущена опечатка.
3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией. 3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
4. В поле "warning" опиши обнаруженную опечатку. 4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки! 5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
@@ -148,10 +151,10 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
return mapping return mapping
def ai_verify_department_exceptions(unexplained_df, exception_departments): def ai_verify_department_exceptions(unexplained_df, exception_departments):
""" """
Локальный ИИ проверит, не являются ли оставшиеся неотмеченными отделы Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений.
синонимами/аббревиатурами отделов из exceptions.json.
""" """
if unexplained_df.empty or not exception_departments: if unexplained_df.empty or not exception_departments:
return [] return []
+28 -17
View File
@@ -7,6 +7,7 @@ from config import (
) )
# Импортируем прямую SQL-выгрузку отсутствий из 1С # Импортируем прямую SQL-выгрузку отсутствий из 1С
from services.zup_extractor import fetch_zup_absences_from_sql from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import load_scud_from_db_by_snapshot
warnings.filterwarnings('ignore', category=UserWarning, module='pandas') warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
@@ -138,27 +139,40 @@ def process_scud_anomalies(df_scud, exceptions=None):
return df_scud return df_scud
def load_scud_data(target_date_type="today"): def load_scud_data(target_date_type="today", snapshot_param=None):
"""Загружает файл СКУД за 'today' или 'yesterday' и выполняет валидацию аномалий.""" """
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
scud_path = find_dated_file("Сотрудники", target_date, search_dirs=[SCUD_DIR])
if not scud_path: # Загружаем данные строго из SQLite БД
scud_path = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if not scud_path:
raise FileNotFoundError(f"Файл СКУД [{target_date_type}] за {target_date} не найден в {SCUD_DIR}!") if df_scud is None or df_scud.empty:
# Fallback на случай отсутствия записей за вчера — пробуем взять за сегодня
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
raise FileNotFoundError(f"В базе SQLite не найдены логи СКУД за {target_date}!")
df_scud = pd.read_excel(scud_path)
exceptions = load_exceptions() exceptions = load_exceptions()
df_scud['fio_raw'] = df_scud['Сотрудник'].astype(str) if 'Сотрудник' in df_scud.columns else df_scud[df_scud.columns[0]].astype(str) if 'fio_clean' not in df_scud.columns:
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
# Применяем обработчик аномалий и первой активности # Нормализация колонок под единый стандарт отчета
df_scud = process_scud_anomalies(df_scud, exceptions=exceptions) if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply( df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1 axis=1
) )
return df_scud return df_scud
@@ -166,8 +180,7 @@ def load_scud_data(target_date_type="today"):
def load_all_data(): def load_all_data():
""" """
Загружает слитные датасеты за Сегодня и за Вчера. Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
Если данных за Сегодня нет, возвращает для них None.
""" """
df_scud_today = load_scud_data(target_date_type="today") df_scud_today = load_scud_data(target_date_type="today")
try: try:
@@ -175,11 +188,9 @@ def load_all_data():
except FileNotFoundError: except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy() df_scud_yesterday = df_scud_today.copy()
# Загружаем 1С за Сегодня (может быть None)
df_staff_today = load_staff_data(DATE_TODAY) df_staff_today = load_staff_data(DATE_TODAY)
df_absent_today = load_absent_data(DATE_TODAY) df_absent_today = load_absent_data(DATE_TODAY)
# Загружаем 1С за Вчера (гарантированно есть)
df_staff_yesterday = load_staff_data(DATE_YESTERDAY) df_staff_yesterday = load_staff_data(DATE_YESTERDAY)
df_absent_yesterday = load_absent_data(DATE_YESTERDAY) df_absent_yesterday = load_absent_data(DATE_YESTERDAY)
+63 -29
View File
@@ -36,13 +36,15 @@ FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_typ
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
# Палитра для Детального отчета # Палитра для Детального отчета
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Неизвестные случаи LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска"
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
@@ -130,8 +132,17 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
current_row = 4 current_row = 4
# 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, чистые ФИО без цифр) # Подготовка флагов для точной фильтрации
unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
unexplained = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained)) ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
@@ -144,7 +155,23 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
ws.row_dimensions[current_row].hidden = False ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) # 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
ws.cell(row=current_row, column=1, value="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_only = merged_df[ absent_only = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
@@ -168,7 +195,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 5. ИТОГО НА РАБОТЕ (Свернут hidden=True) # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
is_working_mask = (merged_df['Пришел'] == True) | ( is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
) )
@@ -187,10 +214,12 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
ws.row_dimensions[current_row].hidden = True ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6. АНОМАЛИИ (Свернут hidden=True) # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
anomalies = merged_df[ anomalies = merged_df[
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())) | (~is_exc) & (
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
] ]
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
@@ -246,7 +275,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С КОЛОНКОЙ 'первая активность' СРАЗУ ПОСЛЕ 'время входа') --- # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
if not filename: if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx" filename = f"{format_date_ru(date_str)} отчет.xlsx"
@@ -261,7 +290,6 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True)
# ⭐️ Порядок колонок: "первая активность" строго после "время входа"
headers = [ headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы" "находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
@@ -290,16 +318,18 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
anom_flag = row.get('anomaly_flag', 'NONE') anom_flag = row.get('anomaly_flag', 'NONE')
in_building_str = str(row.get(hours_col, '00:00')) in_building_str = str(row.get(hours_col, '00:00'))
first_act_val = row.get('Первая_активность', '—') first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
ws.append([ ws.append([
idx + 1, idx + 1,
row.get('Сотрудник', ''), row.get('Сотрудник', ''),
row.get('Подразделение', ''), dept_scud_val,
row.get(start_col, 'Нет входа'), row.get(start_col, 'Нет входа'),
first_act_val, # ⭐️ Колонки №5: "первая активность" first_act_val,
row.get(end_col, 'Нет выхода'), row.get(end_col, 'Нет выхода'),
in_building_str, in_building_str,
absence_reason if has_reason else '', absence_reason if has_reason else '',
@@ -309,13 +339,14 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
row_num = 5 + idx row_num = 5 + idx
# Заливка строк по категориям # ЗАЛИВКА СТРОК:
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': if is_present and has_reason:
row_fill = LIGHT_RED_FILL
elif is_present and has_reason:
row_fill = GREEN_FILL row_fill = GREEN_FILL
elif not is_present: elif not is_present and has_reason:
row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
row_fill = LIGHT_RED_FILL
else: else:
row_fill = None row_fill = None
@@ -339,18 +370,21 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
else: else:
cell.alignment = Alignment(horizontal="left", vertical="center") cell.alignment = Alignment(horizontal="left", vertical="center")
# Динамический компактный автоподгон ширины колонок
for col in ws.columns: for col in ws.columns:
col_letter = get_column_letter(col[0].column) col_letter = get_column_letter(col[0].column)
if col_letter == 'H': max_len = 0
ws.column_dimensions['H'].width = 25.0 for cell in col:
else: if cell.value is not None:
max_len = 0 cell_lines = str(cell.value).split("\n")
for cell in col: line_max = max(len(line) for line in cell_lines)
if cell.value is not None: if line_max > max_len:
line_max = max(len(l) for l in str(cell.value).split("\n")) max_len = line_max
if line_max > max_len:
max_len = line_max optimal_width = max(max_len + 2, 8)
ws.column_dimensions[col_letter].width = max(max_len + 4, 12) if optimal_width > 35:
optimal_width = 35
ws.column_dimensions[col_letter].width = optimal_width
try: try:
wb.save(output_path) wb.save(output_path)
+9 -6
View File
@@ -208,8 +208,6 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
os.makedirs(SCUD_DIR, exist_ok=True) os.makedirs(SCUD_DIR, exist_ok=True)
targets = get_targets(input_date) targets = get_targets(input_date)
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn_str = ( conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};" f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};" f"SERVER={SERVER_NAME};"
@@ -227,13 +225,18 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
period_label = target["name"] period_label = target["name"]
is_yesterday = (period_label == "Вчера") is_yesterday = (period_label == "Вчера")
# ⚡️ ПРОВЕРКА: Если вчерашние данные уже есть в SQLite, повторную выгрузку из MS SQL пропускаем # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_scud_logs_for_date(processing_date_str): if is_yesterday and has_scud_logs_for_date(processing_date_str):
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован в SQLite. Пропускаем запрос к MS SQL.")
log(f"[ℹ️] Вчерашние логи СКУД за {processing_date_str} уже присутствуют в SQLite. Повторная выгрузка пропущена.", "INFO")
continue continue
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
connection = None connection = None