diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9fec38f..e448773 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,33 @@ Все важные изменения проекта документируются в этом файле. +## [3.3.0] — 2026-09-24 + +### ✨ Добавлено (Added) +- **Генератор «Упрощенного отчета» (`services/reports/simplified_builder.py`):** + - Формирование суточного файла `Упрощенный отчет за ДД.ММ.ГГГГг..xlsx` с выгрузкой на сетевую шару параллельно с детальным отчетом. + - Сокращение полных ФИО до эталонного формата с инициалами (`Иванов И.И.`). + - Подстановка официальных текстовых заглушек СКУД `Нет входа (0:00)` и `Нет выхода (23:59)` при отсутствии отметок. + - Алфавитная сортировка по подразделениям и сотрудникам, форматирование сетки `Calibri 11` с числовым форматом времени `h:mm`. +- **Разделы сводки «Не приняты на работу» и «Нет пропуска»:** + - В `services/scud_etl/merger.py` и `services/reports/svodka_builder.py` выделена категория физлиц, присутствующих в СКУД, но еще не оформленных в 1С:ЗУП (`not_hired_yet`). + - Сотрудники штата без карт доступа теперь явно направляются в блок «Нет пропуска» (`no_scud_pass`). + +### 🔧 Изменено (Changed) +- **Исключение не принятых на работу из табеля:** + - В `services/reports/otchet_builder.py` сотрудники без проведенного приказа о приеме в 1С исключены из таблицы детального суточного отчета за вчера. +- **Восстановление аббревиатур подразделений:** + - В `merger.py` восстановлен приоритет компактных названий отделов из базы СКУД Орион (`ОАН`, `ПУ`, `ОИЗ`, `ОА`, `ИГТЛЛ` и др.) взамен длинных кадровых формулировок 1С. +- **Инфраструктура нового сервера (Debian 12):** + - Исправлены пути виртуального окружения `/home/apushkov/projects/scud_ai/venv/bin/python` в cron-скриптах. + - Настроено автоматическое CIFS-монтирование шары отчетов в `/etc/fstab` с правами пользователя `apushkov`. + +### 🛡️ Исправлено (Fixed) +- **Сбой генерации детального отчета (`TypeError: NAN/INF not supported`):** + - В `otchet_builder.py` включен параметр книги `nan_inf_to_errors: True` и внедрена санитизация пустых ячеек `NaN`/`None`, исключающая аварийное завершение `write_number()`. +- **Предупреждения Pandas в логах:** + - Устранены ошибки `Boolean Series key will be reindexed to match DataFrame index` в `merger.py` за счет изоляции расчета масок от `df_staff_only`. + ## [2.5.8] - 2026-09-10 ### Добавлено (Added) diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index 2f7e40d..1016f76 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -193,4 +193,24 @@ - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2]. - [ ] **Динамические Python/Pandas вычисления:** - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2]. - - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2]. \ No newline at end of file + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2]. + +--- + +## 17. Миграция инфраструктуры, отказоустойчивость отчетов и Упрощенный отчет `[ЗАВЕРШЕНО]` +- [x] **Миграция на новый сервер (Debian 12):** + - Актуализация путей виртуального окружения в cron-скриптах (`scripts/cron/*.sh`). + - Восстановление прямого подключения к базе 1С:ЗУП (MS SQL Server `ACCOUNT-01`). + - Настройка автоматического CIFS-монтирования шары отчетов `//storage/SCUD/Отчеты` в `/etc/fstab` с правами пользователя. +- [x] **Разделение статусов наличия в кадровых реестрах (`merger.py`):** + - Выделение сотрудников, заведенных в СКУД, но не принятых в ЗУП (`not_hired_yet = True`), в отдельный раздел сводки **«Не приняты на работу»**. + - Полное исключение непринятых сотрудников из детального суточного табеля за прошлые смены. + - Обособление штатных сотрудников без карт СКУД (`no_scud_pass = True`) в раздел **«Нет пропуска»** с исключением их из «Неизвестных». + - Устранение предупреждений `UserWarning: Boolean Series key will be reindexed` при вычислении метрик штата. +- [x] **Стабилизация генерации Excel-книг (`otchet_builder.py`):** + - Включение защитной опции `nan_inf_to_errors: True` в `xlsxwriter.Workbook`. + - Полная санитарная очистка ячеек от значений `NaN` / `None` / `pd.NA` перед записью, устранившая критический сбой `TypeError: NAN/INF not supported in write_number()`. +- [x] **Генератор «Упрощенного отчета» (`services/reports/simplified_builder.py`):** + - Создание нового генератора по эталонному формату: заголовок `Упрощенный отчет: c ДД.ММ.ГГГГ по ДД.ММ.ГГГГ`, сокращение ФИО до инициалов (`Фамилия И.О.`), отображение времени с текстовыми заглушками (`Нет входа (0:00)`, `Нет выхода (23:59)`). + - Сортировка по подразделениям и алфавиту сотрудников. + - Интеграция этапа сборки упрощенного отчета за вчера в ETL-конвейер (`main_etl.py`). \ No newline at end of file diff --git a/main_etl.py b/main_etl.py index 54c6f83..25800a5 100644 --- a/main_etl.py +++ b/main_etl.py @@ -134,12 +134,24 @@ def main(): if df_scud_today is not None and not df_scud_today.empty: export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx") - # [Этап 3] Детальный отчет за вчера через otchet_generator - print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") + # [Этап 3] Детальный и Упрощенный отчеты за вчера + print(f"\n[3/5] Обработка и построение детального и упрощенного отчетов за ВЧЕРА ({yesterday_str})...") res_otchet = generate_otchet_service(target_date=yesterday_str) if res_otchet.get("status") == "success": print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}") + # Генерация Упрощенного отчета за вчера + try: + from services.reports.simplified_builder import generate_simplified_excel + df_scud_y = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True) + df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_str) + df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y) + + simplified_path = generate_simplified_excel(df_merged_y, date_str=yesterday_str) + print(f"[✓] Упрощенный отчет за {yesterday_str} успешно сформирован: {simplified_path}") + except Exception as e: + print(f"[⚠️] Ошибка формирования упрощенного отчета: {e}") + # [Этап 4] Сводка за сегодня через svodka_generator print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...") res_svodka = generate_svodka_service( @@ -165,8 +177,10 @@ def main(): ] absent_unexplained = df_merged_today[ (df_merged_today['Пришел'] == False) & - (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (df_merged_today.get('is_excluded', False) == False) + (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) & + (df_merged_today.get('is_excluded', False) == False) & + (df_merged_today.get('not_hired_yet', False) == False) & + (df_merged_today.get('no_scud_pass', False) == False) ] summary_md = generate_markdown_report( diff --git a/scripts/cron/run_cron_etl.sh b/scripts/cron/run_cron_etl.sh index b27a077..6bdddfb 100755 --- a/scripts/cron/run_cron_etl.sh +++ b/scripts/cron/run_cron_etl.sh @@ -1,14 +1,14 @@ #!/bin/bash set -e -cd /home/puh/projects/scud_ai -mkdir -p /home/puh/projects/scud_ai/logs +cd /home/apushkov/projects/scud_ai +mkdir -p /home/apushkov/projects/scud_ai/logs -echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "==================================================" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log +echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log +echo "==================================================" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log -/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1 +/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/main_etl.py >> /home/apushkov/projects/scud_ai/logs/cron_etl.log 2>&1 -echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log -echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log \ No newline at end of file +echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log +echo "" >> /home/apushkov/projects/scud_ai/logs/cron_etl.log \ No newline at end of file diff --git a/scripts/cron/run_hourly_snapshot.sh b/scripts/cron/run_hourly_snapshot.sh index 1693818..3119497 100755 --- a/scripts/cron/run_hourly_snapshot.sh +++ b/scripts/cron/run_hourly_snapshot.sh @@ -1,9 +1,9 @@ #!/bin/bash set -e -cd /home/puh/projects/scud_ai -mkdir -p /home/puh/projects/scud_ai/logs +cd /home/apushkov/projects/scud_ai +mkdir -p /home/apushkov/projects/scud_ai/logs -echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log -/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1 -echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log \ No newline at end of file +echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log +/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/services/scud_export.py >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log 2>&1 +echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log \ No newline at end of file diff --git a/scripts/cron/run_reports_only.sh b/scripts/cron/run_reports_only.sh index 299cc46..8eca54b 100755 --- a/scripts/cron/run_reports_only.sh +++ b/scripts/cron/run_reports_only.sh @@ -1,9 +1,9 @@ #!/bin/bash set -e -cd /home/puh/projects/scud_ai -mkdir -p /home/puh/projects/scud_ai/logs +cd /home/apushkov/projects/scud_ai +mkdir -p /home/apushkov/projects/scud_ai/logs -echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log -/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --skip-export >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1 -echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log \ No newline at end of file +echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_reports.log +/home/apushkov/projects/scud_ai/venv/bin/python /home/apushkov/projects/scud_ai/main_etl.py --skip-export >> /home/apushkov/projects/scud_ai/logs/cron_reports.log 2>&1 +echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_reports.log \ No newline at end of file diff --git a/services/reports/otchet_builder.py b/services/reports/otchet_builder.py index f56d2bf..36e9153 100644 --- a/services/reports/otchet_builder.py +++ b/services/reports/otchet_builder.py @@ -18,12 +18,17 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): if not filename: filename = f"{format_date_ru(date_clean)} отчет.xlsx" - df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() if merged_df is not None and not merged_df.empty else pd.DataFrame() + is_exc = merged_df.get('is_excluded', False) == True + is_not_hired = merged_df.get('not_hired_yet', False) == True + + # Исключаем из отчета за вчера сотрудников-исключений И тех, кто еще не принят на работу + df_export = merged_df[(~is_exc) & (~is_not_hired)].copy() if merged_df is not None and not merged_df.empty else pd.DataFrame() target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) - wb = xlsxwriter.Workbook(output_path) + # Включаем опцию защиты nan_inf_to_errors + wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True}) ws = wb.add_worksheet("Детальный_отчет") fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True}) @@ -46,18 +51,32 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): for idx, row in df_export.reset_index(drop=True).iterrows(): row_num = 4 + idx - is_present = row.get('Пришел', False) + is_present = bool(row.get('Пришел', False)) absence_reason = row.get('Вид_отсутствия', '') - has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' + has_reason = pd.notna(absence_reason) and str(absence_reason).strip() not in ['', 'nan', 'None'] in_val = str(row.get(start_col, 'Нет входа')).strip() + if in_val.lower() in ['nan', 'none']: in_val = 'Нет входа' + out_val = str(row.get(end_col, 'Нет выхода')).strip() - in_building_str = str(row.get(hours_col, '00:00')) + if out_val.lower() in ['nan', 'none']: out_val = 'Нет выхода' + + in_building_str = str(row.get(hours_col, '00:00')).strip() + if in_building_str.lower() in ['nan', 'none']: in_building_str = '00:00' + first_act_val = str(row.get('Первая_активность', '—')).strip() + if first_act_val.lower() in ['nan', 'none']: first_act_val = '—' has_first_act = first_act_val not in ['—', '', 'None', 'nan'] deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) - dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) + + dept_raw = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) + dept_scud_val = str(dept_raw).strip() if pd.notna(dept_raw) else '—' + if dept_scud_val.lower() in ['nan', 'none']: dept_scud_val = '—' + + fio_raw = row.get('Сотрудник', '') + fio_val = str(fio_raw).strip() if pd.notna(fio_raw) else '' + if fio_val.lower() in ['nan', 'none']: fio_val = '' row_color = None if is_present and has_reason: @@ -67,24 +86,29 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): elif not is_present and not has_reason and not has_first_act: row_color = '#FCE4D6' - val_h_str = str(absence_reason) if has_reason else "" + val_h_str = str(absence_reason).strip() if has_reason else "" + if val_h_str.lower() in ['nan', 'none']: val_h_str = "" + lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 ws.set_row(row_num, max(lines_count * 18, 20)) values = [ (idx + 1, 'center', False), - (row.get('Сотрудник', ''), 'left', False), + (fio_val, 'left', False), (dept_scud_val, 'center', False), (in_val, 'center', False), (first_act_val, 'center', False), (out_val, 'center', False), (in_building_str, 'center', False), - (absence_reason if has_reason else '', 'left', True), + (val_h_str, 'left', True), (8, 'center', False), (deviation_val, 'center', False) ] for col_idx, (val, align_type, is_wrap) in enumerate(values): + # Санитизация для исключения передачи float('nan') в _write_number + if pd.isna(val) or val is None or str(val).strip().lower() == 'nan': + val = "" fmt = create_xlsx_format(wb, font_name='Arial', font_size=10, bg_color=row_color, align=align_type, wrap=is_wrap) ws.write(row_num, col_idx, val, fmt) diff --git a/services/reports/simplified_builder.py b/services/reports/simplified_builder.py new file mode 100644 index 0000000..183a782 --- /dev/null +++ b/services/reports/simplified_builder.py @@ -0,0 +1,147 @@ +""" +=============================================================================== +FILE: services/reports/simplified_builder.py +ROLE: Генератор книги "Упрощенный отчет за ДД.ММ.ГГГГг..xlsx" + на основе агрегированных данных СКУД и 1С. +=============================================================================== +""" + +import os +import openpyxl +from openpyxl.styles import Font, Alignment, Border, Side +from datetime import datetime, time +import pandas as pd +from config import REPORTS_DIR +from services.reports.styles import get_dated_reports_dir + + +def format_fio_initials(full_fio: str) -> str: + """Преобразует 'Иванов Иван Иванович' в 'Иванов И.И.'""" + if not full_fio or pd.isna(full_fio): + return "" + parts = str(full_fio).strip().split() + if len(parts) >= 3: + return f"{parts[0]} {parts[1][0]}.{parts[2][0]}." + elif len(parts) == 2: + return f"{parts[0]} {parts[1][0]}." + return str(full_fio).strip() + + +def parse_time_cell(val, default_empty="Нет входа (0:00)"): + """Преобразует строку времени СКУД в time(hh, mm) либо оставляет текстовую заглушку.""" + if not val or pd.isna(val): + return default_empty + s = str(val).strip() + if s in ["Нет входа", "—", "", "nan", "None", "00:00:00", "00:00"]: + return default_empty + if s in ["Нет выхода"]: + return "Нет выхода (23:59)" + + # Если уже пришёл time + if isinstance(val, time): + return val + + try: + parts = s.split(":") + h = int(parts[0]) + m = int(parts[1]) if len(parts) > 1 else 0 + return time(h, m) + except Exception: + return s + + +def generate_simplified_excel(df_merged: pd.DataFrame, date_str: str, filename: str = None) -> str: + """ + Генерирует Excel-файл упрощенного отчета точно по образцу Ориона. + Структура: + A: № | B: Подразделение | C: Сотрудник | D: Должность | E: Начало дня | F: Конец дня + """ + clean_date = str(date_str).replace('_', '.') + if not filename: + filename = f"Упрощенный отчет за {clean_date}г..xlsx" + + target_dir = get_dated_reports_dir(clean_date) + output_path = os.path.join(target_dir, filename) + + # Исключаем тех, кто не принят на работу и исключения + is_exc = df_merged.get('is_excluded', False) == True + is_not_hired = df_merged.get('not_hired_yet', False) == True + df_src = df_merged[(~is_exc) & (~is_not_hired)].copy() + + # Подготовка данных + rows_to_sort = [] + for _, r in df_src.iterrows(): + fio_raw = r.get('Сотрудник', r.get('ФИО', '')) + dept = str(r.get('Подразделение', '—')).strip() + pos = str(r.get('Должность', '—')).strip() + if pos.lower() in ['nan', 'none', '']: pos = '—' + if dept.lower() in ['nan', 'none', '']: dept = '—' + + fio_short = format_fio_initials(fio_raw) + t_in = parse_time_cell(r.get('Начало_дня'), default_empty="Нет входа (0:00)") + t_out = parse_time_cell(r.get('Конец_дня'), default_empty="Нет выхода (23:59)") + + rows_to_sort.append({ + 'dept': dept, + 'fio_short': fio_short, + 'pos': pos, + 't_in': t_in, + 't_out': t_out + }) + + # Сортировка: Подразделение (А-Я), затем Сотрудник (А-Я) + rows_sorted = sorted(rows_to_sort, key=lambda x: (x['dept'].lower(), x['fio_short'].lower())) + + # Создание книги openpyxl для точного соблюдения структуры образца + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Лист1" + + # Стили по образцу + font_main = Font(name="Calibri", size=11, bold=False) + align_center = Alignment(horizontal="center", vertical="center") + border_thin = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin") + ) + + # Заголовок + ws["B1"] = f"Упрощенный отчет: c {clean_date} по {clean_date}" + ws["B1"].font = font_main + + # Шапка таблицы (Строка 3) + headers = [None, "Подразделение", "Сотрудник", "Должность", "Начало дня", "Конец дня"] + for col_idx, h_text in enumerate(headers, start=1): + cell = ws.cell(row=3, column=col_idx, value=h_text) + cell.font = font_main + cell.alignment = align_center + cell.border = border_thin + + # Заполнение строк данных (с 4 строки) + for idx, item in enumerate(rows_sorted, start=1): + row_num = 3 + idx + vals = [idx, item['dept'], item['fio_short'], item['pos'], item['t_in'], item['t_out']] + + for col_idx, val in enumerate(vals, start=1): + cell = ws.cell(row=row_num, column=col_idx, value=val) + cell.font = font_main + cell.alignment = align_center + cell.border = border_thin + + # Числовой формат времени h:mm + if isinstance(val, time): + cell.number_format = "h:mm" + + # Настройка ширины колонок по образцу + widths = {'A': 13.0, 'B': 25.0, 'C': 27.0, 'D': 80.0, 'E': 21.5, 'F': 23.3} + for col_letter, w in widths.items(): + ws.column_dimensions[col_letter].width = w + + # Создание пустых Лист2 и Лист3 как в оригинальном шаблоне + wb.create_sheet("Лист2") + wb.create_sheet("Лист3") + + wb.save(output_path) + return output_path \ No newline at end of file diff --git a/services/reports/svodka_builder.py b/services/reports/svodka_builder.py index 6290283..425920f 100644 --- a/services/reports/svodka_builder.py +++ b/services/reports/svodka_builder.py @@ -20,7 +20,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) - wb = xlsxwriter.Workbook(output_path) + wb = xlsxwriter.Workbook(output_path, {'nan_inf_to_errors': True}) ws = wb.add_worksheet("Лист_1") ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) @@ -38,18 +38,24 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.write(1, 0, "", fmt_empty) ws.write(1, 1, "", fmt_empty) + is_not_hired = merged_df.get('not_hired_yet', False) == True + is_no_pass = merged_df.get('no_scud_pass', False) == True + is_exc = merged_df.get('is_excluded', False) == True + + # "По списку" строго по официальному штату 1С + staff_total = len(merged_df[~is_not_hired]) + ws.set_row(2, 20) ws.write(2, 0, "По списку", fmt_tot_l) - ws.write(2, 1, len(merged_df), fmt_tot_r) + ws.write(2, 1, staff_total, fmt_tot_r) current_row = 3 - is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False - is_exc = merged_df.get('is_excluded', False) == True # 1. Неизвестно unexplained = merged_df[ + (~is_not_hired) & (merged_df['Пришел'] == False) & - (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'None']))) & (~is_no_pass) & (~is_exc) ] fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left") @@ -64,34 +70,54 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): for fio in sorted(unexplained['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) - ws.write(current_row, 0, fio, fmt_unexp_rl) + ws.write(current_row, 0, str(fio), fmt_unexp_rl) ws.write(current_row, 1, "", fmt_unexp_rr) current_row += 1 - # 2. Нет пропуска - no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + # 2. Нет пропуска (в штате 1С есть, но карты СКУД нет) + no_pass_df = merged_df[is_no_pass & (~is_exc) & (~is_not_hired)] fmt_np_hl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="left") fmt_np_hr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=True, align="right") fmt_np_rl = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="left") fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right") ws.set_row(current_row, 20) - ws.write(current_row, 0, "Нет пропуска", fmt_np_hl) + ws.write(current_row, 0, "Нет в СКУД", fmt_np_hl) ws.write(current_row, 1, len(no_pass_df), fmt_np_hr) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) - ws.write(current_row, 0, fio, fmt_np_rl) + ws.write(current_row, 0, str(fio), fmt_np_rl) ws.write(current_row, 1, "", fmt_np_rr) current_row += 1 - # 3. Официальные отсутствия + # 3. Не приняты на работу (в СКУД есть, но в 1С приказа еще нет) + not_hired_df = merged_df[is_not_hired & (~is_exc)] + fmt_nh_hl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="left") + fmt_nh_hr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=True, align="right") + fmt_nh_rl = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="left") + fmt_nh_rr = create_xlsx_format(wb, bg_color='#FFF0F5', bold=False, align="right") + + ws.set_row(current_row, 20) + ws.write(current_row, 0, "Нет в ЗУП", fmt_nh_hl) + ws.write(current_row, 1, len(not_hired_df), fmt_nh_hr) + current_row += 1 + + if not not_hired_df.empty: + for fio in sorted(not_hired_df['Сотрудник'].dropna().unique()): + ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) + ws.write(current_row, 0, str(fio), fmt_nh_rl) + ws.write(current_row, 1, "", fmt_nh_rr) + current_row += 1 + + # 4. Официальные отсутствия reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) absent_only = merged_df[ + (~is_not_hired) & (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & @@ -108,7 +134,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right") ws.set_row(current_row, 20) - ws.write(current_row, 0, cat_name, fmt_cat_hl) + ws.write(current_row, 0, str(cat_name), fmt_cat_hl) ws.write(current_row, 1, len(group), fmt_cat_hr) current_row += 1 @@ -116,14 +142,16 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): for _, row in group.sort_values(by='Сотрудник').iterrows(): fio = row.get('Сотрудник', '') detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else "" + if pd.isna(detail_val): + detail_val = "" ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) - ws.write(current_row, 0, fio, fmt_cat_rl) - ws.write(current_row, 1, detail_val, fmt_cat_rr) + ws.write(current_row, 0, str(fio), fmt_cat_rl) + ws.write(current_row, 1, str(detail_val), fmt_cat_rr) current_row += 1 - # 4. Итого на работе - exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] - present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] + # 5. Итого на работе (только принятые) + exc_without_doc = merged_df[(~is_not_hired) & is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] + present_scud = merged_df[(~is_not_hired) & (merged_df['Пришел'] == True) & (~is_exc)] total_present_count = len(present_scud) + len(exc_without_doc) fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left") @@ -134,8 +162,8 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.write(current_row, 1, total_present_count, fmt_pres_hr) current_row += 1 - # 5. Удаленная работа - remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] + # 6. Удаленная работа + remote_home = merged_df[(~is_not_hired) & (merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] fmt_rem_hl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="left") fmt_rem_hr = create_xlsx_format(wb, bg_color='#E8F8F5', bold=True, align="right") fmt_rem_rl = create_xlsx_format(wb, bg_color='#E8F8F5', bold=False, align="left") @@ -149,13 +177,13 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): if not remote_home.empty: for fio in sorted(remote_home['Сотрудник'].dropna().unique()): ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) - ws.write(current_row, 0, fio, fmt_rem_rl) + ws.write(current_row, 0, str(fio), fmt_rem_rl) ws.write(current_row, 1, "", fmt_rem_rr) current_row += 1 - # 6. Аномалии СКУД и 1С + # 7. Аномалии СКУД и 1С anomalies = merged_df[ - (~is_exc) & ( + (~is_not_hired) & (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~is_remote_reason) & @@ -182,8 +210,8 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 ws.set_row(current_row, max(lines_count * 18, 20), None, {'level': 1, 'hidden': True, 'collapsed': True}) - ws.write(current_row, 0, fio, fmt_anom_rl) - ws.write(current_row, 1, reason_text, fmt_anom_rr) + ws.write(current_row, 0, str(fio), fmt_anom_rl) + ws.write(current_row, 1, str(reason_text), fmt_anom_rr) current_row += 1 ws.set_column(0, 0, 45) diff --git a/services/scud_etl/merger.py b/services/scud_etl/merger.py index ee6f066..dfee53d 100644 --- a/services/scud_etl/merger.py +++ b/services/scud_etl/merger.py @@ -138,7 +138,8 @@ def merge_scud_and_1c( return pd.DataFrame(columns=[ 'Сотрудник', 'fio_clean', 'Подразделение', 'Должность', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', - 'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded' + 'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', + 'is_excluded', 'not_hired_yet', 'no_scud_pass' ]) synonyms = get_department_synonyms_dict() @@ -147,15 +148,63 @@ def merge_scud_and_1c( df_scud_agg = aggregate_scud_by_person(df_scud) df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg) - df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy() + staff_fios = set(df_staff_agg['fio_clean'].dropna().tolist()) if df_staff_agg is not None and not df_staff_agg.empty else set() + scud_dict = df_scud_agg.set_index('fio_clean').to_dict('index') if df_scud_agg is not None and not df_scud_agg.empty else {} - if "Сотрудник" in df_res.columns: - df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) - elif "ФИО" in df_res.columns: + merged_rows = [] + + # 1. Формируем строки по официальному штату 1С + if df_staff_agg is not None and not df_staff_agg.empty: + for _, s_row in df_staff_agg.iterrows(): + fio_c = s_row.get('fio_clean', '') + r = dict(s_row) + if 'Сотрудник' not in r or pd.isna(r['Сотрудник']) or str(r['Сотрудник']).strip() == '': + r['Сотрудник'] = r.get('ФИО', fio_c) + + if fio_c in scud_dict: + # Сотрудник есть в СКУД — берем короткую аббревиатуру отдела из СКУД + scud_data = scud_dict[fio_c] + dept_scud = str(scud_data.get('Подразделение', '')).strip() + if dept_scud and dept_scud.lower() not in ['nan', 'none', '—', 'без подразделения']: + r['Подразделение'] = dept_scud + + r['Начало_дня'] = scud_data.get('Начало_дня', 'Нет входа') + r['Первая_активность'] = scud_data.get('Первая_активность', '—') + r['Конец_дня'] = scud_data.get('Конец_дня', 'Нет выхода') + r['Находился_в_здании'] = scud_data.get('Находился_в_здании', '00:00') + r['Пришел'] = bool(scud_data.get('Пришел', False)) + r['anomaly_flag'] = scud_data.get('anomaly_flag', 'NONE') + r['no_scud_pass'] = False + r['not_hired_yet'] = False + else: + # Сотрудника нет в СКУД (Нет пропуска) + r['Начало_дня'] = 'Нет входа' + r['Первая_активность'] = '—' + r['Конец_дня'] = 'Нет выхода' + r['Находился_в_здании'] = '00:00' + r['Пришел'] = False + r['anomaly_flag'] = 'NONE' + r['no_scud_pass'] = True + r['not_hired_yet'] = False + + merged_rows.append(r) + + # 2. Сотрудники из СКУД, которых еще нет в 1С (Не приняты на работу) + if df_scud_agg is not None and not df_scud_agg.empty: + for _, scud_row in df_scud_agg.iterrows(): + fio_c = scud_row.get('fio_clean', '') + if fio_c not in staff_fios: + r = dict(scud_row) + r['not_hired_yet'] = True + r['no_scud_pass'] = False + if 'Должность' not in r or pd.isna(r['Должность']): + r['Должность'] = '—' + merged_rows.append(r) + + df_res = pd.DataFrame(merged_rows) + + if "Сотрудник" not in df_res.columns and "ФИО" in df_res.columns: df_res["Сотрудник"] = df_res["ФИО"] - df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio) - elif "fio_clean" not in df_res.columns: - df_res["fio_clean"] = "" for col, default_val in [ ('Начало_дня', 'Нет входа'), @@ -163,20 +212,46 @@ def merge_scud_and_1c( ('Конец_дня', 'Нет выхода'), ('Находился_в_здании', '00:00'), ('Пришел', False), - ('anomaly_flag', 'NONE') + ('anomaly_flag', 'NONE'), + ('not_hired_yet', False), + ('no_scud_pass', False) ]: if col not in df_res.columns: df_res[col] = default_val + # Словарь синонимов и принудительное сокращение длинных отделов 1С до аббревиатур reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} - all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} + all_dept_map = { + **reverse_synonyms, + **direct_synonyms, + "отдел внутреннего контроля": "ОВК", + "отдел вневедомственного контроля": "ОВК", + "отдел авторского надзора и технического аудита": "ОАН", + "отдел инженерных изысканий": "ОИЗ", + "правовое управление": "ПУ", + "макетная мастерская": "ММ", + "отдел автоматизации": "ОА", + "испытательная геотехническая лаборатория лабораторного центра": "ИГТЛЛ", + "отдел экономики, смет и организации строительства": "ОЭС", + "отдел электротехники, связи и пожарной автоматики": "ОЭСС", + "бетонная лаборатория": "БЛ", + "управление главных инженеров проектов №1": "УГИП №1", + "управление главных инженеров проектов №2": "УГИП №2", + "строительный отдел": "СО", + "гидротехническая экспедиция": "ГЭ", + "планово-экономический отдел": "ПЭО", + "конструкторский отдел": "КО", + "отдел тепловодоснабжения и канализации": "ОТВК", + "электротехнический отдел": "ЭТО" + } if "Подразделение" in df_res.columns: df_res["Подразделение"] = df_res["Подразделение"].apply( - lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) + lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) if pd.notna(d) else "—" ) + # Привязка кадровых документов 1С absences_map = {} if df_absences_1c is not None and not df_absences_1c.empty: fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None) @@ -186,13 +261,12 @@ def merge_scud_and_1c( for _, row in df_absences_1c.iterrows(): fio = normalize_fio(str(row[fio_col])) reason = str(row[reason_col]).strip() - if reason and reason.lower() != "nan": + if reason and reason.lower() not in ["nan", "none"]: absences_map[fio] = reason manual_reasons_map = {} try: from services.manual_absences_repo import get_active_manual_absences_for_date - # date_clean берется из даты контекста либо из текущих суток target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None if target_date_val: m_records = get_active_manual_absences_for_date(str(target_date_val)) @@ -205,6 +279,7 @@ def merge_scud_and_1c( df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["Вид_отсутствия"] = df_res["причина отсутствия"] + # Исключения и белый список exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d] exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] @@ -226,10 +301,7 @@ def merge_scud_and_1c( is_match_exc = (fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw)) if is_match_exc: - if has_official_absence: - df_res.at[idx, "is_excluded"] = False - else: - df_res.at[idx, "is_excluded"] = True + df_res.at[idx, "is_excluded"] = not has_official_absence mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" @@ -239,38 +311,54 @@ def merge_scud_and_1c( def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: - total_staff = len(df_merged) + # Создаем независимую копию среза штата с собственным непрерывным индексом + df_staff_only = df_merged[~df_merged.get('not_hired_yet', False)].copy().reset_index(drop=True) + total_staff = len(df_staff_only) - came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False) - exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & ( - df_merged["Вид_отсутствия"].isna() | - df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"]) + is_exc_staff = df_staff_only.get('is_excluded', False) == True + is_no_pass_staff = df_staff_only.get('no_scud_pass', False) == True + + came_to_office_mask = (df_staff_only["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (~is_exc_staff) + exc_without_doc_mask = (is_exc_staff) & ( + df_staff_only["Вид_отсутствия"].isna() | + df_staff_only["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"]) ) - working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask]) + working_in_office_count = int((came_to_office_mask | exc_without_doc_mask).sum()) - df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask] + df_not_working = df_staff_only[~came_to_office_mask & ~exc_without_doc_mask].copy().reset_index(drop=True) reason_series = df_not_working["причина отсутствия"].astype(str).str.lower() is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) - remote_home = df_not_working[is_remote_mask] - remote_home_count = len(remote_home) + remote_home_count = int(is_remote_mask.sum()) - df_remaining_absent = df_not_working[~is_remote_mask] - has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ - df_remaining_absent["причина отсутствия"].ne("") & \ - df_remaining_absent["причина отсутствия"].ne("nan") & \ - (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) - official_absent_count = len(df_remaining_absent[has_doc_mask]) + df_remaining_absent = df_not_working[~is_remote_mask].copy().reset_index(drop=True) + has_doc_mask = ( + df_remaining_absent["причина отсутствия"].notna() & + df_remaining_absent["причина отсутствия"].ne("") & + df_remaining_absent["причина отсутствия"].ne("nan") & + (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) + ) + official_absent_count = int(has_doc_mask.sum()) - unknown = df_remaining_absent[~has_doc_mask] + # Неизвестно: среди тех, у кого нет официального документа и кто имеет пропуск + is_no_pass_remaining = df_remaining_absent.get('no_scud_pass', False) == True + unknown = df_remaining_absent[~has_doc_mask & ~is_no_pass_remaining] unknown_count = len(unknown) + no_pass_count = int((is_no_pass_staff & ~is_exc_staff).sum()) + + is_not_hired_all = df_merged.get('not_hired_yet', False) == True + is_exc_all = df_merged.get('is_excluded', False) == True + not_hired_count = int((is_not_hired_all & ~is_exc_all).sum()) + 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, + "no_pass_count": no_pass_count, + "not_hired_count": not_hired_count, "unknown_count": unknown_count, "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else [] } \ No newline at end of file