перед внедрением веб интерфейса и 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():
|
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.
@@ -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,9 +46,10 @@ 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:
|
||||||
|
if df is not None:
|
||||||
df['is_excluded'] = False
|
df['is_excluded'] = False
|
||||||
return df
|
return df
|
||||||
|
|
||||||
@@ -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,12 +295,9 @@ 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
|
||||||
@@ -303,7 +336,6 @@ def main():
|
|||||||
on='fio_clean', how='left'
|
on='fio_clean', how='left'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. ПРИОРИТЕТ 1: 1С-документ
|
|
||||||
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:
|
||||||
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
||||||
|
|
||||||
@@ -315,7 +347,6 @@ def main():
|
|||||||
if 'Сотрудник' not in merged_yesterday.columns:
|
if 'Сотрудник' not in merged_yesterday.columns:
|
||||||
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
|
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['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_yesterday = (
|
mask_yesterday = (
|
||||||
@@ -325,7 +356,6 @@ def main():
|
|||||||
)
|
)
|
||||||
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
|
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
|
||||||
|
|
||||||
# 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С)
|
|
||||||
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
|
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
|
||||||
mask_exc_yesterday = (
|
mask_exc_yesterday = (
|
||||||
(merged_yesterday['Пришел'] == False) &
|
(merged_yesterday['Пришел'] == False) &
|
||||||
@@ -334,9 +364,27 @@ def main():
|
|||||||
)
|
)
|
||||||
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
|
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
|
||||||
|
|
||||||
generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY)
|
# Расчет флага 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)
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
# 🚨 Рассчитываем аномалии за ВЧЕРА
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ⚡️ Фильтрация: исключения НЕ попадают в вчерашний отчет (если нет отпуска из 1С)
|
||||||
|
filtered_yesterday = filter_report_dataframe(merged_yesterday)
|
||||||
|
generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY)
|
||||||
|
|
||||||
|
# Фиксируем вчерашний снапшот строго на 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: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
|
# 🎯 ЧАСТЬ 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
|
||||||
|
|||||||
@@ -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):
|
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
@@ -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 []
|
||||||
|
|||||||
+27
-16
@@ -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:
|
||||||
|
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['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)
|
||||||
|
|
||||||
|
|||||||
+57
-23
@@ -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['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
|
||||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
(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':
|
|
||||||
ws.column_dimensions['H'].width = 25.0
|
|
||||||
else:
|
|
||||||
max_len = 0
|
max_len = 0
|
||||||
for cell in col:
|
for cell in col:
|
||||||
if cell.value is not None:
|
if cell.value is not None:
|
||||||
line_max = max(len(l) for l in str(cell.value).split("\n"))
|
cell_lines = str(cell.value).split("\n")
|
||||||
|
line_max = max(len(line) for line in cell_lines)
|
||||||
if line_max > max_len:
|
if line_max > max_len:
|
||||||
max_len = line_max
|
max_len = line_max
|
||||||
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
|
||||||
|
optimal_width = max(max_len + 2, 8)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user