перед внедрением веб интерфейса и API для взаимодействием с gemini.
This commit is contained in:
@@ -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`).
|
||||
@@ -373,3 +373,24 @@ def add_rule_to_db(rule_text, added_by="Human"):
|
||||
|
||||
def sync_knowledge_base_to_db():
|
||||
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.
@@ -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.share_copier import copy_1c_files_from_share
|
||||
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.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
|
||||
from services.text_reporter import generate_markdown_report
|
||||
@@ -46,10 +46,11 @@ def load_exceptions_config():
|
||||
def apply_exceptions_from_json(df, exceptions_cfg):
|
||||
"""
|
||||
Размечает флаг is_excluded=True на основе правил из exceptions.json.
|
||||
Сверяет отдел одновременно по полному имени из 1С и по аббревиатуре из СКУД.
|
||||
Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД.
|
||||
"""
|
||||
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
|
||||
|
||||
deps = [d.lower() for d in exceptions_cfg.get("departments", [])]
|
||||
@@ -75,6 +76,27 @@ def apply_exceptions_from_json(df, exceptions_cfg):
|
||||
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():
|
||||
"""Загружает реестр удалёнщиков и статических причин из CSV."""
|
||||
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||||
@@ -90,8 +112,11 @@ def load_static_reason_workers():
|
||||
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 = []
|
||||
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_clean = row.get('fio_clean', '')
|
||||
is_present = row.get('Пришел', False)
|
||||
is_exc = row.get('is_excluded', False)
|
||||
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')
|
||||
|
||||
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
|
||||
|
||||
# АНОМАЛИЯ 1: Физическое присутствие при документе отсутствия 1С
|
||||
# 🚨 АНОМАЛИЯ 1: Физическое присутствие при документе отсутствия 1С
|
||||
if is_present and has_1c_reason:
|
||||
reason_lower = reason_1c.lower()
|
||||
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}'"
|
||||
})
|
||||
|
||||
# АНОМАЛИЯ 2: Перемещение внутри здания без отметки утреннего входа
|
||||
# ИСКЛЮЧЕНИЯ без документов 1С НЕ пропускаются дальше к аномалиям СКУД
|
||||
if is_exc and not has_1c_reason:
|
||||
continue
|
||||
|
||||
# 🚨 АНОМАЛИЯ 2: Перемещение внутри здания без отметки утреннего входа
|
||||
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
|
||||
first_act = row.get('Первая_активность', '—')
|
||||
anomalies.append({
|
||||
@@ -128,6 +158,15 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
|
||||
"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
|
||||
|
||||
|
||||
@@ -158,10 +197,8 @@ def main():
|
||||
|
||||
ПРИМЕРЫ ЗАПУСКА:
|
||||
python main.py -- Обычный дневной запуск
|
||||
python main.py --skip-export -- Расчет отчета на момент ПОСЛЕДНЕГО имеющегося снапшота из SQLite
|
||||
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 --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
|
||||
python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
|
||||
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -169,9 +206,8 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
|
||||
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет на момент последнего имеющегося снапшота из SQLite")
|
||||
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID (например, '20260805-001', 'Y20260805-001') или время создания конкретного снапшота")
|
||||
parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/")
|
||||
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
|
||||
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
|
||||
|
||||
args = parser.parse_args()
|
||||
DEBUG = args.debug
|
||||
@@ -190,7 +226,7 @@ def main():
|
||||
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
|
||||
elif args.skip_export:
|
||||
snapshot_param = get_latest_snapshot_time()
|
||||
print(f"[📸] РЕЖИМ --skip-export: Используем самый последний снапшот из SQLite ('{snapshot_param}')")
|
||||
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
|
||||
else:
|
||||
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
|
||||
@@ -199,7 +235,7 @@ def main():
|
||||
try:
|
||||
run_export(save_xlsx=True, debug=DEBUG)
|
||||
except Exception as e:
|
||||
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к имеющимся записям в SQLite.")
|
||||
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
|
||||
else:
|
||||
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
|
||||
|
||||
@@ -259,84 +295,96 @@ def main():
|
||||
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} уже обработаны и зафиксированы в SQLite. Повторный расчет пропущен.")
|
||||
else:
|
||||
print("\n[3/5] Обработка данных за ВЧЕРА...")
|
||||
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({DATE_YESTERDAY})...")
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
if fio_mapping_scud_y:
|
||||
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
|
||||
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
|
||||
if fio_mapping_scud_y:
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
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
|
||||
)
|
||||
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
||||
|
||||
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 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'] = ''
|
||||
if 'Сотрудник' not in merged_yesterday.columns:
|
||||
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
|
||||
|
||||
merged_yesterday = merged_yesterday.merge(
|
||||
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
|
||||
on='fio_clean', how='left'
|
||||
)
|
||||
if static_reasons_dict:
|
||||
for fio_clean, reason_val in static_reasons_dict.items():
|
||||
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С-документ
|
||||
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
|
||||
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
||||
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
|
||||
mask_exc_yesterday = (
|
||||
(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:
|
||||
merged_yesterday['Пришел'] = False
|
||||
else:
|
||||
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
|
||||
# Расчет флага no_scud_pass для вчера
|
||||
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
|
||||
merged_yesterday['no_scud_pass'] = (
|
||||
(~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: Реестр статических причин
|
||||
if static_reasons_dict:
|
||||
for fio_clean, reason_val in static_reasons_dict.items():
|
||||
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С)
|
||||
filtered_yesterday = filter_report_dataframe(merged_yesterday)
|
||||
generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY)
|
||||
|
||||
# 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С)
|
||||
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
|
||||
mask_exc_yesterday = (
|
||||
(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, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
|
||||
|
||||
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)
|
||||
# Фиксируем вчерашний снапшот строго на 22:00:00
|
||||
yesterday_dt_obj = datetime.strptime(DATE_YESTERDAY, "%d.%m.%Y")
|
||||
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
|
||||
save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=yesterday_22_str, is_yesterday=True)
|
||||
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite со снапшотом на {yesterday_22_str}")
|
||||
|
||||
# ============================================================
|
||||
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
|
||||
@@ -382,7 +430,6 @@ def main():
|
||||
on='fio_clean', how='left'
|
||||
)
|
||||
|
||||
# 1. ПРИОРИТЕТ 1: 1С-документы
|
||||
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')
|
||||
|
||||
@@ -394,7 +441,6 @@ def main():
|
||||
if 'Сотрудник' not in merged_today.columns:
|
||||
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
|
||||
|
||||
# 2. ПРИОРИТЕТ 2: Реестр статических причин
|
||||
if static_reasons_dict:
|
||||
for fio_clean, reason_val in static_reasons_dict.items():
|
||||
mask_today = (
|
||||
@@ -404,7 +450,6 @@ def main():
|
||||
)
|
||||
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
|
||||
|
||||
# 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С)
|
||||
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
|
||||
mask_exc_today = (
|
||||
(merged_today['Пришел'] == False) &
|
||||
@@ -413,28 +458,65 @@ def main():
|
||||
)
|
||||
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)
|
||||
if mass_failure_result and mass_failure_result.get("is_mass_failure"):
|
||||
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
|
||||
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(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ МАССОВЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА ({mass_failure_result['anomaly_percent']}% СМЕНЫ)")
|
||||
print(mass_failure_result["alert_text"])
|
||||
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
|
||||
print(mass_failure_today["alert_text"])
|
||||
print("!" * 60 + "\n")
|
||||
|
||||
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())]
|
||||
scud_present_but_absent_in_1c = merged_today[(merged_today['Пришел'] == True) & (merged_today['Вид_отсутствия'].notna())]
|
||||
# Флаги фильтрации для ИИ-репортера
|
||||
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
|
||||
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] Запуск ИИ-аудитора и построение Ежедневной сводки...")
|
||||
# Фильтруем аномалии СКУД для текстового отчета (убираем аномалии "ОТСУТСТВУЕТ В СКУД", т.к. они ушли в раздел "Нет пропуска")
|
||||
filtered_anomalies_list = [
|
||||
a for a in anomalies_list
|
||||
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
|
||||
]
|
||||
|
||||
report_text = generate_markdown_report(
|
||||
merged_df=merged_today,
|
||||
absent_explained=absent_explained,
|
||||
absent_unexplained=absent_unexplained,
|
||||
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_absent_df=df_absent_today,
|
||||
date_str=DATE_TODAY
|
||||
|
||||
@@ -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
@@ -35,23 +35,22 @@ def print_stats():
|
||||
|
||||
|
||||
def print_snapshots_list(date_str=None):
|
||||
"""Выводит реестр снапшотов с идеальным вертикальным выравниванием дефисов и цифр."""
|
||||
"""Выводит реестр снапшотов с отображением даты и точного времени среза."""
|
||||
rows = get_available_snapshots(date_str)
|
||||
|
||||
print("\n" + "=" * 95)
|
||||
print("\n" + "=" * 105)
|
||||
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("-" * 95)
|
||||
print("-" * 105)
|
||||
|
||||
if not rows:
|
||||
print("Снапшотов пока нет.")
|
||||
print("=" * 95 + "\n")
|
||||
print("=" * 105 + "\n")
|
||||
return
|
||||
|
||||
# Внутрипитоновская хронологическая сортировка: сначала snap_time DESC, затем seq_num DESC
|
||||
def snapshot_sort_key(row):
|
||||
snap_id = row[0] 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 '—'
|
||||
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"):
|
||||
formatted_snap_id = f" {snap_id}"
|
||||
else:
|
||||
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):
|
||||
@@ -171,28 +198,42 @@ def main():
|
||||
help_text = """
|
||||
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 scud --snapshot 20260805-001 -- Выгрузка СКУД строго по ID конкретного снапшота
|
||||
python scripts/db_cli.py scud --export-xlsx debug_today -- Выгрузить срез СКУД в форматированный Excel-файл
|
||||
python scripts/db_cli.py snapshots -- Посмотреть список всех снапшотов с их ID (от новых к старым)
|
||||
python scripts/db_cli.py snapshots 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
|
||||
python scripts/db_cli.py stats
|
||||
python scripts/db_cli.py snapshots 04.08.2026
|
||||
python scripts/db_cli.py scud --snapshot Y20260805-007 --export-xlsx вчера_1
|
||||
python scripts/db_cli.py snapshot del Y20260805-007
|
||||
python scripts/db_cli.py snapshot del --day 04.08.2026
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
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('param', nargs='?', default=None, help="Параметр команды (дата в формате ДД.ММ.ГГГГ или имя файла)")
|
||||
parser.add_argument('--snapshot', type=str, default=None, help="Выбрать конкретный ID снапшота для инспекции (например, '20260805-001', 'Y20260805-001')")
|
||||
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспортировать выбранный срез СКУД в Excel-файл")
|
||||
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump', 'snapshot'], help="Основная команда")
|
||||
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')")
|
||||
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
|
||||
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()
|
||||
|
||||
# Валидация команд
|
||||
if args.command == 'stats':
|
||||
print_stats()
|
||||
elif args.command == 'snapshots':
|
||||
@@ -206,7 +247,21 @@ CLI-утилита инспекции и управления SQLite базой
|
||||
elif args.command == 'dump':
|
||||
filename = args.param if args.param else "db_dump_full.xlsx"
|
||||
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__":
|
||||
main()
|
||||
+15
-12
@@ -36,19 +36,19 @@ def ask_ollama(prompt, system_prompt=None):
|
||||
|
||||
def get_system_rules_context():
|
||||
"""
|
||||
Загружает глобальные системные правила компании прямо из Базы Данных SQLite.
|
||||
Загружает динамические системные правила компании напрямую из Базы Данных SQLite.
|
||||
"""
|
||||
rules = get_all_rules_from_db()
|
||||
if not rules:
|
||||
return ""
|
||||
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):
|
||||
"""
|
||||
Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке.
|
||||
Если процент аномалий превышает 5% от смены, вызывает ИИ для генерации критического алерта.
|
||||
Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта.
|
||||
"""
|
||||
if df_scud is None or df_scud.empty:
|
||||
return None
|
||||
@@ -66,9 +66,9 @@ def analyze_scud_mass_failure_ai(df_scud):
|
||||
rules_context = get_system_rules_context()
|
||||
prompt = f"""
|
||||
{rules_context}
|
||||
ВНИМАНИЕ! Проведён анализ дневной смены СКУД:
|
||||
ВНИМАНИЕ! Проведён анализ смены СКУД:
|
||||
- Всего записей за смену: {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):
|
||||
"""
|
||||
ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП.
|
||||
Записи из 1С считаются 100% эталоном.
|
||||
"""
|
||||
if not unrecognized_scud_fios or not staff_fios:
|
||||
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()
|
||||
|
||||
prompt = f"""
|
||||
@@ -110,7 +113,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
2. В СКУД допущена опечатка.
|
||||
3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
|
||||
4. В поле "warning" опиши обнаруженную опечатку.
|
||||
5. Запрещено выдумывать опечатки!
|
||||
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
||||
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
|
||||
|
||||
@@ -148,10 +151,10 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def ai_verify_department_exceptions(unexplained_df, exception_departments):
|
||||
"""
|
||||
Локальный ИИ проверит, не являются ли оставшиеся неотмеченными отделы
|
||||
синонимами/аббревиатурами отделов из exceptions.json.
|
||||
Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений.
|
||||
"""
|
||||
if unexplained_df.empty or not exception_departments:
|
||||
return []
|
||||
|
||||
+28
-17
@@ -7,6 +7,7 @@ from config import (
|
||||
)
|
||||
# Импортируем прямую SQL-выгрузку отсутствий из 1С
|
||||
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')
|
||||
|
||||
@@ -138,27 +139,40 @@ def process_scud_anomalies(df_scud, exceptions=None):
|
||||
return df_scud
|
||||
|
||||
|
||||
def load_scud_data(target_date_type="today"):
|
||||
"""Загружает файл СКУД за 'today' или 'yesterday' и выполняет валидацию аномалий."""
|
||||
def load_scud_data(target_date_type="today", snapshot_param=None):
|
||||
"""
|
||||
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
|
||||
"""
|
||||
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:
|
||||
scud_path = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
|
||||
if not scud_path:
|
||||
raise FileNotFoundError(f"Файл СКУД [{target_date_type}] за {target_date} не найден в {SCUD_DIR}!")
|
||||
# Загружаем данные строго из SQLite БД
|
||||
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
|
||||
|
||||
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()
|
||||
|
||||
df_scud['fio_raw'] = df_scud['Сотрудник'].astype(str) if 'Сотрудник' in df_scud.columns else df_scud[df_scud.columns[0]].astype(str)
|
||||
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
|
||||
if 'fio_clean' not in df_scud.columns:
|
||||
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(
|
||||
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
|
||||
)
|
||||
return df_scud
|
||||
@@ -166,8 +180,7 @@ def load_scud_data(target_date_type="today"):
|
||||
|
||||
def load_all_data():
|
||||
"""
|
||||
Загружает слитные датасеты за Сегодня и за Вчера.
|
||||
Если данных за Сегодня нет, возвращает для них None.
|
||||
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
|
||||
"""
|
||||
df_scud_today = load_scud_data(target_date_type="today")
|
||||
try:
|
||||
@@ -175,11 +188,9 @@ def load_all_data():
|
||||
except FileNotFoundError:
|
||||
df_scud_yesterday = df_scud_today.copy()
|
||||
|
||||
# Загружаем 1С за Сегодня (может быть None)
|
||||
df_staff_today = load_staff_data(DATE_TODAY)
|
||||
df_absent_today = load_absent_data(DATE_TODAY)
|
||||
|
||||
# Загружаем 1С за Вчера (гарантированно есть)
|
||||
df_staff_yesterday = load_staff_data(DATE_YESTERDAY)
|
||||
df_absent_yesterday = load_absent_data(DATE_YESTERDAY)
|
||||
|
||||
|
||||
+63
-29
@@ -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_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", 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"]
|
||||
|
||||
# Палитра для Детального отчета
|
||||
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") # Аномалии (пришел в отпуске)
|
||||
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):
|
||||
@@ -130,8 +132,17 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
|
||||
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=2, value=len(unexplained))
|
||||
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
|
||||
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[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(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
|
||||
current_row += 1
|
||||
|
||||
# 5. ИТОГО НА РАБОТЕ (Свернут hidden=True)
|
||||
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
|
||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||
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
|
||||
current_row += 1
|
||||
|
||||
# 6. АНОМАЛИИ (Свернут hidden=True)
|
||||
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
|
||||
anomalies = merged_df[
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())) |
|
||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
||||
(~is_exc) & (
|
||||
((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С")
|
||||
@@ -246,7 +275,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С КОЛОНКОЙ 'первая активность' СРАЗУ ПОСЛЕ 'время входа') ---
|
||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
|
||||
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
if not filename:
|
||||
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["D2"].font = Font(name="Arial", size=10, bold=True)
|
||||
|
||||
# ⭐️ Порядок колонок: "первая активность" строго после "время входа"
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
ws.append([
|
||||
idx + 1,
|
||||
row.get('Сотрудник', ''),
|
||||
row.get('Подразделение', ''),
|
||||
dept_scud_val,
|
||||
row.get(start_col, 'Нет входа'),
|
||||
first_act_val, # ⭐️ Колонки №5: "первая активность"
|
||||
first_act_val,
|
||||
row.get(end_col, 'Нет выхода'),
|
||||
in_building_str,
|
||||
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
|
||||
|
||||
# Заливка строк по категориям
|
||||
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
|
||||
row_fill = LIGHT_RED_FILL
|
||||
elif is_present and has_reason:
|
||||
# ЗАЛИВКА СТРОК:
|
||||
if is_present and has_reason:
|
||||
row_fill = GREEN_FILL
|
||||
elif not is_present:
|
||||
row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL
|
||||
elif not is_present and has_reason:
|
||||
row_fill = YELLOW_FILL
|
||||
elif not is_present and not has_reason and not has_first_act:
|
||||
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
|
||||
row_fill = LIGHT_RED_FILL
|
||||
else:
|
||||
row_fill = None
|
||||
|
||||
@@ -339,18 +370,21 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
else:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
# Динамический компактный автоподгон ширины колонок
|
||||
for col in ws.columns:
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
if col_letter == 'H':
|
||||
ws.column_dimensions['H'].width = 25.0
|
||||
else:
|
||||
max_len = 0
|
||||
for cell in col:
|
||||
if cell.value is not None:
|
||||
line_max = max(len(l) for l in str(cell.value).split("\n"))
|
||||
if line_max > max_len:
|
||||
max_len = line_max
|
||||
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
||||
max_len = 0
|
||||
for cell in col:
|
||||
if cell.value is not None:
|
||||
cell_lines = str(cell.value).split("\n")
|
||||
line_max = max(len(line) for line in cell_lines)
|
||||
if line_max > max_len:
|
||||
max_len = line_max
|
||||
|
||||
optimal_width = max(max_len + 2, 8)
|
||||
if optimal_width > 35:
|
||||
optimal_width = 35
|
||||
ws.column_dimensions[col_letter].width = optimal_width
|
||||
|
||||
try:
|
||||
wb.save(output_path)
|
||||
|
||||
@@ -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)
|
||||
|
||||
targets = get_targets(input_date)
|
||||
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
conn_str = (
|
||||
f"DRIVER={{{ODBC_DRIVER}}};"
|
||||
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"]
|
||||
is_yesterday = (period_label == "Вчера")
|
||||
|
||||
# ⚡️ ПРОВЕРКА: Если вчерашние данные уже есть в SQLite, повторную выгрузку из MS SQL пропускаем
|
||||
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
|
||||
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
|
||||
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. Повторная выгрузка пропущена.", "INFO")
|
||||
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован в SQLite. Пропускаем запрос к MS SQL.")
|
||||
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"))
|
||||
|
||||
connection = None
|
||||
|
||||
Reference in New Issue
Block a user