feat(reports): add simplified report builder, fix nan/inf crash and separate unhired/no-pass staff

This commit is contained in:
2026-09-24 09:25:57 +03:00
parent 9c89b6592e
commit 30651672ab
10 changed files with 437 additions and 89 deletions
+27
View File
@@ -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 ## [2.5.8] - 2026-09-10
### Добавлено (Added) ### Добавлено (Added)
+20
View File
@@ -194,3 +194,23 @@
- [ ] **Динамические Python/Pandas вычисления:** - [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2]. - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2].
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2]. - Перехват 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`).
+18 -4
View File
@@ -134,12 +134,24 @@ def main():
if df_scud_today is not None and not df_scud_today.empty: if df_scud_today is not None and not df_scud_today.empty:
export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx") export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx")
# [Этап 3] Детальный отчет за вчера через otchet_generator # [Этап 3] Детальный и Упрощенный отчеты за вчера
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") print(f"\n[3/5] Обработка и построение детального и упрощенного отчетов за ВЧЕРА ({yesterday_str})...")
res_otchet = generate_otchet_service(target_date=yesterday_str) res_otchet = generate_otchet_service(target_date=yesterday_str)
if res_otchet.get("status") == "success": if res_otchet.get("status") == "success":
print(f"[✓] {res_otchet.get('message')}: {res_otchet.get('filepath')}") 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 # [Этап 4] Сводка за сегодня через svodka_generator
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...") print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str} {args.time or ''}...")
res_svodka = generate_svodka_service( res_svodka = generate_svodka_service(
@@ -165,8 +177,10 @@ def main():
] ]
absent_unexplained = df_merged_today[ absent_unexplained = df_merged_today[
(df_merged_today['Пришел'] == False) & (df_merged_today['Пришел'] == False) &
(df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & (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('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( summary_md = generate_markdown_report(
+8 -8
View File
@@ -1,14 +1,14 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd /home/puh/projects/scud_ai cd /home/apushkov/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs mkdir -p /home/apushkov/projects/scud_ai/logs
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/puh/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/puh/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 "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/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
+5 -5
View File
@@ -1,9 +1,9 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd /home/puh/projects/scud_ai cd /home/apushkov/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs 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 echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/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 /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/puh/projects/scud_ai/logs/cron_hourly.log echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_hourly.log
+5 -5
View File
@@ -1,9 +1,9 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd /home/puh/projects/scud_ai cd /home/apushkov/projects/scud_ai
mkdir -p /home/puh/projects/scud_ai/logs 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 echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/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 /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/puh/projects/scud_ai/logs/cron_reports.log echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/apushkov/projects/scud_ai/logs/cron_reports.log
+33 -9
View File
@@ -18,12 +18,17 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
if not filename: if not filename:
filename = f"{format_date_ru(date_clean)} отчет.xlsx" 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) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) 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("Детальный_отчет") ws = wb.add_worksheet("Детальный_отчет")
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True}) 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(): for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx row_num = 4 + idx
is_present = row.get('Пришел', False) is_present = bool(row.get('Пришел', False))
absence_reason = row.get('Вид_отсутствия', '') 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() 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() 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() 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'] 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) 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 row_color = None
if is_present and has_reason: 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: elif not is_present and not has_reason and not has_first_act:
row_color = '#FCE4D6' 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 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)) ws.set_row(row_num, max(lines_count * 18, 20))
values = [ values = [
(idx + 1, 'center', False), (idx + 1, 'center', False),
(row.get('Сотрудник', ''), 'left', False), (fio_val, 'left', False),
(dept_scud_val, 'center', False), (dept_scud_val, 'center', False),
(in_val, 'center', False), (in_val, 'center', False),
(first_act_val, 'center', False), (first_act_val, 'center', False),
(out_val, 'center', False), (out_val, 'center', False),
(in_building_str, 'center', False), (in_building_str, 'center', False),
(absence_reason if has_reason else '', 'left', True), (val_h_str, 'left', True),
(8, 'center', False), (8, 'center', False),
(deviation_val, 'center', False) (deviation_val, 'center', False)
] ]
for col_idx, (val, align_type, is_wrap) in enumerate(values): 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) 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) ws.write(row_num, col_idx, val, fmt)
+147
View File
@@ -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
+52 -24
View File
@@ -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) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) 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 = wb.add_worksheet("Лист_1")
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False) 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, 0, "", fmt_empty)
ws.write(1, 1, "", 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.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l) 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 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. Неизвестно # 1. Неизвестно
unexplained = merged_df[ unexplained = merged_df[
(~is_not_hired) &
(merged_df['Пришел'] == False) & (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) (~is_no_pass) & (~is_exc)
] ]
fmt_unexp_hl = create_xlsx_format(wb, bg_color='#FCE4D6', bold=True, align="left") 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()): for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) 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) ws.write(current_row, 1, "", fmt_unexp_rr)
current_row += 1 current_row += 1
# 2. Нет пропуска # 2. Нет пропуска (в штате 1С есть, но карты СКУД нет)
no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() 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_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_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_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") fmt_np_rr = create_xlsx_format(wb, bg_color='#E1F5FE', bold=False, align="right")
ws.set_row(current_row, 20) 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) ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
current_row += 1 current_row += 1
if not no_pass_df.empty: if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False}) 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) ws.write(current_row, 1, "", fmt_np_rr)
current_row += 1 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() reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
absent_only = merged_df[ absent_only = merged_df[
(~is_not_hired) &
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~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") fmt_cat_rr = create_xlsx_format(wb, bg_color=hex_c, bold=False, align="right")
ws.set_row(current_row, 20) 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) ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1 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(): for _, row in group.sort_values(by='Сотрудник').iterrows():
fio = row.get('Сотрудник', '') fio = row.get('Сотрудник', '')
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else "" 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.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, 0, str(fio), fmt_cat_rl)
ws.write(current_row, 1, detail_val, fmt_cat_rr) ws.write(current_row, 1, str(detail_val), fmt_cat_rr)
current_row += 1 current_row += 1
# 4. Итого на работе # 5. Итого на работе (только принятые)
exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))] 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[(merged_df['Пришел'] == True) & (~is_exc)] present_scud = merged_df[(~is_not_hired) & (merged_df['Пришел'] == True) & (~is_exc)]
total_present_count = len(present_scud) + len(exc_without_doc) total_present_count = len(present_scud) + len(exc_without_doc)
fmt_pres_hl = create_xlsx_format(wb, bg_color='#E2EFDA', bold=True, align="left") 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) ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1 current_row += 1
# 5. Удаленная работа # 6. Удаленная работа
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] 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_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_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") 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: if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()): for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True}) 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) ws.write(current_row, 1, "", fmt_rem_rr)
current_row += 1 current_row += 1
# 6. Аномалии СКУД и 1С # 7. Аномалии СКУД и 1С
anomalies = merged_df[ anomalies = merged_df[
(~is_exc) & ( (~is_not_hired) & (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) & (~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 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.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, 0, str(fio), fmt_anom_rl)
ws.write(current_row, 1, reason_text, fmt_anom_rr) ws.write(current_row, 1, str(reason_text), fmt_anom_rr)
current_row += 1 current_row += 1
ws.set_column(0, 0, 45) ws.set_column(0, 0, 45)
+121 -33
View File
@@ -138,7 +138,8 @@ def merge_scud_and_1c(
return pd.DataFrame(columns=[ return pd.DataFrame(columns=[
'Сотрудник', 'fio_clean', 'Подразделение', 'Должность', 'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded' 'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия',
'is_excluded', 'not_hired_yet', 'no_scud_pass'
]) ])
synonyms = get_department_synonyms_dict() synonyms = get_department_synonyms_dict()
@@ -147,15 +148,63 @@ def merge_scud_and_1c(
df_scud_agg = aggregate_scud_by_person(df_scud) df_scud_agg = aggregate_scud_by_person(df_scud)
df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg) 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: merged_rows = []
df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
elif "ФИО" in df_res.columns: # 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["Сотрудник"] = 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 [ for col, default_val in [
('Начало_дня', 'Нет входа'), ('Начало_дня', 'Нет входа'),
@@ -163,20 +212,46 @@ def merge_scud_and_1c(
('Конец_дня', 'Нет выхода'), ('Конец_дня', 'Нет выхода'),
('Находился_в_здании', '00:00'), ('Находился_в_здании', '00:00'),
('Пришел', False), ('Пришел', False),
('anomaly_flag', 'NONE') ('anomaly_flag', 'NONE'),
('not_hired_yet', False),
('no_scud_pass', False)
]: ]:
if col not in df_res.columns: if col not in df_res.columns:
df_res[col] = default_val df_res[col] = default_val
# Словарь синонимов и принудительное сокращение длинных отделов 1С до аббревиатур
reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} 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: if "Подразделение" in df_res.columns:
df_res["Подразделение"] = df_res["Подразделение"].apply( 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 = {} absences_map = {}
if df_absences_1c is not None and not df_absences_1c.empty: 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) 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(): for _, row in df_absences_1c.iterrows():
fio = normalize_fio(str(row[fio_col])) fio = normalize_fio(str(row[fio_col]))
reason = str(row[reason_col]).strip() 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 absences_map[fio] = reason
manual_reasons_map = {} manual_reasons_map = {}
try: try:
from services.manual_absences_repo import get_active_manual_absences_for_date 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 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: if target_date_val:
m_records = get_active_manual_absences_for_date(str(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["fio_clean"].map(absences_map)
df_res["Вид_отсутствия"] = df_res["причина отсутствия"] df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
# Исключения и белый список
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] 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_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] 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)) 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 is_match_exc:
if has_official_absence: df_res.at[idx, "is_excluded"] = not has_official_absence
df_res.at[idx, "is_excluded"] = False
else:
df_res.at[idx, "is_excluded"] = True
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" 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]: 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) is_exc_staff = df_staff_only.get('is_excluded', False) == True
exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & ( is_no_pass_staff = df_staff_only.get('no_scud_pass', False) == True
df_merged["Вид_отсутствия"].isna() |
df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"]) 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() reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_working[is_remote_mask] remote_home_count = int(is_remote_mask.sum())
remote_home_count = len(remote_home)
df_remaining_absent = df_not_working[~is_remote_mask] df_remaining_absent = df_not_working[~is_remote_mask].copy().reset_index(drop=True)
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ has_doc_mask = (
df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].notna() &
df_remaining_absent["причина отсутствия"].ne("nan") & \ df_remaining_absent["причина отсутствия"].ne("") &
(~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) df_remaining_absent["причина отсутствия"].ne("nan") &
official_absent_count = len(df_remaining_absent[has_doc_mask]) (~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) 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 { return {
"total_staff": total_staff, "total_staff": total_staff,
"working_in_office_count": working_in_office_count, "working_in_office_count": working_in_office_count,
"remote_home_count": remote_home_count, "remote_home_count": remote_home_count,
"official_absent_count": official_absent_count, "official_absent_count": official_absent_count,
"no_pass_count": no_pass_count,
"not_hired_count": not_hired_count,
"unknown_count": unknown_count, "unknown_count": unknown_count,
"unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else [] "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
} }