12.08.2026 17:30 Логика формирования отчетов работает, все отсутствия учитываются, логика присутствия сотрудников в случае сбоя СКУД на работе настроена.

This commit is contained in:
2026-08-12 17:33:48 +03:00
parent ffb0e18635
commit 8897b14197
13 changed files with 467 additions and 192 deletions
@@ -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 к запросу.
+21
View File
@@ -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
View File
@@ -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.
+81 -96
View File
@@ -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
View File
@@ -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()
+47
View File
@@ -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)
+23
View File
@@ -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)
+31
View File
@@ -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
View File
@@ -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,
+9 -5
View File
@@ -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 [Находился_в_здании],