готовность к созданию API и базы контекста для ИИ.

This commit is contained in:
2026-08-05 16:29:17 +03:00
parent 0fd145df88
commit 8f611ec41a
12 changed files with 1093 additions and 354 deletions
+28
View File
@@ -0,0 +1,28 @@
# 🗺 Реестр задач проекта: SCUD Orion AI (v2)
## 🟢 Выполненные задачи (Completed Tasks)
* **[TASK-01]** `services/data_loader` — Исправление KeyError по ФИО в Excel экспорте 1С (переход на `.iloc`) *(03.08.2026)* — **ГОТОВО**
* **[TASK-02]** `services/zup_extractor` — Перевод подключения 1С:ЗУП на продакшн MS SQL сервер `ACCOUNT-01` + гибридный фоллбэк *(04.08.2026)* — **ГОТОВО**
* **[TASK-03]** `services/scud_export` — Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов `pLogData` СКУД над расчетом УРВ Ориона *(04.08.2026)* — **ГОТОВО**
* **[TASK-04]** `core/database` — Реализация схемы SQLite WAL, сохранение снапшотов и маппинг колонок (`department`/`position`) *(05.08.2026)* — **ГОТОВО**
* **[TASK-05]** `core/database` — Исправление бага дублирования номеров снапшотов (переход на вычисление `MAX()` по суффиксу) *(05.08.2026)* — **ГОТОВО**
* **[TASK-06]** `scripts/fix_snapshots` — Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом `Y` *(05.08.2026)* — **ГОТОВО**
* **[TASK-07]** `scripts/db_cli` — Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале *(05.08.2026)* — **ГОТОВО**
* **[TASK-08]** `main.py / logic` — Двойная проверка исключений по `department_scud` и 1С, удержание 100% приоритета кадровых документов *(05.08.2026)* — **ГОТОВО**
* **[TASK-09]** `services/text_reporter` — Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (`РУК`, `ОИЗ`, `ПУ` и др.) *(05.08.2026)* — **ГОТОВО**
---
## 🟡 Ближайшие планы и бэклог (In Progress & Backlog v2.1)
* **[TASK-10]** `services/scud_export` — **Внутридневной контроль:** Извлечение полных цепочек проходов `pLogData` (входы/выходы за смену) — **В ПЛАНАХ** [High]
* **[TASK-11]** `services/data_loader` — **Внутридневной контроль:** Расчет количества и суммарной длительности отлучек/перекуров сверх обеда — **В ПЛАНАХ** [High]
* **[TASK-12]** `services/text_reporter` — **Внутридневной контроль:** Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет — **В ПЛАНАХ** [Medium]
* **[TASK-13]** `automation` — Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на **12:00**, **17:00**, **19:00**) — **В ПЛАНАХ** [High]
* **[TASK-14]** `services/notifications` — Интеграция Telegram-бота для алертов Администратору при массовых сбоях турникетов (>5%) — **В ПЛАНАХ** [Medium]
---
## 🔵 Дорожная карта Веб-релиза (Roadmap v3.0)
* **[TASK-15]** `api / fastapi` — Создание REST API на FastAPI (`/api/v1/snapshots`, `/api/v1/scud/logs`, `/api/v1/anomalies`) — **В ПЛАНАХ** [High]
* **[TASK-16]** `frontend / chat` — Веб-интерфейс с ИИ-чатом на базе Ollama (Qwen 2.5 14B) с поддержкой Function Calling к REST API — **В ПЛАНАХ** [High]
* **[TASK-17]** `core/memory` — Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений — **В ПЛАНАХ** [High]
+197 -113
View File
@@ -12,7 +12,7 @@ def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками.""" """Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;") conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;") # Режим быстрого чтения/записи conn.execute("PRAGMA journal_mode = WAL;")
return conn return conn
@@ -21,7 +21,6 @@ def init_db():
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# 1. Таблица СКУД (Добавлены first_activity, anomaly_flag, snapshot_time)
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs ( CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -37,11 +36,16 @@ def init_db():
is_present INTEGER NOT NULL, is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE', anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL, snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
""") """)
# 2. Таблица Штатного расписания 1С cursor.execute("PRAGMA table_info(scud_logs);")
cols = [col[1] for col in cursor.fetchall()]
if 'snapshot_id' not in cols:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff ( CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -54,7 +58,6 @@ def init_db():
); );
""") """)
# 3. Таблица Отсутствий из 1С
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences ( CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -66,7 +69,6 @@ def init_db():
); );
""") """)
# 4. Реестр аномалий
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history ( CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,7 +81,6 @@ def init_db():
); );
""") """)
# 5. База знаний ИИ
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base ( CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -89,79 +90,93 @@ def init_db():
); );
""") """)
# ⚡ ИНДЕКСЫ ДЛЯ УСКОРЕНИЯ ЗАПРОСОВ
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_absences_date ON zup_absences(absence_date);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_staff_date ON zup_staff(snapshot_date);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_anomalies_date ON anomalies_history(anomaly_date);")
conn.commit() conn.commit()
# Автоматически загружаем базовые правила и системный промпт
sync_knowledge_base_to_db() sync_knowledge_base_to_db()
def sync_knowledge_base_to_db(): def has_scud_logs_for_date(date_str):
""" """Проверяет, есть ли в базе данные СКУД за указанную дату."""
Автоматически мигрирует правила из knowledge_base.json и системный промпт в таблицу ai_knowledge_base SQLite. with get_connection() as conn:
""" cursor = conn.cursor()
system_ai_prompt = ( cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
"СИСТЕМНЫЙ ПРОМПТ ИИ (MATCHING, VERIFICATION & ANOMALY DETECTION):\n" return cursor.fetchone() is not None
"Ты — экспертный ИИ-аудитор СКУД и кадрового учета 1С. Твоя задача — проводить интеллектуальный анализ аномалий, "
"выявление массовых сбоев оборудования и сопоставление (Fuzzy Matching) нераспознанных ФИО из СКУД с эталонным Штатным расписанием 1С.\n\n"
"1. ДЕТЕКТИРОВАНИЕ АНОМАЛИЙ И МАССОВЫХ СБОЕВ СКУД:\n"
" - Если у сотрудника 'Начало_дня' = 'Нет входа', но заполнено поле 'Первая_активность' (например, '14:11:06') или 'Конец_дня' != 'Нет выхода', "
"классифицируй случай как одиночную аномалию: '🚨 АНОМАЛИЯ СКУД: Присутствие/активность без утреннего входа'.\n"
" - Если процент сотрудников с подобной аномалией превышает 5% от смены (или >10 человек), СГЕНЕРИРУЙ КРИТИЧЕСКИЙ СИСТЕМНЫЙ АЛЕРТ: "
"'⚠️ ВНИМАНИЕ! Обнаружен массовый сбой регистраторов входа СКУД на главном КПП. Требуется проверка контроллеров.'\n\n"
"2. ЗАЩИТА ОТ ПОЛНЫХ ТЁЗОК (FULL NAME COLLISION):\n"
" - Если в эталонном штате 1С присутствуют два или более сотрудников с ОДИНАКОВЫМИ ФИО (полные тёзки), "
"ты ОБЯЗАН учитывать контекст: Подразделение и Должность.\n"
" - Ни в коем случае не объединяй записи разных людей! Если контекст не позволяет однозначно определить, кто именно из тёзок совершил проход, ПОМЕТЬ СЛУЧАЙ КАК НЕОДНОЗНАЧНЫЙ и НЕ делай автоматическую подмену.\n\n"
"3. ПРАВИЛА СОПОСТАВЛЕНИЯ (MATCHING RULES):\n"
" - Допускаются исправления мелких опечаток, перестановки порядка (Имя Фамилия -> Фамилия Имя), замена букв Е/Ё, И/Й.\n"
" - Учитывай сокращения инициалов (например, 'Иванов И.В.' -> 'Иванов Иван Васильевич').\n"
" - Если уверенность в совпадении меньше 85% — НЕ сопоставляй, оставляй запись как нераспознанную.\n\n"
"4. ФОРМАТ ВЫХОДНЫХ ДАННЫХ:\n"
" Верни строго JSON-объект без вводных слов и Markdown-разметки: {\"mappings\": [...], \"unmatched\": [...], \"alerts\": [...]}"
)
rules_to_insert = [
system_ai_prompt,
"ГЛОБАЛЬНОЕ ПРАВИЛО 1: Любое физическое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (отпуск, больничный, уход за ребенком и др.), КРОМЕ командировок и служебных поездок — ЯВЛЯЕТСЯ СТРОГОЙ ИСТИННОЙ АНОМАЛИЕЙ.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 2: Документы отсутствий из 1С:ЗУП имеют 100% высший приоритет над статическим реестром удалёнщиков.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 3: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией).",
"ГЛОБАЛЬНОЕ ПРАВИЛО 4: Отсутствие утренней отметки входа при наличии дневных перемещений (первая активность) является аномалией оборудования и не должно автоматически превращать запись в подтверждённое присутствие."
]
kb_path = os.path.join(os.path.dirname(DATA_DIR), "knowledge_base.json") def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
if os.path.exists(kb_path): """
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
"""
try:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
if date_str:
try: try:
with open(kb_path, "r", encoding="utf-8") as f: dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
kb_data = json.load(f) if dt_log < dt_snap:
file_rules = kb_data.get("rules", []) is_yesterday = True
for r in file_rules: except Exception:
if r not in rules_to_insert: pass
rules_to_insert.append(r)
except Exception as e: prefix = "Y" if is_yesterday else ""
print(f"[⚠️] Ошибка чтения knowledge_base.json: {e}")
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
for rule in rules_to_insert:
if date_str:
cursor.execute( cursor.execute(
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(rule.strip(), "System_AI") (date_str, snapshot_time)
) )
conn.commit() else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone()
next_seq = 1
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
def save_scud_to_db(df_scud, date_str, snapshot_time=None): def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
"""Оптимизированное сохранение логов СКУД за дату с поддержкой первой активности, флага аномалии и метки выгрузки.""" """Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
if df_scud.empty: if df_scud is None or df_scud.empty:
return return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Передаем date_str в генератор snapshot_id
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = [] data_to_insert = []
for _, r in df_scud.iterrows(): for _, r in df_scud.iterrows():
data_to_insert.append(( data_to_insert.append((
@@ -176,116 +191,185 @@ def save_scud_to_db(df_scud, date_str, snapshot_time=None):
r.get('Находился_в_здании', '00:00'), r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0, 1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'), r.get('anomaly_flag', 'NONE'),
snapshot_time snapshot_time,
snapshot_id
)) ))
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
if snapshot_time: cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
else:
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time IS NULL", (date_str,))
cursor.executemany(""" cursor.executemany("""
INSERT INTO scud_logs ( INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position, log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building, time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time is_present, anomaly_flag, snapshot_time, snapshot_id
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert) """, data_to_insert)
conn.commit() conn.commit()
def get_latest_snapshot_time(date_str=None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
"""
Загружает логи СКУД по ID снапшота ('20260805-001', 'Y20260805-001') или по штампу времени.
С автоматическим маппингом полей Подразделения и Должности.
"""
with get_connection() as conn:
df = pd.DataFrame()
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:
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)
)
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']
# 💡 Маппинг колонок БД в стандартные русские наименования
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']
else:
df = pd.DataFrame(columns=[
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
'Начало_дня', 'Первая_активность', 'Конец_дня',
'Находился_в_здании', 'Пришел', 'anomaly_flag'
])
return df
def get_available_snapshots(date_str=None):
"""
Возвращает список снапшотов.
Сортировка по времени snapshot_time DESC.
"""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""", (date_str,))
else:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""")
return cursor.fetchall()
def save_staff_to_db(df_staff, date_str): def save_staff_to_db(df_staff, date_str):
"""Сохраняет выгрузку Штатного расписания 1С в БД."""
if df_staff is None or df_staff.empty: if df_staff is None or df_staff.empty:
return return
data_to_insert = [
data_to_insert = [] (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows(): for _, r in df_staff.iterrows()
data_to_insert.append(( ]
date_str,
r.get('ФИО', ''),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', '')
))
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,)) cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany(""" cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position)
VALUES (?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit() conn.commit()
def save_absences_to_db(df_absent, date_str): def save_absences_to_db(df_absent, date_str):
"""Оптимизированное сохранение отсутствий из 1С в БД."""
if df_absent is None or df_absent.empty: if df_absent is None or df_absent.empty:
return return
data_to_insert = [
data_to_insert = [] (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows(): for _, r in df_absent.iterrows()
data_to_insert.append(( ]
date_str,
r.get('ФИО', r.get('fio_clean', '')),
r.get('fio_clean', ''),
r.get('Вид_отсутствия', '')
))
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany(""" cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type)
VALUES (?, ?, ?, ?)
""", data_to_insert)
conn.commit() conn.commit()
def save_anomalies_to_db(anomalies_list, date_str): def save_anomalies_to_db(anomalies_list, date_str):
"""Записывает обнаруженные аномалии за дату."""
if not anomalies_list: if not anomalies_list:
return return
data_to_insert = [ data_to_insert = [
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) (date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
for a in anomalies_list for a in anomalies_list
] ]
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany(""" cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details)
VALUES (?, ?, ?, ?)
""", data_to_insert)
conn.commit() conn.commit()
def get_all_rules_from_db(): def get_all_rules_from_db():
"""Возвращает список всех правил для ИИ из базы данных."""
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base") cursor.execute("SELECT rule_text FROM ai_knowledge_base")
rows = cursor.fetchall() return [r[0] for r in cursor.fetchall()]
return [r[0] for r in rows]
def add_rule_to_db(rule_text, added_by="Human"): def add_rule_to_db(rule_text, added_by="Human"):
"""Добавляет новое динамическое правило в базу данных."""
if not rule_text or not rule_text.strip(): if not rule_text or not rule_text.strip():
return return
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute( cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)",
(rule_text.strip(), added_by)
)
conn.commit() conn.commit()
except Exception as e: except Exception as e:
print(f"[⚠️] Ошибка записи правила в БД: {e}") print(f"[⚠️] Ошибка записи правила в БД: {e}")
def sync_knowledge_base_to_db():
pass
Binary file not shown.
+208 -71
View File
@@ -1,5 +1,6 @@
import os import os
import sys import sys
import json
import argparse import argparse
import pandas as pd import pandas as pd
from datetime import datetime from datetime import datetime
@@ -21,10 +22,59 @@ from core.database import (
save_scud_to_db, save_scud_to_db,
save_staff_to_db, save_staff_to_db,
save_absences_to_db, save_absences_to_db,
save_anomalies_to_db save_anomalies_to_db,
load_scud_from_db_by_snapshot,
get_latest_snapshot_time,
has_scud_logs_for_date
) )
def load_exceptions_config():
"""Загружает файл exceptions.json из корня проекта."""
root_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(root_dir, "exceptions.json")
if not os.path.exists(json_path):
return {}
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
def apply_exceptions_from_json(df, exceptions_cfg):
"""
Размечает флаг is_excluded=True на основе правил из exceptions.json.
Сверяет отдел одновременно по полному имени из 1С и по аббревиатуре из СКУД.
"""
if df is None or df.empty or not exceptions_cfg:
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", [])]
df['is_excluded'] = False
for idx, row in df.iterrows():
fio_clean = row.get('fio_clean', '')
dep_1c = str(row.get('Подразделение', '')).lower()
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
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
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return 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")
@@ -41,9 +91,7 @@ def load_static_reason_workers():
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
""" """Автоматически находит ИСТИННЫЕ аномалии."""
Автоматически находит ИСТИННЫЕ аномалии.
"""
anomalies = [] anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
@@ -54,7 +102,7 @@ def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules):
fio_clean = row.get('fio_clean', '') fio_clean = row.get('fio_clean', '')
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip() reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and reason_1c != 'Исключение (ОВК/Подрядчики)'
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
@@ -105,37 +153,58 @@ def aggregate_scud_by_employee(df, debug=False):
def main(): def main():
parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") help_text = """
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки") Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL")
parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/")
args = parser.parse_args()
ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета на момент ПОСЛЕДНЕГО имеющегося снапшота из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по составному ID снапшота
python main.py --snapshot Y20260805-001 -- Расчет отчета по вчерашнему фиксированному снапшоту
python main.py --snapshot "2026-08-05 09:23:31" -- Расчет отчета по точной метке времени создания
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет на момент последнего имеющегося снапшота из SQLite")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID (например, '20260805-001', 'Y20260805-001') или время создания конкретного снапшота")
parser.add_argument('--with-xlsx', action='store_true', help="Сохранять дублирующие сырые XLSX-файлы в папке data/")
args = parser.parse_args()
DEBUG = args.debug DEBUG = args.debug
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print("=" * 60) print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60) print("=" * 60)
# 0. Инициализация локальной базы данных SQLite и Базы Знаний
init_db() init_db()
kb_data = load_knowledge_base() kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", []) kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
# 0.1 Выгрузка из БД СКУД напрямую в SQLite и Excel if args.snapshot:
if not args.skip_export: snapshot_param = args.snapshot
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export:
snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем самый последний снапшот из SQLite ('{snapshot_param}')")
else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
if not args.skip_export and not args.snapshot:
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] Пропуск прямого экспорта из БД (--skip-export)...") print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
# 0.5 Копирование файлов 1С с сетевой шары /mnt/scud_share
copy_1c_files_from_share() copy_1c_files_from_share()
# 1. Валидация наличия файлов и данных
print("[1/5] Проверка актуальности и свежести входных данных...") print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness() is_valid, warnings, errors, has_today_1c = check_file_freshness()
@@ -155,18 +224,27 @@ def main():
print("[✓] Проверка доступности данных успешно пройдена!\n") print("[✓] Проверка доступности данных успешно пройдена!\n")
# 2. Раздельная загрузка данных за Сегодня и за Вчера
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
(
raw_scud_today_df,
raw_scud_yesterday_df,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
) = load_all_data()
# 💾 СОХРАНЕНИЕ КАДРОВЫХ СРЕЗОВ 1С В БАЗУ ДАННЫХ SQLite 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)
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()
else:
(
raw_scud_today_df, raw_scud_yesterday_df,
df_staff_today, df_absent_today,
df_staff_yesterday, df_absent_yesterday
) = load_all_data()
if df_staff_yesterday is not None: if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY) save_staff_to_db(df_staff_yesterday, DATE_YESTERDAY)
if df_absent_yesterday is not None: if df_absent_yesterday is not None:
@@ -181,63 +259,99 @@ def main():
static_reasons_dict = load_static_reason_workers() static_reasons_dict = load_static_reason_workers()
# ============================================================ # ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (Строится Всегда) # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА
# ============================================================ # ============================================================
print("\n[3/5] Обработка данных за ВЧЕРА...") if has_scud_logs_for_date(DATE_YESTERDAY) and not args.snapshot:
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else [] print(f"\n[3/5] Вчерашние данные за {DATE_YESTERDAY} уже обработаны и зафиксированы в SQLite. Повторный расчет пропущен.")
else:
print("\n[3/5] Обработка данных за ВЧЕРА...")
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
if fio_mapping_scud_y: 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['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
if df_absent_yesterday is not None and not df_absent_yesterday.empty: scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday: fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean) if fio_mapping_scud_y:
if fio_mapping_absent_y: raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply( lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x )
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
merged_yesterday = df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame()
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
) )
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) # 1. ПРИОРИТЕТ 1: 1С-документ
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
merged_yesterday = raw_scud_yesterday_df[raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)].copy() if 'Пришел' not in merged_yesterday.columns:
if df_absent_yesterday is not None: merged_yesterday['Пришел'] = False
merged_yesterday = merged_yesterday.merge(df_absent_yesterday, on='fio_clean', how='left') else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if static_reasons_dict: if 'Сотрудник' not in merged_yesterday.columns:
for fio_clean, reason_val in static_reasons_dict.items(): merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
mask_exc_yesterday = ( # 2. ПРИОРИТЕТ 2: Реестр статических причин
(merged_yesterday['Пришел'] == False) & if static_reasons_dict:
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & for fio_clean, reason_val in static_reasons_dict.items():
(merged_yesterday.get('is_excluded', False) == True) mask_yesterday = (
) (merged_yesterday['Пришел'] == False) &
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY) # 3. ПРИОРИТЕТ 3: Исключения из exceptions.json (ТОЛЬКО при пустом 1С)
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
# 💾 СОХРАНЕНИЕ ВЧЕРАШНИХ ДАННЫХ В SQLite: generate_detailed_excel(merged_df=merged_yesterday, date_str=DATE_YESTERDAY)
save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=snapshot_time)
save_scud_to_db(merged_yesterday, DATE_YESTERDAY, snapshot_time=snapshot_param if not args.snapshot and not args.skip_export else None, is_yesterday=True)
# ============================================================ # ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА ЗА СЕГОДНЯ
# ============================================================ # ============================================================
save_scud_to_db(raw_scud_today_df, DATE_TODAY, snapshot_time=snapshot_time) 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)
if has_today_1c and df_staff_today is not None and df_absent_today is not None: if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print("\n[4/5] Обработка данных за СЕГОДНЯ...") print("\n[4/5] Обработка данных за СЕГОДНЯ...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist() staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean) fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud: if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
@@ -254,9 +368,33 @@ def main():
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = raw_scud_today_df[raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)].copy() merged_today = df_staff_today.copy()
merged_today = merged_today.merge(df_absent_today, on='fio_clean', how='left') if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
# 1. ПРИОРИТЕТ 1: 1С-документы
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
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 = (
@@ -266,6 +404,8 @@ 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)
mask_exc_today = ( mask_exc_today = (
(merged_today['Пришел'] == False) & (merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
@@ -275,7 +415,6 @@ def main():
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules) anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules)
# ⭐️ ПРОВЕРКА МАССОВОГО СБОЯ ОБОРУДОВАНИЯ ЧЕРЕЗ ИИ
mass_failure_result = analyze_scud_mass_failure_ai(merged_today) mass_failure_result = analyze_scud_mass_failure_ai(merged_today)
if mass_failure_result and mass_failure_result.get("is_mass_failure"): if mass_failure_result and mass_failure_result.get("is_mass_failure"):
print("\n" + "!" * 60) print("\n" + "!" * 60)
@@ -283,7 +422,6 @@ def main():
print(mass_failure_result["alert_text"]) print(mass_failure_result["alert_text"])
print("!" * 60 + "\n") print("!" * 60 + "\n")
# 💾 СОХРАНЕНИЕ СЕГОДНЯШНИХ АНОМАЛИЙ В БАЗУ ДАННЫХ SQLite
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_explained = merged_today[(merged_today['Пришел'] == False) & (merged_today['Вид_отсутствия'].notna())]
@@ -326,7 +464,6 @@ def main():
print(report_text) print(report_text)
else: else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.") print(f"\n[ℹ️] Формирование Ежедневной сводки за {DATE_TODAY} ПРОПУЩЕНО.")
print(" Ждём появления файлов 1С за текущую дату на сетевой шаре.")
if __name__ == "__main__": if __name__ == "__main__":
+252
View File
@@ -0,0 +1,252 @@
О’кей хорошо! Ниже представлена полностью объединенная и систематизированная история развития проекта **`scud_orion_ai_v2`**. В нее вошел весь путь: от стартовых инцидентов с файлами 1С и отладкой MS SQL Ориона до текущей архитектуры со снапшотами, двойной фильтрацией исключений и SQLite-базой знаний.
---
# 🚀 ПОЛНАЯ ИСТОРИЯ РАЗВИТИЯ И СНАПШОТ ПРОЕКТА: SCUD ORION AI (v2)
**Дата актуализации:** 05.08.2026
**Статус проекта:** Производственный релиз (*Production-Ready*) с поддержкой снапшотов, двойной фильтрацией исключений, лаконичным форматированием и синхронизацией SQLite.
**Общий объем кода:** ~2 620 строк Python (17 модулей + CLI-инструментарий)
---
## 📌 1. НАЗНАЧЕНИЕ И АРХИТЕКТУРА СИСТЕМЫ
Проект **`scud_orion_ai_v2`** — это система автоматизированного кадрового контроллинга и аудита трудовой дисциплины. Система сопоставляет логи физических проходов СКУД «Орион Pro» (MS SQL Server) с кадровыми данными и документами отсутствия из 1С:ЗУП 3.1 (MS SQL Server / Сетевая шара), а также выполняет квалификацию отклонений с помощью локальной LLM (Ollama / Qwen 2.5 14B).
### 💻 Ключевой стек технологий:
* **Язык разработки:** Python 3.12 (в изолированном `venv`).
* **СУБД СКУД (Источник):** MS SQL Server (`Orion-14.01.21-1` на хосте `172.16.31.221\SQL`).
* **СУБД 1С (Источник):** MS SQL Server (`ACCOUNT-01`, БД `zup_db`).
* **Фоллбэк/Обмен:** Сетевая Linux-шара (`/mnt/scud_share`).
* **Локальная СУБД (Проект):** SQLite (`data/scud_orion_ai.db` с поддержкой WAL-режима и истории снапшотов).
* **AI Engine:** Ollama API (`http://192.168.11.3:11434/api/generate`, модель `qwen2.5:14b`).
* **CLI & Репорты:** `pandas`, `openpyxl`, `pyodbc`, `sqlite3`.
---
## 📜 2. ПОЛНАЯ ХРОНОЛОГИЯ ИНЦИДЕНТОВ И РЕШЕНИЙ
### 🔹 Инцидент 1: Ошибка распарсивания Excel-файлов Штатного расписания 1С
* **Симптом:** `KeyError: 'ФИО'` при запуске `main.py` на этапе загрузки штата из `/data/1c/Штат_DD_MM_YYYY.xlsx`.
* **Причина:** Файлы экспорта 1С выгружаются со служебным заголовком. При `skiprows=7` датафрейм воспринимал строку с именами колонок как данные, присваивая столбцам имена `Unnamed: 0`, `Unnamed: 1`.
* **Решение:** Перешли от обращения по текстовым именам колонок к позиционному связыванию через `.iloc[:, [1, 5, 12]]` (индексы: `1` — ФИО, `5` — Подразделение, `12` — Должность) со сдвигом `skiprows=8`.
### 🔹 Инцидент 2: Падение сетевого подключения к MS SQL Server 1С (`Login timeout expired`)
* **Симптом:** Соединение к MS SQL падало по таймауту (`HYT00`), приводя к отсутствию таблицы `zup_absences` в памяти и `KeyError: 'Вид_отсутствия'`.
* **Причина:** В модуле `zup_extractor.py` находились дефолтные тестовые параметры подключения (`192.168.1.10`) вместо продакшн-конфигурации.
* **Решение:**
* Перевели `zup_extractor.py` на прямой импорт параметров из `config.py` (сервер `ACCOUNT-01`).
* В `services/data_loader.py` реализован гибридный фоллбэк: при недоступности MS SQL 1С система автоматически считывает локальные файлы `Отсутствия_DD_MM_YYYY.xlsx` из папки `data/1c/` (копируемые с шары `/mnt/scud_share`).
### 🔹 Инцидент 3: Аномалия УРВ «Орион Pro» (Случай сотрудника Равина В.Э.)
* **Симптом:** В выгрузке `scud_export.py` сотрудник числился присутствовавшим (вход в 14:11:06), а в отчете УРВ «Орион Pro» стоял статус «Нет входа» / 0:00 отработанных часов.
* **Расследование в SSMS:** Был установлен истинный системный ключ в базе Ориона: `pList.ID = 2107` (`pList.TabNumber = 13058`, а связка логов идет по `pLogData.HozOrgan = pList.ID`). Прямой запрос к сырым логам `pLogData` вернул события: `13:51:15` — Выход (Event 28/32), `14:11:06` — Вход (Event 28/32).
* **Причина расхождения:** День сотрудника начался с фиксации Выхода в 13:51 (приход утром без прикладывания карты). Модуль УРВ «Ориона» не смог корректно сопоставить смену, начинающуюся с выхода, и обнулил табель.
* **Вывод для системы:** Первичные физические логи `pLogData` (события 28/32) обладают 100% приоритетом над расчетным модулем УРВ Ориона.
### 🔹 Инцидент 4: Ошибка нулевых показателей при `--skip-export`
* **Причина:** В запросе к SQLite использовалось жесткое равенство по метке времени `snapshot_time`. Если секунды среза отличались, запрос возвращал 0 строк.
* **Решение:** Введены составные ID снапшотов (`20260805-001`, `Y20260805-001`) и ступенчатый fallback-механизм в `core/database.py`: если точного времени нет, подтягивается самый свежий имеющийся срез за указанную дату.
### 🔹 Инцидент 5: Потеря названий отделов и должностей (`—`, `—`)
* **Причина:** Колонки в базе SQLite назывались `department` и `position`, а отчет требовал `Подразделение` и `Должность`.
* **Решение:** В `core/database.py` добавлен автоматический маппинг колонок при считывании из SQLite, а в `main.py` — подтягивание метаданных из 1С:Штат.
### 🔹 Инцидент 6: Раздутый список «Неизвестных случаев» (21 чел $\rightarrow$ 11 чел)
* **Причина:** В 1С отделы записаны полными именами (*«Отдел внутреннего контроля»*), а в `exceptions.json` — аббревиатурами (*«ОВК»*). Из-за этого контролеры ОВК, клининг и подрядчики не находились алгоритмом и попадали в неизвестные.
* **Решение:** Реализована двойная проверка в `main.py`: при объединении данных сохраняется родная аббревиатура из MS SQL СКУД (`department_scud`). Функция `apply_exceptions_from_json` проводит поиск и по имени 1С, и по короткой аббревиатуре СКУД.
---
## 🛡️ 3. АРХИТЕКТУРА БЕЗОПАСНОСТИ И ПРИОРИТЕТОВ
### 👥 Защита от «Полных Тёзок» (Full Name Collision)
1. **Уровень 1 (БД & SQL):** Использование строго уникальных идентификаторов (`pList.ID` в СКУД, GUID/Табельный номер в 1С).
2. **Уровень 2 (Составные ключи):** При мёрдже без прямых ID применяется контекстный ключ:
$$ ext{Composite Key} = ext{fio\_clean} + ext{"\_"} + ext{department} + ext{"\_"} + ext{position}$$
3. **Уровень 3 (AI-Валидатор):** Системный промпт LLM запрещает объединять записи полных тёзок при разнице в отделе или должности.
### ⚙️ Иерархия приоритетов статуса сотрудника:
<div style="text-align:center; margin:1em 0; font-size:1.05em;">
<span class="math">Status = </span>
<span class="math">
 Пришел (по СКУД), если есть вход или активность<br/>
 Документ 1С:ЗУП (Отпуск, Больничный...), высший приоритет над исключениями<br/>
 Причина из static_reason_workers.csv, если указан удаленщик<br/>
 Исключение (ОВК/Подрядчики), если подпадает под exceptions.json и в 1С ПУСТО<br/>
 Неизвестно (Истинно неотмеченный), если офисный сотрудник без проходов и без 1С
</span>
</div>
---
## 📁 4. СТРУКТУРА КАТАЛОГОВ И ИСПРАВЛЕННЫХ МОДУЛЕЙ
```text
scud_orion_ai_v2/
├── config.py # Глобальная конфигурация, пути, параметры SQL и LLM (Ollama)
├── main.py # Главный оркестратор пайплайна (СКУД ⟷ 1С, ИИ-аудит, Excel/MD)
├── exceptions.json # Исключения (подрядчики, ОВК, клининг, списки ФИО)
├── core/
│ ├── __init__.py
│ └── database.py # Схема SQLite, таблицы scud_logs, zup_staff, ai_knowledge_base
├── services/
│ ├── __init__.py
│ ├── scud_export.py # Выгрузка СКУД из MS SQL Орион Pro + расчет первой активности
│ ├── zup_extractor.py # Выгрузка отсутствий из MS SQL 1С:ЗУП 3.1
│ ├── share_copier.py # Копирование XLSX файлов с шары /mnt/scud_share
│ ├── data_loader.py # Загрузка, очистка и маркировка аномалий СКУД/1С
│ ├── data_validator.py # Проверка свежести входных файлов
│ ├── excel_exporter.py # Формирование Excel-сводок и детальных отчетов (.xlsx)
│ ├── ai_verifier.py # Взаимодействие с Ollama (Qwen 2.5): сверка ФИО и сбои
│ ├── text_reporter.py # Генерация итогового Markdown-отчета (.md)
│ ├── feedback_loop.py # Контур обучения ИИ (Human-in-the-Loop)
│ └── knowledge_base.py # Загрузка и пополнение Базы Знаний ИИ через SQLite
├── scripts/
│ ├── __init__.py
│ ├── migrate_db.py # Мигратор схемы SQLite БД
│ └── db_cli.py # CLI утилита управления БД, снапшотами и экспорта в Excel
├── data/ # Локальная SQLite БД, выгрузки 1С, СКУД
│ ├── scud_orion_ai.db
│ └── static_reason_workers.csv
└── output/ # Готовые сводки и отчеты (.xlsx, .md)
```
---
## 🛠 5. СПРАВОЧНИК КОМАНД И ЗАПУСКА
### 1. Главный скрипт (`main.py`)
```bash
python main.py [ФЛАГИ]
```
| Команда / Флаг | Описание |
| :--- | :--- |
| `python main.py` | Обычный полный дневной запуск |
| `python main.py --skip-export` | Расчет по последнему имеющемуся снапшоту из SQLite |
| `python main.py --snapshot 20260805-001` | Расчет строго по ID конкретного снапшота |
| `python main.py -d` | Режим расширенной отладки (DEBUG) |
---
### 2. CLI-утилита инспекции БД (`scripts/db_cli.py`)
> Прототип Сервисного Слоя для будущего REST API.
```bash
python scripts/db_cli.py <КОМАНДА> [ПАРАМЕТР] [ФЛАГИ]
```
| Команда | Описание |
| :--- | :--- |
| `python scripts/db_cli.py scud` | Просмотр логов СКУД за сегодня |
| `python scripts/db_cli.py scud --snapshot 20260805-001` | Просмотр логов конкретного снапшота |
| `python scripts/db_cli.py scud --export-xlsx debug_today` | Экспорт среза СКУД в Excel |
| `python scripts/db_cli.py snapshots` | Список всех снапшотов |
| `python scripts/db_cli.py anomalies` | История найденных аномалий |
| `python scripts/db_cli.py rules` | Правила Базы Знаний ИИ из SQLite |
| `python scripts/db_cli.py dump [output.xlsx]` | Полный дамп всех таблиц БД в Excel |
---
📐 Сквозной цикл работы контроллинга SCUD Orion AI (v2)
🔄 Схема движения данных
┌────────────────────────────────────────┐
│ Входные CLI-параметры main.py │
│ (--skip-export / --snapshot / --debug) │
└───────────────────┬────────────────────┘
│
┌────────────────────────────┴───────────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ 0. MS SQL Орион Pro (СКУД) │ │ 0.5 Сетевая шара /mnt/scud_share │
│ scud_export.py (Первая акт.) │ │ share_copier.py (Штат и Отсут.) │
└──────────────┬────────────────┘ └─────────────────┬─────────────────┘
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ SQLite: scud_logs │ │ data/1c/Штат & Отсутствия │
│ (Сохранение snapshot_id) │ │ (Файлы Excel за сегодня/вчера) │
└──────────────┬────────────────┘ └─────────────────┬─────────────────┘
│ │
└───────────────────────┬───────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 1. data_validator.py │
│ (Проверка свежести и остановок) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 2. data_loader.py │
│ (Фоллбэки + Загрузка + Scud Dept) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 3. ИИ-Арбитраж & SQLite KB │
│ (Qwen 2.5 + ai_knowledge_base) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 4. Иерархическое сведение │
│ (1С -> CSV -> exceptions.json) │
└─────────────────┬─────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Данные за ВЧЕРА │ │ Данные за СЕГОДНЯ │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Detailed Excel (.xlsx) │ │ Summary Excel (.xlsx) │
│ Детальный разбор вчера │ │ + Markdown сводка (.md) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────┐
│ 5. feedback_loop.py │
│ ( Human-in-the-Loop обучение ИИ ) │
└─────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ SQLite: scud_orion_ai.db │
│ (Фиксация логов, аномалий и KB) │
└───────────────────────────────────┘
🛠 Подробное описание каждого этапа
Этап Модуль Описание и выполняемые задачи
0. СКУД Экспорт services/scud_export.py Запрос к MS SQL «Орион Pro», вычисление утреннего входа и первой активности (first_activity), запись в SQLite с присвоением snapshot_id.
0.5. Копирование 1С services/share_copier.py Забор актуальных Excel-файлов Штатного расписания и Отсутствий из Linux-шары /mnt/scud_share.
1. Валидация services/data_validator.py Проверка наличия кадровых данных за вчерашний день. При критическом отсутствии файлов блокирует расчет.
2. Загрузка services/data_loader.py Загрузка датасетов, нормализация ФИО и проброс родных коротких аббревиатур отделов из СКУД (department_scud).
3. ИИ-Аудит services/ai_verifier.py Запрос к локальной LLM Ollama (Qwen 2.5) с подгрузкой контекста правил из SQLite-таблицы ai_knowledge_base. Исправление опечаток ФИО и отслеживание сбоев турникетов (>5%).
4. Сведение main.py Применение иерархии приоритетов: 1С:ЗУП → static_csv → exceptions.json (проверка по 1С и department_scud) → Неизвестные.
5. Отчетность & Обучение
excel_exporter.py
text_reporter.py
feedback_loop.py
Формирование Excel-файлов, сборка текста Markdown с аббревиатурами и запись вердиктов кадровика в SQLite.
## 🔮 6. БУДУЩИЕ ТОЧКИ РОСТА (ДОРОЖНАЯ КАРТА ВЕБ-РЕЛИЗА)
1. **REST API на FastAPI:** Перенос функций из `db_cli.py` в эндпоинты (`/api/v1/snapshots`, `/api/v1/scud/logs`, `/api/v1/anomalies`).
2. **ИИ-Чат с поддержкой Function Calling:** Предоставление Ollama (Qwen 2.5) права прямого вызова эндпоинтов API из чата по запросу пользователя (*«Сформируй срез за 12:00 и экспортируй в Excel»*).
3. **Telegram-уведомления:** Автоматическая отправка алертов администраторам при выявлении массовых сбоев турникетов (>5%).
+193 -108
View File
@@ -1,127 +1,212 @@
import sys
import os import os
import sys
import argparse
import sqlite3 import sqlite3
import pandas as pd import pandas as pd
from datetime import datetime
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
sys.path.append(PROJECT_ROOT)
from config import DATA_DIR from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR
from core.database import (
get_connection,
get_available_snapshots,
get_all_rules_from_db,
load_scud_from_db_by_snapshot,
get_latest_snapshot_time
)
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def query_db(sql, params=()): def print_stats():
"""Выполняет SQL-запрос к локальной БД и возвращает результат в виде DataFrame.""" """Выводит общую статистику по записям в таблицах БД."""
if not os.path.exists(DB_PATH): print("\n" + "=" * 60)
print(f"❌ База данных не найдена: {DB_PATH}") print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ SQLITE (scud_orion_ai.db):")
return None print("=" * 60)
try: with get_connection() as conn:
conn = sqlite3.connect(DB_PATH) cursor = conn.cursor()
df = pd.read_sql_query(sql, conn, params=params) tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']
conn.close() for t in tables:
return df cursor.execute(f"SELECT COUNT(*) FROM {t}")
except Exception as e: cnt = cursor.fetchone()[0]
print(f"❌ Ошибка выполнения запроса: {e}") print(f" • Таблица [{t:<18}]: {cnt:>6} записей")
return None print("=" * 60 + "\n")
def print_db_stats(): def print_snapshots_list(date_str=None):
"""Выводит статистику по количеству записей во всех таблицах базы данных.""" """Выводит реестр снапшотов с идеальным вертикальным выравниванием дефисов и цифр."""
if not os.path.exists(DB_PATH): rows = get_available_snapshots(date_str)
print(f"❌ База данных не найдена: {DB_PATH}")
print("\n" + "=" * 95)
print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:")
print("=" * 95)
header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата среза':<12} | {'Записей':<8}"
print(header)
print("-" * 95)
if not rows:
print("Снапшотов пока нет.")
print("=" * 95 + "\n")
return return
conn = sqlite3.connect(DB_PATH) # Внутрипитоновская хронологическая сортировка: сначала snap_time DESC, затем seq_num DESC
cursor = conn.cursor() def snapshot_sort_key(row):
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") snap_id = row[0] or ""
tables = [t[0] for t in cursor.fetchall() if t[0] != 'sqlite_sequence'] snap_time = row[2] or ""
print("\n" + "=" * 55) seq_num = 0
print(f"📊 СТАТИСТИКА ЛОКАЛЬНОЙ БАЗЫ ДАННЫХ: {DB_PATH}") if "-" in snap_id:
print("=" * 55) parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1])
for t in tables: return (snap_time, seq_num)
cursor.execute(f"SELECT COUNT(*) FROM {t}")
count = cursor.fetchone()[0]
print(f" • {t:<22} : {count} строк")
conn.close() sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
print("=" * 55 + "\n")
for r in sorted_rows:
snap_id = r[0] if r[0] else '----------'
log_date = r[1] if r[1] else '—'
snap_time = r[2] if r[2] else '—'
count = r[3]
# 💡 ИДЕАЛЬНОЕ ВЫРАВНИВАНИЕ: если нет префикса Y, добавляем ведущий пробел
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {log_date:<12} | {count:<8}")
print("=" * 95 + "\n")
def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 90)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty:
print(f"Записи СКУД не найдены.")
print("=" * 90 + "\n")
return
total = len(df)
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 90)
cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id']
existing_cols = [c for c in cols_to_show if c in df.columns]
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.")
if export_xlsx:
out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx"
if not os.path.isabs(out_path):
out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False)
print("\n" + "*" * 90)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 90)
print("=" * 90 + "\n")
def print_anomalies():
"""Выводит список аномалий СКУД из БД."""
print("\n" + "=" * 80)
print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
print("=" * 80)
with get_connection() as conn:
df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn)
if df.empty:
print("Аномалии не найдены.")
else:
print(df.to_string(index=False))
print("=" * 80 + "\n")
def print_rules():
"""Выводит правила базы знаний ИИ."""
rules = get_all_rules_from_db()
print("\n" + "=" * 80)
print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:")
print("=" * 80)
if not rules:
print("База знаний пуста.")
else:
for idx, r in enumerate(rules, 1):
print(f" {idx}. {r}")
print("=" * 80 + "\n")
def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
"""Дампит всю базу SQLite во многостраничный Excel."""
out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def main():
help_text = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py scud -- Выгрузка СКУД за текущую дату (последний снапшот)
python scripts/db_cli.py scud --snapshot 20260805-001 -- Выгрузка СКУД строго по ID конкретного снапшота
python scripts/db_cli.py scud --export-xlsx debug_today -- Выгрузить срез СКУД в форматированный Excel-файл
python scripts/db_cli.py snapshots -- Посмотреть список всех снапшотов с их ID (от новых к старым)
python scripts/db_cli.py snapshots 04.08.2026 -- Посмотреть снапшоты за конкретную дату
python scripts/db_cli.py stats -- Общая статистика строк по всем таблицам БД
python scripts/db_cli.py anomalies -- Посмотреть все найденные аномалии СКУД ⟷ 1С
python scripts/db_cli.py rules -- Посмотреть правила Базы Знаний ИИ из SQLite
python scripts/db_cli.py dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('command', nargs='?', default='stats', choices=['stats', 'snapshots', 'scud', 'anomalies', 'rules', 'dump'], help="Команда: stats | snapshots | scud | anomalies | rules | dump")
parser.add_argument('param', nargs='?', default=None, help="Параметр команды (дата в формате ДД.ММ.ГГГГ или имя файла)")
parser.add_argument('--snapshot', type=str, default=None, help="Выбрать конкретный ID снапшота для инспекции (например, '20260805-001', 'Y20260805-001')")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспортировать выбранный срез СКУД в Excel-файл")
args = parser.parse_args()
if args.command == 'stats':
print_stats()
elif args.command == 'snapshots':
print_snapshots_list(date_str=args.param)
elif args.command == 'scud':
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
elif args.command == 'anomalies':
print_anomalies()
elif args.command == 'rules':
print_rules()
elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename)
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: main()
print("\nИспользование CLI-модуля базы данных:")
print(" python scripts/db_cli.py stats -- Посмотреть статистику таблиц БД")
print(" python scripts/db_cli.py anomalies -- Просмотреть все обнаруженные аномалии")
print(" python scripts/db_cli.py rules -- Просмотреть все правила Базы Знаний ИИ")
print(" python scripts/db_cli.py scud 03.08.2026 -- Посмотреть логи СКУД за конкретную дату")
print(" python scripts/db_cli.py staff 03.08.2026 -- Посмотреть Штатное расписание за дату")
print(" python scripts/db_cli.py absences 03.08.2026 -- Посмотреть отсутствия из 1С за дату")
print(" python scripts/db_cli.py dump [filename.xlsx] -- Дамп всей базы SQLite в Excel-файл")
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == "stats":
print_db_stats()
elif cmd == "anomalies":
df = query_db("SELECT * FROM anomalies_history ORDER BY id DESC")
if df is not None:
print("\n🚨 РЕЕСТР НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
print(df.to_string(index=False) if not df.empty else "Аномалий в базе пока нет.")
elif cmd == "rules":
df = query_db("SELECT id, added_by, created_at, rule_text FROM ai_knowledge_base ORDER BY id ASC")
if df is not None and not df.empty:
print("\n🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ (ОБУЧЕНИЕ):")
print("=" * 80)
for _, row in df.iterrows():
print(f"📌 [ID: {row['id']}] | Добавил: {row['added_by']} | Дата: {row['created_at']}")
print(f"{row['rule_text']}")
print("-" * 80)
else:
print("\n🧠 База знаний пуста.")
elif cmd == "scud" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM scud_logs WHERE log_date = ?", (date_str,))
if df is not None:
print(f"\n🚪 ЛОГИ СКУД ЗА {date_str} ({len(df)} записей):")
print(df.head(20).to_string(index=False))
elif cmd == "staff" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM zup_staff WHERE snapshot_date = ?", (date_str,))
if df is not None:
print(f"\n📋 ШТАТНОЕ РАСПИСАНИЕ 1С ЗА {date_str} ({len(df)} сотрудников):")
print(df.head(20).to_string(index=False))
elif cmd == "absences" and len(sys.argv) > 2:
date_str = sys.argv[2]
df = query_db("SELECT * FROM zup_absences WHERE absence_date = ?", (date_str,))
if df is not None:
print(f"\n🏥 ДОКУМЕНТЫ ОТСУТСТВИЙ 1С ЗА {date_str} ({len(df)} записей):")
print(df.to_string(index=False) if not df.empty else f"Отсутствий за {date_str} не зарегистрировано.")
elif cmd == "dump":
out_path = sys.argv[2] if len(sys.argv) > 2 else "db_dump.xlsx"
if not os.path.exists(DB_PATH):
print(f"❌ Файл базы {DB_PATH} не найден.")
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
tables = ["scud_logs", "zup_staff", "zup_absences", "anomalies_history", "ai_knowledge_base"]
with pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in tables:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table, index=False)
conn.close()
print(f"✅ Вся база данных успешно экспортирована в Excel-файл: {out_path}")
else:
print(f"❌ Неизвестная команда: {cmd}")
+90
View File
@@ -0,0 +1,90 @@
import sqlite3
import os
from datetime import datetime
DB_PATH = 'data/scud_orion_ai.db'
def parse_to_date(date_str):
"""Надежное преобразование даты логов и времени снапшота в объект date."""
if not date_str:
return None
date_str = str(date_str).split(" ")[0].strip()
if "." in date_str:
try:
return datetime.strptime(date_str, "%d.%m.%Y").date()
except ValueError:
return None
elif "-" in date_str:
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
return None
def fix_snapshots():
"""
Переиндексирует снапшоты в таблице scud_logs.
Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ.
Префикс Y выставляется только если дата логов < даты создания.
"""
if not os.path.exists(DB_PATH):
print(f"База данных {DB_PATH} не найдена.")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Группируем по уникальным выгрузкам
cursor.execute("""
SELECT snapshot_time, log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_time, log_date
ORDER BY snapshot_time ASC, log_date ASC
""")
rows = cursor.fetchall()
if not rows:
print("Снапшоты не найдены.")
conn.close()
return
print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===")
# Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD)
seq_counters = {}
for snap_time_raw, log_date_raw in rows:
d_snap = parse_to_date(snap_time_raw)
d_log = parse_to_date(log_date_raw)
if not d_snap or not d_log:
continue
date_prefix = d_snap.strftime("%Y%m%d")
# Если дата логов строго раньше даты создания снапшота — ставим Y
is_yesterday = (d_log < d_snap)
prefix = "Y" if is_yesterday else ""
# Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ
seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1
seq_num = seq_counters[date_prefix]
new_id = f"{prefix}{date_prefix}-{seq_num:03d}"
cursor.execute(
"UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?",
(new_id, snap_time_raw, log_date_raw)
)
print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]")
conn.commit()
conn.close()
print("\n[✓] Переиндексация внутри дней создания успешно выполнена!")
if __name__ == "__main__":
fix_snapshots()
+15 -8
View File
@@ -12,28 +12,35 @@ def migrate():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor() cursor = conn.cursor()
# Проверяем существующие колонки в таблице scud_logs
cursor.execute("PRAGMA table_info(scud_logs);") cursor.execute("PRAGMA table_info(scud_logs);")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
# Добавляем столбец first_activity (Первая активность)
if 'first_activity' not in columns: if 'first_activity' not in columns:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN first_activity TEXT DEFAULT '—';") cursor.execute("ALTER TABLE scud_logs ADD COLUMN first_activity TEXT DEFAULT '—';")
print("Добавлена колонка 'first_activity'") print("[✓] Добавлена колонка 'first_activity'")
# Добавляем столбец anomaly_flag (Флаг аномалии СКУД)
if 'anomaly_flag' not in columns: if 'anomaly_flag' not in columns:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN anomaly_flag TEXT DEFAULT 'NONE';") cursor.execute("ALTER TABLE scud_logs ADD COLUMN anomaly_flag TEXT DEFAULT 'NONE';")
print("Добавлена колонка 'anomaly_flag'") print("[✓] Добавлена колонка 'anomaly_flag'")
# Добавляем столбец snapshot_time (Время среза/выгрузки)
if 'snapshot_time' not in columns: if 'snapshot_time' not in columns:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_time TEXT DEFAULT NULL;") cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_time TEXT DEFAULT NULL;")
print("Добавлена колонка 'snapshot_time'") print("[✓] Добавлена колонка 'snapshot_time'")
if 'snapshot_id' not in columns:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
print("[✓] Добавлена колонка 'snapshot_id'")
# Проставляем номера для ранее сохраненных срезов
cursor.execute("SELECT DISTINCT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time ASC")
snaps = cursor.fetchall()
for idx, (s_time,) in enumerate(snaps, 1):
s_id = f"{idx:07d}"
cursor.execute("UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ?", (s_id, s_time))
conn.commit() conn.commit()
conn.close() conn.close()
print("Миграция схемы БД успешно завершена!") print("[✓] Миграция схемы БД успешно завершена!")
if __name__ == "__main__": if __name__ == "__main__":
+37 -1
View File
@@ -36,7 +36,7 @@ def ask_ollama(prompt, system_prompt=None):
def get_system_rules_context(): def get_system_rules_context():
""" """
Загружает глобальные системные правила компании прямо из Базы Данных. Загружает глобальные системные правила компании прямо из Базы Данных SQLite.
""" """
rules = get_all_rules_from_db() rules = get_all_rules_from_db()
if not rules: if not rules:
@@ -147,3 +147,39 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
return mapping return mapping
def ai_verify_department_exceptions(unexplained_df, exception_departments):
"""
Локальный ИИ проверит, не являются ли оставшиеся неотмеченными отделы
синонимами/аббревиатурами отделов из exceptions.json.
"""
if unexplained_df.empty or not exception_departments:
return []
dept_list = unexplained_df['Подразделение'].dropna().unique().tolist()
rules_context = get_system_rules_context()
prompt = f"""
{rules_context}
Ты — кадровый аудитор.
Список отделов-исключений компании: {json.dumps(exception_departments, ensure_ascii=False)}
Список отделов сотрудников, попавших в неизвестные: {json.dumps(dept_list, ensure_ascii=False)}
Определи, какие из отделов сотрудников являются ПОЛНЫМИ НАЗВАНИЯМИ или СИНУНИМАМИ отделов-исключений (например: 'Отдел внутреннего контроля' — это 'ОВК').
Выдай ответ строго в формате JSON:
{{
"matched_departments": ["Название отдела 1", "Название отдела 2"]
}}
"""
raw_response = ask_ollama(prompt, system_prompt="Выдавай только валидный JSON.")
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
data = json.loads(match.group(0))
return data.get("matched_departments", [])
except Exception as e:
print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
return []
+17 -14
View File
@@ -1,20 +1,23 @@
import json
import os import os
from core.database import get_all_rules_from_db, add_rule_to_db
KB_FILE = os.path.join("data", "knowledge_base.json")
def load_knowledge_base(): def load_knowledge_base():
"""Загружает накопленный опыт и правила компании""" """
if not os.path.exists(KB_FILE): Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite.
return {"rules": [], "fio_corrections": {}} """
with open(KB_FILE, "r", encoding="utf-8") as f: rules = get_all_rules_from_db()
return json.load(f) return {
"rules": rules if rules else [],
"fio_corrections": {}
}
def add_rule_to_kb(new_rule): def add_rule_to_kb(new_rule):
"""Добавляет новое правило, принятое человеком, в базу знаний""" """
kb = load_knowledge_base() Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base.
if new_rule not in kb["rules"]: """
kb["rules"].append(new_rule) if not new_rule or not new_rule.strip():
with open(KB_FILE, "w", encoding="utf-8") as f: return
json.dump(kb, f, ensure_ascii=False, indent=2) add_rule_to_db(new_rule.strip(), added_by="Human")
print(f"[✓] База знаний обновлена! Новое правило: {new_rule}") print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}")
+27 -17
View File
@@ -15,7 +15,7 @@ import pyodbc
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db from core.database import save_scud_to_db, has_scud_logs_for_date
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
@@ -85,7 +85,7 @@ WITH DailyLogs AS (
Passages AS ( Passages AS (
SELECT SELECT
EmployeeID, EmployeeID,
MIN(TimeVal) AS FirstRawEvent, -- ⭐️ Первое событие ДНЯ ВООБЩЕ (любая дверь/туалет/выход) MIN(TimeVal) AS FirstRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
@@ -106,14 +106,11 @@ SELECT
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№], ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата], CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня], ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
-- ⭐️ НОВАЯ КОЛОНКА: "Первая активность" (если нет входа, но есть события в здании)
CASE CASE
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20)) THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
ELSE N'—' ELSE N'—'
END AS [Первая_активность], END AS [Первая_активность],
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода' THEN N'Нет выхода'
@@ -228,9 +225,15 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
processing_date = target["date"] processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y") processing_date_str = processing_date.strftime("%d.%m.%Y")
period_label = target["name"] period_label = target["name"]
is_yesterday = (period_label == "Вчера")
# ⚡️ ПРОВЕРКА: Если вчерашние данные уже есть в SQLite, повторную выгрузку из MS SQL пропускаем
if is_yesterday and has_scud_logs_for_date(processing_date_str):
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---")
log(f"[ℹ️] Вчерашние логи СКУД за {processing_date_str} уже присутствуют в SQLite. Повторная выгрузка пропущена.", "INFO")
continue
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---")
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
@@ -241,22 +244,17 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
log(f"База вернула {len(df)} строк за {processing_date_str}") log(f"База вернула {len(df)} строк за {processing_date_str}")
if len(df) > 0: if len(df) > 0:
# 1. Готовим колонки для локальной БД
df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light) df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
# Валидация прихода с учетом аномалии неявки на вход
df['anomaly_flag'] = 'NONE' df['anomaly_flag'] = 'NONE'
mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—') mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—')
df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY' df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY'
# Флаг 'Пришел' ставится в True только если был вход
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly) df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
# 2. ПРЯМОЕ СОХРАНЕНИЕ В SQLite (scud_orion_ai.db) С METКОЙ СРЕЗА snapshot_time save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time) log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite с меткой {snapshot_time}!", "SUCCESS")
# 3. Сохранение в Excel (если включен флаг save_xlsx)
if save_xlsx: if save_xlsx:
file_name = f"Сотрудники_{processing_date_str}.xlsx" file_name = f"Сотрудники_{processing_date_str}.xlsx"
file_path = os.path.join(SCUD_DIR, file_name) file_path = os.path.join(SCUD_DIR, file_name)
@@ -285,10 +283,22 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Экспорт данных СКУД в SQLite и Excel") help_text = """
parser.add_argument("--date", dest="input_date", default=None) Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить сохранение в XLSX") ПРИМЕРЫ ЗАПУСКА:
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
args = parser.parse_args() args = parser.parse_args()
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+23 -16
View File
@@ -8,12 +8,11 @@ from services.knowledge_base import load_knowledge_base
def find_python_fuzzy_matches(unexplained_df, raw_absent_df): def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
""" """
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
Сравнивает фамилию и полное имя с порогом сходства >= 0.80. Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
""" """
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
return [] return []
# Собираем словарь чистых ФИО 1С -> сырые ФИО 1С
absent_dict = {} absent_dict = {}
for idx, r in raw_absent_df.iterrows(): for idx, r in raw_absent_df.iterrows():
raw_fio = str(r.get('ФИО', '')) raw_fio = str(r.get('ФИО', ''))
@@ -37,7 +36,6 @@ def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
continue continue
abs_surname = abs_parts[0].lower() abs_surname = abs_parts[0].lower()
# Строгая проверка совпадения фамилии
if surname == abs_surname: if surname == abs_surname:
ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio()
if ratio >= 0.75: if ratio >= 0.75:
@@ -51,23 +49,13 @@ def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
return matches return matches
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="29.07.2026"): def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"):
# 1. Загрузка Базы Знаний
kb = load_knowledge_base() kb = load_knowledge_base()
custom_rules = kb.get("rules", []) custom_rules = kb.get("rules", [])
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
# 1. Точный расчет неточных совпадений через Python (Исключает галлюцинации ИИ) # 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
# 2. Список истинно неизвестных для ИИ
unexplained_list = []
if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept = r.get('Подразделение', '—')
pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
total_staff = len(merged_df) total_staff = len(merged_df)
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)
@@ -77,6 +65,25 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc
unexplained_cnt = len(absent_unexplained) unexplained_cnt = len(absent_unexplained)
anomalies_cnt = len(anomalies_list) if anomalies_list else 0 anomalies_cnt = len(anomalies_list) if anomalies_list else 0
# 3. Точный расчет неточных совпадений через Python
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
unexplained_list = []
if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept:
dept = '—'
pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f""" prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД. Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}. Сформируй итоговую сводку кадрового контроля на {date_str}.