12.08.2026 17:30 Логика формирования отчетов работает, все отсутствия учитываются, логика присутствия сотрудников в случае сбоя СКУД на работе настроена.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# 🛠 HOWTO: Помощь в технической диагностике системы (`scud_orion_ai_v2`)
|
||||
|
||||
Данный документ содержит руководство по работе со служебными диагностическими утилитами проекта. Скрипты предназначены для быстрой оценки состояния базы данных SQLite, анализа файловой структуры и формирования контекстного снимка кодовой базы для ИИ.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Содержимое папки `scripts/diagnostics/`
|
||||
|
||||
Рекомендуется размещать диагностические скрипты в отдельной директории `scripts/diagnostics/`:
|
||||
|
||||
1. **`inspect_db.py`** — Инспектор таблиц и записей базы данных SQLite.
|
||||
2. **`inspect_files.py`** — Сканер дискового пространства и структуры файлов.
|
||||
3. **`make_code_snapshot.py`** — Генератор единого текстового слепка кода для ИИ.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Назначение и запуск утилит
|
||||
|
||||
### 1. Инспектор базы данных SQLite (`inspect_db.py`)
|
||||
|
||||
* **Назначение:**
|
||||
* Проверяет доступность базы данных (`data/scud_orion_ai.db`).
|
||||
* Автоматически считывает список всех таблиц и их колонок.
|
||||
* Подсчитывает количество записей в каждой таблице (`scud_logs`, `zup_staff`, `zup_absences` и др.).
|
||||
* Выводит список всех активных правил из таблицы `ai_knowledge_base`.
|
||||
|
||||
* **Команда для запуска:**
|
||||
```bash
|
||||
python scripts/diagnostics/inspect_db.py
|
||||
2. Сканер файловой структуры (inspect_files.py)
|
||||
Назначение:
|
||||
|
||||
Обходит структуру каталогов проекта.
|
||||
|
||||
Показывает точный размер каждого файла в байтах и итоговый вес проекта в мегабайтах.
|
||||
|
||||
Автоматически игнорирует служебные и временные папки (.git, __pycache__, venv, .venv).
|
||||
|
||||
Команда для запуска:
|
||||
|
||||
Bash
|
||||
python scripts/diagnostics/inspect_files.py
|
||||
3. Генератор контекстного снимка кода (make_code_snapshot.py)
|
||||
Назначение:
|
||||
|
||||
Собирает весь исходный код Python (.py), настройки (.json), инструкции (.md) и скрипты (.sh) в единый файл project_code_snapshot.md.
|
||||
|
||||
Исключает тяжелые архивы, логи и виртуальное окружение.
|
||||
|
||||
Сформированный файл передается нейросети (LLM) для быстрого погружения в контекст проекта без потери архитектурных деталей.
|
||||
|
||||
Команда для запуска:
|
||||
|
||||
Bash
|
||||
python scripts/diagnostics/make_code_snapshot.py
|
||||
🎯 Чек-лист применения при сбоях
|
||||
Ошибка или несостыковка в отчетах: запустить inspect_db.py и проверить, заполнены ли таблицы scud_logs и zup_absences.
|
||||
|
||||
Передача задачи или кода ИИ-ассистенту: запустить make_code_snapshot.py и прикрепить итоговый project_code_snapshot.md к запросу.
|
||||
@@ -0,0 +1,21 @@
|
||||
import openpyxl
|
||||
|
||||
wb_good = openpyxl.load_workbook('output/5 августа 2026 отчет.xlsx') # или ваш путь к рабочему
|
||||
wb_bad = openpyxl.load_workbook('output/10 августа 2026 отчет.xlsx')
|
||||
|
||||
ws_good = wb_good.active
|
||||
ws_bad = wb_bad.active
|
||||
|
||||
print(f"Размер хорошего: {ws_good.max_row} строк, {ws_good.max_column} колонок")
|
||||
print(f"Размер с ошибкой: {ws_bad.max_row} строк, {ws_bad.max_column} колонок")
|
||||
|
||||
# Сравним типы ячеек в колонках
|
||||
for r in range(4, min(ws_good.max_row, ws_bad.max_row) + 1):
|
||||
for c in range(1, 11):
|
||||
v_g = ws_good.cell(row=r, column=c).value
|
||||
v_b = ws_bad.cell(row=r, column=c).value
|
||||
t_g = type(v_g)
|
||||
t_b = type(v_b)
|
||||
if t_g != t_b:
|
||||
|
||||
print(f"Строка {r}, колонка {c}: тип в хорошем {t_g}, в плохом {t_b}")
|
||||
+70
-35
@@ -231,57 +231,73 @@ def get_latest_snapshot_time(date_str=None):
|
||||
|
||||
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
|
||||
"""
|
||||
Загружает логи СКУД по ID снапшота ('20260805-001', 'Y20260805-001') или по штампу времени.
|
||||
С автоматическим маппингом полей Подразделения и Должности.
|
||||
Загружает логи СКУД по ID снапшота.
|
||||
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
|
||||
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
df = pd.DataFrame()
|
||||
|
||||
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
|
||||
if snapshot_param:
|
||||
if "-" in str(snapshot_param) or str(snapshot_param).startswith('Y'):
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_id = ?",
|
||||
conn, params=(date_str, str(snapshot_param))
|
||||
)
|
||||
if df.empty:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
||||
conn, params=(str(snapshot_param),)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
|
||||
if df.empty and date_str:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
|
||||
cursor.execute(
|
||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
|
||||
(date_str,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
|
||||
if not row:
|
||||
cursor.execute(
|
||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
|
||||
(date_str,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row and row[0]:
|
||||
target_snap_id = row[0]
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_time = ?",
|
||||
conn, params=(date_str, str(snapshot_param))
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
latest_time = get_latest_snapshot_time(date_str)
|
||||
if latest_time:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM scud_logs WHERE log_date = ? AND snapshot_time = ?",
|
||||
conn, params=(date_str, latest_time)
|
||||
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
||||
conn, params=(target_snap_id,)
|
||||
)
|
||||
|
||||
# Приводим названия колонок к стандарту
|
||||
if not df.empty:
|
||||
df['Сотрудник'] = df['fio']
|
||||
df['Пришел'] = df['is_present'].astype(int) == 1 if 'is_present' in df.columns else False
|
||||
df['Начало_дня'] = df['time_in']
|
||||
df['Первая_активность'] = df['first_activity']
|
||||
df['Конец_дня'] = df['time_out']
|
||||
df['Находился_в_здании'] = df['time_in_building']
|
||||
rename_map = {
|
||||
'department': 'Подразделение',
|
||||
'position': 'Должность',
|
||||
'fio': 'Сотрудник',
|
||||
'time_in': 'Начало_дня',
|
||||
'first_activity': 'Первая_активность',
|
||||
'time_out': 'Конец_дня',
|
||||
'duration': 'Находился_в_здании',
|
||||
'is_present': 'Пришел'
|
||||
}
|
||||
# Переименовываем имеющиеся колонки
|
||||
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
|
||||
|
||||
# 💡 Маппинг колонок БД в стандартные русские наименования
|
||||
if 'department' in df.columns and 'Подразделение' not in df.columns:
|
||||
df['Подразделение'] = df['department']
|
||||
if 'position' in df.columns and 'Должность' not in df.columns:
|
||||
df['Должность'] = df['position']
|
||||
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
|
||||
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
|
||||
for col in required_cols:
|
||||
if col not in df.columns:
|
||||
if col == 'Пришел':
|
||||
df[col] = False
|
||||
else:
|
||||
df = pd.DataFrame(columns=[
|
||||
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
|
||||
'Начало_дня', 'Первая_активность', 'Конец_дня',
|
||||
'Находился_в_здании', 'Пришел', 'anomaly_flag'
|
||||
])
|
||||
df[col] = '—'
|
||||
|
||||
# Приводим тип флага Пришел к bool
|
||||
if 'Пришел' in df.columns:
|
||||
df['Пришел'] = df['Пришел'].astype(bool)
|
||||
|
||||
return df
|
||||
|
||||
@@ -443,3 +459,22 @@ def add_department_synonym_to_db(short_name, full_name):
|
||||
)
|
||||
conn.commit()
|
||||
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
|
||||
|
||||
def load_staff_from_db(date_str):
|
||||
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
|
||||
with get_connection() as conn:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
|
||||
conn, params=(date_str,)
|
||||
)
|
||||
return df if not df.empty else None
|
||||
|
||||
def load_absences_from_db(date_str):
|
||||
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
|
||||
with get_connection() as conn:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
|
||||
conn, params=(date_str,)
|
||||
)
|
||||
return df if not df.empty else None
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@ import sys
|
||||
import json
|
||||
import argparse
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -11,7 +11,7 @@ from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_f
|
||||
from services.scud_export import run_export
|
||||
from services.share_copier import copy_1c_files_from_share
|
||||
from services.data_validator import check_file_freshness
|
||||
from services.data_loader import load_all_data, load_scud_data
|
||||
from services.data_loader import load_1c_data_smart, 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
|
||||
@@ -24,45 +24,12 @@ from core.database import (
|
||||
save_absences_to_db,
|
||||
save_anomalies_to_db,
|
||||
load_scud_from_db_by_snapshot,
|
||||
load_staff_from_db,
|
||||
load_absences_from_db,
|
||||
get_latest_snapshot_time,
|
||||
has_scud_logs_for_date
|
||||
)
|
||||
|
||||
from services.ai_verifier import resolve_department_exception_ai
|
||||
|
||||
def apply_exceptions_from_json(df, exceptions_cfg):
|
||||
"""
|
||||
Размечает флаг is_excluded=True на основе правил из exceptions.json и БД SQLite.
|
||||
Сверяет одновременно 1С, СКУД и использует интеллектуальный поиск отделов.
|
||||
"""
|
||||
if df is None or df.empty or not exceptions_cfg:
|
||||
if df is not None:
|
||||
df['is_excluded'] = False
|
||||
return df
|
||||
|
||||
deps = exceptions_cfg.get("departments", [])
|
||||
exact_pos = [p.lower() for p in exceptions_cfg.get("positions", [])]
|
||||
pos_kw = [k.lower() for k in exceptions_cfg.get("position_keywords", [])]
|
||||
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", [])]
|
||||
|
||||
df['is_excluded'] = False
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
fio_clean = row.get('fio_clean', '')
|
||||
dep_1c = row.get('Подразделение', '')
|
||||
dep_scud = row.get('department_scud', row.get('department', ''))
|
||||
pos = str(row.get('Должность', '')).lower()
|
||||
|
||||
is_fio_exc = fio_clean in exc_fios
|
||||
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw) if pos else False
|
||||
|
||||
# Двухуровневая интеллектуальная проверка отделов (1С + СКУД + ИИ-Кеш БД)
|
||||
is_dep_exc = resolve_department_exception_ai(dep_1c, dep_scud, deps)
|
||||
|
||||
if is_fio_exc or is_dep_exc or is_pos_exc:
|
||||
df.at[idx, 'is_excluded'] = True
|
||||
|
||||
return df
|
||||
|
||||
def load_exceptions_config():
|
||||
"""Загружает файл exceptions.json из корня проекта."""
|
||||
@@ -80,18 +47,18 @@ def load_exceptions_config():
|
||||
|
||||
def apply_exceptions_from_json(df, exceptions_cfg):
|
||||
"""
|
||||
Размечает флаг is_excluded=True на основе правил из exceptions.json.
|
||||
Сверяет отдел одновременно по полному имени из 1С и по короткой аббревиатуре из СКУД.
|
||||
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
|
||||
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
|
||||
"""
|
||||
if df is None or df.empty or not exceptions_cfg:
|
||||
if df is not None:
|
||||
df['is_excluded'] = False
|
||||
return df
|
||||
|
||||
deps = [d.lower() for d in exceptions_cfg.get("departments", [])]
|
||||
exact_pos = [p.lower() for p in exceptions_cfg.get("positions", [])]
|
||||
pos_kw = [k.lower() for k in exceptions_cfg.get("position_keywords", [])]
|
||||
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", [])]
|
||||
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
|
||||
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
|
||||
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
|
||||
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
|
||||
|
||||
df['is_excluded'] = False
|
||||
|
||||
@@ -102,8 +69,12 @@ def apply_exceptions_from_json(df, exceptions_cfg):
|
||||
pos = str(row.get('Должность', '')).lower()
|
||||
|
||||
is_fio_exc = fio_clean in exc_fios
|
||||
is_dep_exc = any(d in dep_1c or d == dep_scud for d in deps) if (dep_1c or dep_scud) else False
|
||||
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw) if pos else False
|
||||
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
|
||||
|
||||
# Быстрая проверка отделов по подстрокам
|
||||
is_dep_exc = False
|
||||
if deps:
|
||||
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
|
||||
|
||||
if is_fio_exc or is_dep_exc or is_pos_exc:
|
||||
df.at[idx, 'is_excluded'] = True
|
||||
@@ -127,7 +98,6 @@ def filter_report_dataframe(merged_df):
|
||||
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
|
||||
|
||||
@@ -150,7 +120,6 @@ def load_static_reason_workers():
|
||||
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 ""
|
||||
@@ -168,7 +137,6 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set
|
||||
|
||||
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
|
||||
|
||||
# 🚨 АНОМАЛИЯ 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)
|
||||
@@ -180,11 +148,9 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set
|
||||
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
|
||||
})
|
||||
|
||||
# ИСКЛЮЧЕНИЯ без документов 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({
|
||||
@@ -193,7 +159,6 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set
|
||||
"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({
|
||||
@@ -274,6 +239,8 @@ def main():
|
||||
else:
|
||||
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
|
||||
|
||||
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
|
||||
if not args.snapshot and not args.skip_export:
|
||||
copy_1c_files_from_share()
|
||||
|
||||
print("[1/5] Проверка актуальности и свежести входных данных...")
|
||||
@@ -294,45 +261,62 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
print("[✓] Проверка доступности данных успешно пройдена!\n")
|
||||
else:
|
||||
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
|
||||
has_today_1c = True
|
||||
|
||||
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
|
||||
|
||||
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
|
||||
if args.snapshot or args.skip_export:
|
||||
raw_scud_today_df = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param)
|
||||
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY, snapshot_param)
|
||||
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
|
||||
|
||||
if raw_scud_yesterday_df.empty:
|
||||
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(DATE_YESTERDAY)
|
||||
|
||||
(
|
||||
_, _,
|
||||
df_staff_today, df_absent_today,
|
||||
df_staff_yesterday, df_absent_yesterday
|
||||
) = load_all_data()
|
||||
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
|
||||
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
|
||||
else:
|
||||
(
|
||||
raw_scud_today_df, raw_scud_yesterday_df,
|
||||
df_staff_today, df_absent_today,
|
||||
df_staff_yesterday, df_absent_yesterday
|
||||
) = load_all_data()
|
||||
target_date_str = DATE_TODAY
|
||||
|
||||
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
|
||||
if dt_target.weekday() == 0: # Понедельник -> Пятница
|
||||
dt_yesterday = dt_target - timedelta(days=3)
|
||||
else:
|
||||
dt_yesterday = dt_target - timedelta(days=1)
|
||||
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
|
||||
|
||||
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
|
||||
|
||||
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
|
||||
|
||||
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
|
||||
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
|
||||
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
|
||||
else:
|
||||
target_date_str = DATE_TODAY
|
||||
yesterday_date_str = DATE_YESTERDAY
|
||||
|
||||
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
|
||||
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
|
||||
|
||||
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
|
||||
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
|
||||
|
||||
if df_staff_yesterday is not None:
|
||||
save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY)
|
||||
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
|
||||
if df_absent_yesterday is not None:
|
||||
save_absences_to_db(df_absent_yesterday, DATE_YESTERDAY)
|
||||
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
|
||||
|
||||
if df_staff_today is not None:
|
||||
save_staff_to_db(df_staff_today, DATE_TODAY)
|
||||
save_staff_to_db(df_staff_today, target_date_str)
|
||||
if df_absent_today is not None:
|
||||
save_absences_to_db(df_absent_today, DATE_TODAY)
|
||||
save_absences_to_db(df_absent_today, target_date_str)
|
||||
|
||||
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
|
||||
static_reasons_dict = load_static_reason_workers()
|
||||
|
||||
# ============================================================
|
||||
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ГАРАНТИРОВАННЫЙ РАСЧЕТ)
|
||||
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
|
||||
# ============================================================
|
||||
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({DATE_YESTERDAY})...")
|
||||
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
|
||||
|
||||
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
|
||||
@@ -356,7 +340,9 @@ def main():
|
||||
|
||||
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()
|
||||
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
|
||||
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today 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:
|
||||
@@ -371,8 +357,16 @@ def main():
|
||||
on='fio_clean', how='left'
|
||||
)
|
||||
|
||||
if 'Вид_отсутствия' in merged_yesterday.columns:
|
||||
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
|
||||
|
||||
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
|
||||
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'
|
||||
)
|
||||
|
||||
if 'Пришел' not in merged_yesterday.columns:
|
||||
merged_yesterday['Пришел'] = False
|
||||
@@ -399,7 +393,6 @@ def main():
|
||||
)
|
||||
merged_yesterday.loc[mask_exc_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)) &
|
||||
@@ -407,28 +400,25 @@ def main():
|
||||
(merged_yesterday.get('is_excluded', False) == False)
|
||||
)
|
||||
|
||||
# 🚨 Рассчитываем аномалии за ВЧЕРА
|
||||
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)
|
||||
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
|
||||
|
||||
# ⚡️ Фильтрация: исключения НЕ попадают в вчерашний отчет (если нет отпуска из 1С)
|
||||
filtered_yesterday = filter_report_dataframe(merged_yesterday)
|
||||
generate_detailed_excel(merged_df=filtered_yesterday, date_str=DATE_YESTERDAY)
|
||||
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
|
||||
|
||||
# Фиксируем вчерашний снапшот строго на 22:00:00
|
||||
yesterday_dt_obj = datetime.strptime(DATE_YESTERDAY, "%d.%m.%Y")
|
||||
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%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}")
|
||||
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
|
||||
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
|
||||
|
||||
# ============================================================
|
||||
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
|
||||
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
|
||||
# ============================================================
|
||||
if not args.snapshot and not args.skip_export:
|
||||
save_scud_to_db(raw_scud_today_df, DATE_TODAY, snapshot_time=snapshot_param, is_yesterday=False)
|
||||
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
|
||||
|
||||
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
|
||||
print("\n[4/5] Обработка данных за СЕГОДНЯ...")
|
||||
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
|
||||
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
|
||||
|
||||
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
|
||||
@@ -493,7 +483,6 @@ def main():
|
||||
)
|
||||
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
|
||||
|
||||
# Расчет флага 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)) &
|
||||
@@ -504,7 +493,6 @@ def main():
|
||||
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)
|
||||
@@ -512,9 +500,8 @@ def main():
|
||||
print(mass_failure_today["alert_text"])
|
||||
print("!" * 60 + "\n")
|
||||
|
||||
save_anomalies_to_db(anomalies_list, DATE_TODAY)
|
||||
save_anomalies_to_db(anomalies_list, target_date_str)
|
||||
|
||||
# Флаги фильтрации для ИИ-репортера
|
||||
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
|
||||
|
||||
@@ -524,7 +511,6 @@ def main():
|
||||
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
|
||||
]
|
||||
|
||||
# Истинно неизвестные случаи (исключаем тех, у кого нет пропуска, и спецотделы ОВК/Охрана)
|
||||
absent_unexplained = merged_today[
|
||||
(merged_today['Пришел'] == False) &
|
||||
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||
@@ -540,7 +526,6 @@ def main():
|
||||
]
|
||||
|
||||
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
|
||||
# Фильтруем аномалии СКУД для текстового отчета (убираем аномалии "ОТСУТСТВУЕТ В СКУД", т.к. они ушли в раздел "Нет пропуска")
|
||||
filtered_anomalies_list = [
|
||||
a for a in anomalies_list
|
||||
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
|
||||
@@ -554,12 +539,12 @@ def main():
|
||||
anomalies_list=filtered_anomalies_list,
|
||||
raw_scud_df=raw_scud_today_df,
|
||||
raw_absent_df=df_absent_today,
|
||||
date_str=DATE_TODAY
|
||||
date_str=target_date_str
|
||||
)
|
||||
|
||||
generate_summary_excel(merged_df=merged_today, date_str=DATE_TODAY)
|
||||
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
|
||||
|
||||
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{DATE_TODAY}.md")
|
||||
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
|
||||
with open(md_report_path, "w", encoding="utf-8") as f:
|
||||
f.write(report_text)
|
||||
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
|
||||
@@ -580,7 +565,7 @@ def main():
|
||||
print("=" * 60)
|
||||
print(report_text)
|
||||
else:
|
||||
print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.")
|
||||
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+40
-9
@@ -71,7 +71,6 @@ def print_snapshots_list(date_str=None):
|
||||
snap_time = r[2] if r[2] else '—'
|
||||
count = r[3]
|
||||
|
||||
# Извлекаем время (ЧЧ:ММ:СС) из snapshot_time, если оно есть
|
||||
time_part = "—"
|
||||
if snap_time and " " in snap_time:
|
||||
time_part = snap_time.split(" ")[1]
|
||||
@@ -155,6 +154,30 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
|
||||
print("=" * 90 + "\n")
|
||||
|
||||
|
||||
def print_absences(date_str=None):
|
||||
"""Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день."""
|
||||
target_date = date_str if date_str else DATE_TODAY
|
||||
print("\n" + "=" * 90)
|
||||
print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:")
|
||||
print("=" * 90)
|
||||
|
||||
with get_connection() as conn:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio",
|
||||
conn,
|
||||
params=(target_date,)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.")
|
||||
else:
|
||||
print(f"Всего зафиксировано документов 1С: {len(df)}")
|
||||
print("-" * 90)
|
||||
print(df.to_string(index=False))
|
||||
|
||||
print("=" * 90 + "\n")
|
||||
|
||||
|
||||
def print_anomalies():
|
||||
"""Выводит список аномалий СКУД из БД."""
|
||||
print("\n" + "=" * 80)
|
||||
@@ -193,6 +216,7 @@ def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
|
||||
df.to_excel(writer, sheet_name=table[:31], index=False)
|
||||
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
|
||||
|
||||
|
||||
def print_system_prompts():
|
||||
"""Выводит все системные промпты из базы данных."""
|
||||
print("\n" + "=" * 80)
|
||||
@@ -211,6 +235,7 @@ def print_system_prompts():
|
||||
print(f"{r[4]}\n")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
|
||||
def print_session_states():
|
||||
"""Выводит текущие активные сессии и превью (session_states)."""
|
||||
print("\n" + "=" * 80)
|
||||
@@ -230,14 +255,14 @@ def print_session_states():
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
help_text = """
|
||||
HELP_TEXT = """
|
||||
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
|
||||
|
||||
ДОСТУПНЫЕ КОМАНДЫ:
|
||||
stats -- Общая статистика строк по всем таблицам БД
|
||||
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
|
||||
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
|
||||
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
|
||||
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
|
||||
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
|
||||
prompts -- Посмотреть системные промпты (system_prompts)
|
||||
@@ -250,22 +275,26 @@ CLI-утилита инспекции и управления SQLite базой
|
||||
python scripts/db_cli.py snapshots 06.08.2026
|
||||
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
|
||||
python scripts/db_cli.py scud --snapshot Y20260805-007
|
||||
python scripts/db_cli.py absences 07.08.2026
|
||||
python scripts/db_cli.py prompts
|
||||
python scripts/db_cli.py sessions
|
||||
python scripts/db_cli.py snapshot del Y20260805-007
|
||||
python scripts/db_cli.py snapshot del --day 04.08.2026
|
||||
python scripts/db_cli.py dump my_dump.xlsx
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
if any(arg in sys.argv for arg in ['-h', '--help']):
|
||||
print(help_text)
|
||||
print(HELP_TEXT)
|
||||
sys.exit(0)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=help_text,
|
||||
description=HELP_TEXT,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
add_help=False # Отключаем стандартный вывод справки argparse
|
||||
add_help=False
|
||||
)
|
||||
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot'], help="Основная команда")
|
||||
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', '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 конкретного снапшота для инспекции")
|
||||
@@ -278,7 +307,6 @@ CLI-утилита инспекции и управления SQLite базой
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Валидация команд
|
||||
if args.command == 'stats':
|
||||
print_stats()
|
||||
elif args.command == 'snapshots':
|
||||
@@ -286,6 +314,9 @@ CLI-утилита инспекции и управления SQLite базой
|
||||
print_snapshots_list(date_str=date_val)
|
||||
elif args.command == 'scud':
|
||||
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
|
||||
elif args.command == 'absences':
|
||||
date_val = args.param or args.action
|
||||
print_absences(date_str=date_val)
|
||||
elif args.command == 'anomalies':
|
||||
print_anomalies()
|
||||
elif args.command == 'rules':
|
||||
@@ -311,7 +342,7 @@ CLI-утилита инспекции и управления SQLite базой
|
||||
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
|
||||
else:
|
||||
print(f"\n[❌] Ошибка: Неизвестная команда.")
|
||||
parser.print_help()
|
||||
print(HELP_TEXT)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Автопоиск файла базы данных в проекте
|
||||
db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db'
|
||||
|
||||
print("=" * 80)
|
||||
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
|
||||
print("=" * 80)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Файл базы данных {db_path} не найден!")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Список всех таблиц и колонок
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
tables = [t[0] for t in cursor.fetchall()]
|
||||
|
||||
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
|
||||
print("-" * 80)
|
||||
for t_name in tables:
|
||||
cursor.execute(f"PRAGMA table_info({t_name})")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
|
||||
|
||||
# 2. Просмотр правил Базы Знаний
|
||||
if 'ai_knowledge_base' in tables:
|
||||
print("\n" + "=" * 80)
|
||||
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
|
||||
print("=" * 80)
|
||||
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
|
||||
rules = cursor.fetchall()
|
||||
if not rules:
|
||||
print("Таблица ai_knowledge_base пуста.")
|
||||
else:
|
||||
for r_id, r_text, r_author in rules:
|
||||
print(f" {r_id}. [{r_author}] {r_text}\n")
|
||||
|
||||
conn.close()
|
||||
print("=" * 80)
|
||||
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
|
||||
print("=" * 80)
|
||||
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_ai_v2)")
|
||||
print("=" * 80)
|
||||
|
||||
total_files = 0
|
||||
total_size = 0
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# Исключаем служебные каталоги
|
||||
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
|
||||
|
||||
for f in files:
|
||||
p = os.path.join(root, f)
|
||||
size = os.path.getsize(p)
|
||||
total_files += 1
|
||||
total_size += size
|
||||
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
|
||||
|
||||
print("-" * 80)
|
||||
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
|
||||
print("=" * 80)
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
|
||||
|
||||
# Расширения файлов для включения в снимок
|
||||
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'}
|
||||
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
|
||||
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
|
||||
|
||||
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
|
||||
|
||||
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
|
||||
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n")
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
||||
|
||||
for file in sorted(files):
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
|
||||
filepath = os.path.join(root, file)
|
||||
out.write(f"## File: `{filepath}`\n")
|
||||
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
out.write(f.read())
|
||||
except Exception as e:
|
||||
out.write(f"// Ошибка чтения файла: {e}\n")
|
||||
out.write("\n```\n\n")
|
||||
|
||||
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
|
||||
Binary file not shown.
+62
-23
@@ -5,9 +5,13 @@ from config import (
|
||||
normalize_fio, clean_scud_fio_light, load_exceptions,
|
||||
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
|
||||
)
|
||||
# Импортируем прямую SQL-выгрузку отсутствий из 1С
|
||||
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
|
||||
from services.zup_extractor import fetch_zup_absences_from_sql
|
||||
from core.database import load_scud_from_db_by_snapshot
|
||||
from core.database import (
|
||||
load_scud_from_db_by_snapshot,
|
||||
load_staff_from_db,
|
||||
load_absences_from_db
|
||||
)
|
||||
|
||||
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
|
||||
|
||||
@@ -56,8 +60,10 @@ def load_staff_data(date_str):
|
||||
def load_absent_data(date_str):
|
||||
"""
|
||||
Загружает документально подтвержденные отсутствия сотрудников
|
||||
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату.
|
||||
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
|
||||
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
|
||||
"""
|
||||
df_absent = None
|
||||
try:
|
||||
df_absent = fetch_zup_absences_from_sql(date_str)
|
||||
|
||||
@@ -65,19 +71,63 @@ def load_absent_data(date_str):
|
||||
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
|
||||
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
|
||||
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
|
||||
return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
||||
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
||||
else:
|
||||
return None
|
||||
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
|
||||
except Exception as e:
|
||||
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
|
||||
return None
|
||||
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
|
||||
|
||||
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
|
||||
try:
|
||||
from config import DATA_DIR
|
||||
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||||
if os.path.exists(static_path):
|
||||
df_static = pd.read_csv(static_path, encoding='utf-8')
|
||||
if 'fio' in df_static.columns and 'reason' in df_static.columns:
|
||||
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
|
||||
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
|
||||
|
||||
new_rows = []
|
||||
for _, s_row in df_static.iterrows():
|
||||
if s_row['fio_clean'] not in existing_fios:
|
||||
new_rows.append({
|
||||
'fio_clean': s_row['fio_clean'],
|
||||
'Вид_отсутствия': s_row['reason']
|
||||
})
|
||||
if new_rows:
|
||||
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
|
||||
except Exception as e:
|
||||
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
|
||||
|
||||
return df_absent if not df_absent.empty else None
|
||||
|
||||
|
||||
def load_1c_data_smart(date_str, use_db=False):
|
||||
"""
|
||||
Универсальный загрузчик данных 1С (Штат и Отсутствия).
|
||||
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
|
||||
за целевую дату указанного снапшота.
|
||||
"""
|
||||
df_staff = None
|
||||
df_absent = None
|
||||
|
||||
if use_db:
|
||||
df_staff = load_staff_from_db(date_str)
|
||||
df_absent = load_absences_from_db(date_str)
|
||||
|
||||
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
|
||||
if df_staff is None:
|
||||
df_staff = load_staff_data(date_str)
|
||||
if df_absent is None:
|
||||
df_absent = load_absent_data(date_str)
|
||||
|
||||
return df_staff, df_absent
|
||||
|
||||
|
||||
def process_scud_anomalies(df_scud, exceptions=None):
|
||||
"""
|
||||
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
|
||||
Если 'Начало дня' == 'Нет входа', но есть любая физическая активность —
|
||||
записывает первейшее время в 'Первая_активность', сбрасывает 'Пришел' в False и выставляет флаг аномалии.
|
||||
"""
|
||||
if exceptions is None:
|
||||
exceptions = load_exceptions()
|
||||
@@ -100,7 +150,6 @@ def process_scud_anomalies(df_scud, exceptions=None):
|
||||
val_end = str(row.get(end_col, '')).strip() if end_col else ''
|
||||
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
|
||||
|
||||
# Извлечение первого зафиксированного события из сырых логов (pLogData), если передавалось
|
||||
raw_first_event = row.get('raw_first_event', None)
|
||||
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
|
||||
raw_first_event = None
|
||||
@@ -110,18 +159,13 @@ def process_scud_anomalies(df_scud, exceptions=None):
|
||||
|
||||
first_act = '—'
|
||||
anom_flag = 'NONE'
|
||||
|
||||
# Стандартная проверка присутствия
|
||||
is_present = not ('отсутствовал' in val_status or has_no_in)
|
||||
|
||||
# ЛОГИКА ВЫЧИСЛЕНИЯ ПЕРВОЙ АКТИВНОСТИ И ДЕТЕКТИРОВАНИЯ АНОМАЛИИ
|
||||
if has_no_in:
|
||||
# Если нет входа, но есть проход внутри или зафиксирован выход
|
||||
if raw_first_event is not None or not has_no_out:
|
||||
first_act = str(raw_first_event) if raw_first_event else val_end
|
||||
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
|
||||
|
||||
# ЖЕСТКАЯ ВАЛИДАЦИЯ: Если человек не исключен официально — сбрасываем фейковый Пришел = True
|
||||
if not is_excluded(fio, dept, pos, exceptions):
|
||||
is_present = False
|
||||
else:
|
||||
@@ -145,14 +189,13 @@ def load_scud_data(target_date_type="today", snapshot_param=None):
|
||||
"""
|
||||
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
|
||||
|
||||
# Загружаем данные строго из 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}!")
|
||||
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
|
||||
return pd.DataFrame()
|
||||
|
||||
exceptions = load_exceptions()
|
||||
|
||||
@@ -161,7 +204,6 @@ def load_scud_data(target_date_type="today", snapshot_param=None):
|
||||
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
|
||||
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
|
||||
|
||||
# Нормализация колонок под единый стандарт отчета
|
||||
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:
|
||||
@@ -188,11 +230,8 @@ def load_all_data():
|
||||
except FileNotFoundError:
|
||||
df_scud_yesterday = df_scud_today.copy()
|
||||
|
||||
df_staff_today = load_staff_data(DATE_TODAY)
|
||||
df_absent_today = load_absent_data(DATE_TODAY)
|
||||
|
||||
df_staff_yesterday = load_staff_data(DATE_YESTERDAY)
|
||||
df_absent_yesterday = load_absent_data(DATE_YESTERDAY)
|
||||
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
|
||||
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
|
||||
|
||||
return (
|
||||
df_scud_today,
|
||||
|
||||
@@ -81,11 +81,13 @@ WITH DailyLogs AS (
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
|
||||
),
|
||||
Passages AS (
|
||||
SELECT
|
||||
EmployeeID,
|
||||
MIN(TimeVal) AS FirstRawEvent,
|
||||
MAX(TimeVal) AS LastRawEvent,
|
||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
|
||||
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
|
||||
@@ -116,21 +118,23 @@ SELECT
|
||||
THEN N'Нет выхода'
|
||||
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
|
||||
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
|
||||
ELSE N'Нет выхода'
|
||||
END AS [Конец_дня],
|
||||
CASE
|
||||
WHEN pass.EmployeeID IS NOT NULL AND pass.FirstIn IS NOT NULL THEN
|
||||
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
pass.FirstIn,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.FirstIn)
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
END) / 60 AS VARCHAR), 2) + ':' +
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
pass.FirstIn,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.FirstIn)
|
||||
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
|
||||
END) % 60 AS VARCHAR), 2)
|
||||
ELSE N'00:00'
|
||||
END AS [Находился_в_здании],
|
||||
|
||||
Reference in New Issue
Block a user