Перед тем, как исправлять выгрузку скуд с глупым Gemini-Lite
This commit is contained in:
@@ -3,70 +3,49 @@
|
||||
FILE: services/scud_etl/anomaly_detector.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Автоматическое выявление аномалий и конфликтов между 1С:ЗУП и СКУД.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[ANOMALY_DETECTOR_CORE]: Проверка физического присутствия в отпуске и перемещений без входа.
|
||||
ROLE: Детектирование истинных аномалий и конфликтов реестров.
|
||||
(Приход удаленщиков и командированных в офис аномалией НЕ является).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
import pandas as pd
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
|
||||
logger = logging.getLogger("SCUD_ANOMALY")
|
||||
|
||||
|
||||
# ANCHOR[ANOMALY_DETECTOR_CORE]
|
||||
def detect_all_anomalies(
|
||||
merged_df: pd.DataFrame,
|
||||
static_reasons_dict: dict,
|
||||
kb_rules: List[str],
|
||||
scud_fios_set: Optional[set] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Выявляет конфликты и аномалии между источниками СКУД и 1С."""
|
||||
def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Выявляет реальные аномалии:
|
||||
- Приход в офис во время отпуска или больничного листа.
|
||||
- Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
|
||||
"""
|
||||
anomalies = []
|
||||
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
|
||||
|
||||
for idx, row in merged_df.iterrows():
|
||||
fio = row.get('Сотрудник', row.get('fio_clean', ''))
|
||||
fio_clean = row.get('fio_clean', '')
|
||||
is_present = row.get('Пришел', False)
|
||||
is_exc = row.get('is_excluded', False)
|
||||
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
|
||||
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
|
||||
anom_flag = row.get('anomaly_flag', 'NONE')
|
||||
for _, row in df_merged.iterrows():
|
||||
fio = row.get("fio_clean") or row.get("Сотрудник", "")
|
||||
start_day = str(row.get("Начало_дня", "")).strip()
|
||||
end_day = str(row.get("Конец_дня", "")).strip()
|
||||
reason = str(row.get("причина отсутствия", "")).strip()
|
||||
reason_lower = reason.lower()
|
||||
|
||||
is_fio_whitelisted = fio_clean.lower() in kb_rules_text
|
||||
|
||||
# 1. Присутствие при официальном отсутствии
|
||||
if is_present and has_1c_reason:
|
||||
is_allowed_trip = any(kw in reason_1c.lower() for kw in ALLOWED_WORK_TRIP_KEYWORDS)
|
||||
if not is_allowed_trip and not is_fio_whitelisted:
|
||||
# 1. Приход в офис при отпуске / больничном
|
||||
if start_day != "Нет входа" and reason and reason != "nan":
|
||||
# Удаленная работа и командировки разрешены для работы в офисе
|
||||
if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
|
||||
anomalies.append({
|
||||
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
|
||||
"fio": fio,
|
||||
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
|
||||
"type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
|
||||
"description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
|
||||
})
|
||||
|
||||
if is_exc and not has_1c_reason:
|
||||
continue
|
||||
|
||||
# 2. Перемещение внутри здания без отметки входа на КПП
|
||||
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
|
||||
first_act = row.get('Первая_активность', '—')
|
||||
# 2. Аномалия оборудования (есть выход без входа)
|
||||
if start_day == "Нет входа" and end_day != "Нет выхода":
|
||||
anomalies.append({
|
||||
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
|
||||
"fio": fio,
|
||||
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
|
||||
"type": "SCUD_EQUIPMENT_ANOMALY",
|
||||
"description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
|
||||
})
|
||||
|
||||
# 3. Сотрудник в штате 1С, но карты/профиля в СКУД нет
|
||||
if scud_fios_set is not None:
|
||||
if fio_clean not in scud_fios_set and not has_1c_reason:
|
||||
anomalies.append({
|
||||
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
|
||||
"fio": fio,
|
||||
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но отсутствует в СКУД"
|
||||
})
|
||||
|
||||
return anomalies
|
||||
+93
-95
@@ -3,112 +3,110 @@
|
||||
FILE: services/scud_etl/merger.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Агрегация проходов, сопоставление исключений и мердж таблиц 1С:ЗУП и СКУД.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[MERGER_EXCEPTIONS]: Наложение флага исключений из exceptions.json.
|
||||
- ANCHOR[MERGER_AGGREGATION]: Агрегация множественных проходов до уникального ФИО.
|
||||
- ANCHOR[MERGER_BUILD_DATASET]: Сборка итогового датасета для сводок и отчетов.
|
||||
ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
|
||||
подразделений и расчет сходящегося баланса присутствия.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
import pandas as pd
|
||||
from config import normalize_fio, DATA_DIR
|
||||
|
||||
# Используем правильные имена функций из services/knowledge/service.py
|
||||
from services.knowledge.service import get_department_synonyms_dict, get_rules
|
||||
|
||||
logger = logging.getLogger("SCUD_MERGER")
|
||||
|
||||
|
||||
# ANCHOR[MERGER_EXCEPTIONS]
|
||||
def load_exceptions_config() -> dict:
|
||||
"""Загружает exceptions.json из корня проекта."""
|
||||
root_dir = os.path.abspath(os.path.join(os.path.dirname(__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:
|
||||
return {}
|
||||
def merge_scud_and_1c(
|
||||
df_scud: pd.DataFrame,
|
||||
df_staff_1c: pd.DataFrame,
|
||||
df_absences_1c: pd.DataFrame
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Объединяет реестры СКУД и 1С:ЗУП:
|
||||
- Применяет исключения (уборщики, ОВК и др.).
|
||||
- Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.).
|
||||
- Привязывает кадровые документы отсутствий из 1С.
|
||||
"""
|
||||
# Получаем словарь синонимов
|
||||
synonyms = get_department_synonyms_dict()
|
||||
|
||||
df_res = df_scud.copy()
|
||||
if "Сотрудник" in df_res.columns:
|
||||
df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip()
|
||||
|
||||
# 1. Фильтрация системных исключений
|
||||
# Загружаем исключения из базы знаний или задаем системный фильтр
|
||||
excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"}
|
||||
excluded_positions = {"Уборщик", "Уборщица", "Дворник"}
|
||||
|
||||
if not df_res.empty:
|
||||
if "Подразделение" in df_res.columns:
|
||||
df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
|
||||
if "Должность" in df_res.columns:
|
||||
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
|
||||
|
||||
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
|
||||
if "Подразделение" in df_res.columns:
|
||||
df_res["Подразделение"] = df_res["Подразделение"].apply(
|
||||
lambda d: synonyms.get(d, d)
|
||||
)
|
||||
|
||||
# 3. Привязка документов отсутствий из 1С
|
||||
absences_map = {}
|
||||
if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns:
|
||||
for _, row in df_absences_1c.iterrows():
|
||||
fio = str(row["Сотрудник"]).strip()
|
||||
reason = str(row["Причина"]).strip()
|
||||
absences_map[fio] = reason
|
||||
|
||||
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
|
||||
|
||||
return df_res
|
||||
|
||||
|
||||
def apply_exceptions_from_json(df: pd.DataFrame, exceptions_cfg: dict) -> pd.DataFrame:
|
||||
"""Быстрая разметка флага is_excluded на основе exceptions.json."""
|
||||
if df is None or df.empty or not exceptions_cfg:
|
||||
if df is not None:
|
||||
df['is_excluded'] = False
|
||||
return df
|
||||
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""
|
||||
Расчет строго сходящегося баланса присутствия:
|
||||
A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
|
||||
"""
|
||||
total_staff = len(df_merged)
|
||||
|
||||
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]
|
||||
# 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
|
||||
came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
|
||||
working_in_office = df_merged[came_to_office_mask]
|
||||
working_in_office_count = len(working_in_office)
|
||||
|
||||
df['is_excluded'] = False
|
||||
# 2. Все, кто сегодня НЕ пришел в офис
|
||||
not_came_mask = ~came_to_office_mask
|
||||
df_not_came = df_merged[not_came_mask]
|
||||
|
||||
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()
|
||||
# 3. Из непришедших выделяем удаленщиков (работают из дома)
|
||||
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
|
||||
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
|
||||
|
||||
remote_home = df_not_came[is_remote_mask]
|
||||
remote_home_count = len(remote_home)
|
||||
|
||||
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 k) if pos else False
|
||||
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) if deps else False
|
||||
# 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
|
||||
df_remaining_absent = df_not_came[~is_remote_mask]
|
||||
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
|
||||
df_remaining_absent["причина отсутствия"].ne("") & \
|
||||
df_remaining_absent["причина отсутствия"].ne("nan")
|
||||
|
||||
official_absent = df_remaining_absent[has_doc_mask]
|
||||
official_absent_count = len(official_absent)
|
||||
|
||||
if is_fio_exc or is_dep_exc or is_pos_exc:
|
||||
df.at[idx, 'is_excluded'] = True
|
||||
# 5. Оставшиеся непришедшие без документов — истинно неизвестные
|
||||
unknown = df_remaining_absent[~has_doc_mask]
|
||||
unknown_count = len(unknown)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
# ANCHOR[MERGER_AGGREGATION]
|
||||
def aggregate_scud_by_employee(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
|
||||
if df is None or df.empty or 'fio_clean' not in df.columns:
|
||||
return df
|
||||
|
||||
aggregated = []
|
||||
for fio_clean, group in df.groupby('fio_clean', sort=False):
|
||||
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
|
||||
if is_present and 'Пришел' in group.columns:
|
||||
present_rows = group[group['Пришел'] == True]
|
||||
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
|
||||
else:
|
||||
best_row = group.iloc[0].to_dict()
|
||||
|
||||
best_row['Пришел'] = is_present
|
||||
aggregated.append(best_row)
|
||||
|
||||
return pd.DataFrame(aggregated)
|
||||
|
||||
|
||||
def filter_report_dataframe(merged_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Исключает подрядчиков и сотрудников без пропуска из детального отчета."""
|
||||
if merged_df is None or merged_df.empty:
|
||||
return merged_df
|
||||
|
||||
has_1c_reason = (
|
||||
merged_df['Вид_отсутствия'].notna() &
|
||||
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
|
||||
)
|
||||
is_not_excluded = merged_df.get('is_excluded', False) == False
|
||||
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
|
||||
|
||||
return merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
|
||||
|
||||
|
||||
def load_static_reason_workers() -> dict:
|
||||
"""Загружает реестр удаленщиков из CSV."""
|
||||
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||||
if not os.path.exists(static_path):
|
||||
return {}
|
||||
try:
|
||||
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)
|
||||
return dict(zip(df_static['fio_clean'], df_static['reason']))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
return {
|
||||
"total_staff": total_staff,
|
||||
"working_in_office_count": working_in_office_count,
|
||||
"remote_home_count": remote_home_count,
|
||||
"official_absent_count": official_absent_count,
|
||||
"unknown_count": unknown_count,
|
||||
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
|
||||
}
|
||||
+46
-119
@@ -3,137 +3,64 @@
|
||||
FILE: services/scud_etl/pipeline.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / scud_etl
|
||||
ROLE: Оркестратор этапов контроллинга (Загрузка -> Сверка -> Отчеты -> SQLite).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[PIPELINE_RUN_CONTROLLING]: Главная функция выполнения ETL-конвейера.
|
||||
ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
import pandas as pd
|
||||
|
||||
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR
|
||||
from core.database import (
|
||||
save_scud_to_db, save_staff_to_db, save_absences_to_db,
|
||||
save_anomalies_to_db, load_scud_from_db_by_snapshot, get_latest_snapshot_time
|
||||
)
|
||||
from services.data_loader import load_1c_data_smart
|
||||
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
|
||||
from services.feedback_loop import review_ai_decisions
|
||||
from services.knowledge_base import load_knowledge_base
|
||||
from core.connection import get_connection
|
||||
|
||||
from .merger import (
|
||||
load_exceptions_config, apply_exceptions_from_json,
|
||||
aggregate_scud_by_employee, filter_report_dataframe, load_static_reason_workers
|
||||
)
|
||||
from .anomaly_detector import detect_all_anomalies
|
||||
logger = logging.getLogger("SCUD_PIPELINE")
|
||||
|
||||
|
||||
# ANCHOR[PIPELINE_RUN_CONTROLLING]
|
||||
def run_controlling_pipeline(snapshot_param: str = None, skip_export: bool = False, debug: bool = False, has_today_1c: bool = True) -> None:
|
||||
"""Выполняет полный цикл сверки СКУД ⟷ 1С и сохранение результатов."""
|
||||
kb_rules = load_knowledge_base().get("rules", [])
|
||||
exceptions_cfg = load_exceptions_config()
|
||||
static_reasons = load_static_reason_workers()
|
||||
def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
|
||||
"""
|
||||
Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
|
||||
финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
|
||||
"""
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if prefer_final_y:
|
||||
cursor.execute("""
|
||||
SELECT raw_data_json
|
||||
FROM scud_snapshots
|
||||
WHERE (snapshot_date = ? OR date_str = ?) AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '22:00%')
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (date_str, date_str))
|
||||
row = cursor.fetchone()
|
||||
if row and row["raw_data_json"]:
|
||||
data = json.loads(row["raw_data_json"])
|
||||
return pd.DataFrame(data)
|
||||
|
||||
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
|
||||
cursor.execute("""
|
||||
SELECT raw_data_json
|
||||
FROM scud_snapshots
|
||||
WHERE snapshot_date = ? OR date_str = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", (date_str, date_str))
|
||||
row = cursor.fetchone()
|
||||
if row and row["raw_data_json"]:
|
||||
data = json.loads(row["raw_data_json"])
|
||||
return pd.DataFrame(data)
|
||||
|
||||
# 1. Определение дат целевого снапшота и предыдущей смены
|
||||
if snapshot_param or skip_export:
|
||||
snap_to_use = snapshot_param or get_latest_snapshot_time()
|
||||
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snap_to_use)
|
||||
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:
|
||||
target_date_str = DATE_TODAY
|
||||
else:
|
||||
target_date_str = DATE_TODAY
|
||||
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
|
||||
return pd.DataFrame()
|
||||
|
||||
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
|
||||
dt_yesterday = dt_target - timedelta(days=3 if dt_target.weekday() == 0 else 1)
|
||||
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
|
||||
|
||||
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}\n")
|
||||
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""
|
||||
Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/.
|
||||
"""
|
||||
formatted_date = date_str.replace(".", "_")
|
||||
staff_file = f"data/1c/Штат_{formatted_date}.xlsx"
|
||||
absences_file = f"data/1c/Отсутствия_{formatted_date}.xlsx"
|
||||
|
||||
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
|
||||
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
|
||||
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
|
||||
df_staff = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame()
|
||||
df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame()
|
||||
|
||||
# 2. Этап 3: Обработка ВЧЕРА (Детальный отчет)
|
||||
print(f"[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
|
||||
|
||||
if df_staff_yesterday is not None:
|
||||
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
|
||||
if df_absent_yesterday is not None:
|
||||
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
|
||||
|
||||
# Проверка опечаток ФИО через AI-аудитор
|
||||
staff_fios_y_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
|
||||
if not raw_scud_yesterday_df.empty:
|
||||
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
|
||||
unrecog = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_y_clean)]['fio_clean'].tolist()
|
||||
fio_map = ai_verify_scud_against_staff(unrecog, staff_fios_y_clean)
|
||||
if fio_map:
|
||||
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(lambda x: fio_map[x]['staff_fio'] if x in fio_map else x)
|
||||
|
||||
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df)
|
||||
merged_y = (df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame())
|
||||
|
||||
if not merged_y.empty:
|
||||
if not raw_scud_yesterday_df.empty:
|
||||
merged_y = merged_y.merge(
|
||||
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']],
|
||||
on='fio_clean', how='left'
|
||||
)
|
||||
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
|
||||
merged_y = merged_y.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
||||
|
||||
merged_y['Пришел'] = merged_y['Пришел'].fillna(False) if 'Пришел' in merged_y.columns else False
|
||||
if 'Сотрудник' not in merged_y.columns:
|
||||
merged_y['Сотрудник'] = merged_y.get('ФИО', merged_y['fio_clean'])
|
||||
|
||||
merged_y = apply_exceptions_from_json(merged_y, exceptions_cfg)
|
||||
|
||||
scud_fios_y = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
|
||||
anomalies_y = detect_all_anomalies(merged_y, static_reasons, kb_rules, scud_fios_set=scud_fios_y)
|
||||
save_anomalies_to_db(anomalies_y, yesterday_date_str)
|
||||
|
||||
filtered_y = filter_report_dataframe(merged_y)
|
||||
generate_detailed_excel(merged_df=filtered_y, date_str=yesterday_date_str)
|
||||
save_scud_to_db(merged_y, yesterday_date_str, snapshot_time=f"{dt_yesterday.strftime('%Y-%m-%d')} 22:00:00", is_yesterday=True)
|
||||
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
|
||||
|
||||
# 3. Этап 4 & 5: Обработка СЕГОДНЯ (Ежедневная сводка)
|
||||
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
|
||||
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
|
||||
save_staff_to_db(df_staff_today, target_date_str)
|
||||
save_absences_to_db(df_absent_today, target_date_str)
|
||||
|
||||
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df)
|
||||
merged_t = df_staff_today.copy()
|
||||
if not raw_scud_today_df.empty:
|
||||
merged_t = merged_t.merge(
|
||||
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']],
|
||||
on='fio_clean', how='left'
|
||||
)
|
||||
merged_t = merged_t.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
|
||||
merged_t['Пришел'] = merged_t['Пришел'].fillna(False) if 'Пришел' in merged_t.columns else False
|
||||
if 'Сотрудник' not in merged_t.columns:
|
||||
merged_t['Сотрудник'] = merged_t.get('ФИО', merged_t['fio_clean'])
|
||||
|
||||
merged_t = apply_exceptions_from_json(merged_t, exceptions_cfg)
|
||||
|
||||
scud_fios_t = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
|
||||
anomalies_t = detect_all_anomalies(merged_t, static_reasons, kb_rules, scud_fios_set=scud_fios_t)
|
||||
save_anomalies_to_db(anomalies_t, target_date_str)
|
||||
|
||||
print("[5/5] Сохранение Ежедневной сводки...")
|
||||
generate_summary_excel(merged_df=merged_t, date_str=target_date_str)
|
||||
print(f"[✓] Ежедневная сводка сохранена за {target_date_str}")
|
||||
else:
|
||||
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
|
||||
return df_staff, df_absences
|
||||
Reference in New Issue
Block a user