feat(etl): stable pipeline, exception registry in SQLite, multi-pass aggregation and db_cli
This commit is contained in:
+98
-41
@@ -7,48 +7,65 @@ from datetime import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from config import OUTPUT_DIR
|
||||
from config import REPORTS_DIR
|
||||
|
||||
# Словарь месяцев для текстового формата в названиях файлов
|
||||
MONTHS_RU = {
|
||||
MONTHS_RU_GENITIVE = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
|
||||
}
|
||||
|
||||
MONTHS_RU_NOMINATIVE = {
|
||||
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
|
||||
5: "май", 6: "июнь", 7: "июль", 8: "август",
|
||||
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
|
||||
}
|
||||
|
||||
|
||||
def format_date_ru(date_str):
|
||||
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%d.%m.%Y")
|
||||
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
|
||||
return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
|
||||
# Границы ячеек
|
||||
def get_dated_reports_dir(date_str):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%d.%m.%Y")
|
||||
year_str = str(dt.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[dt.month]
|
||||
except Exception:
|
||||
now = datetime.now()
|
||||
year_str = str(now.year)
|
||||
month_name = MONTHS_RU_NOMINATIVE[now.month]
|
||||
|
||||
target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
return target_dir
|
||||
|
||||
|
||||
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
|
||||
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
|
||||
|
||||
# Палитра заливки для Сводки
|
||||
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
||||
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid")
|
||||
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска"
|
||||
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
|
||||
FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений
|
||||
|
||||
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"]
|
||||
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"]
|
||||
|
||||
# Палитра для Детального отчета
|
||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
|
||||
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы
|
||||
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
|
||||
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска"
|
||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
||||
LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин"""
|
||||
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
|
||||
return "0:00"
|
||||
|
||||
@@ -101,11 +118,12 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f
|
||||
|
||||
|
||||
# --- 1. СВОДКА НА СЕГОДНЯ ---
|
||||
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} сводка.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
target_dir = get_dated_reports_dir(date_str)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Лист_1"
|
||||
@@ -132,11 +150,10 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
|
||||
current_row = 4
|
||||
|
||||
# Подготовка флагов для точной фильтрации
|
||||
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
|
||||
|
||||
# 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана")
|
||||
# 3. НЕИЗВЕСТНО
|
||||
unexplained = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
|
||||
@@ -155,7 +172,7 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = False
|
||||
current_row += 1
|
||||
|
||||
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок)
|
||||
# 4. РАЗДЕЛ: НЕТ ПРОПУСКА
|
||||
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Нет пропуска")
|
||||
@@ -171,11 +188,16 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = False
|
||||
current_row += 1
|
||||
|
||||
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
|
||||
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений)
|
||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
|
||||
absent_only = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~is_exc)
|
||||
]
|
||||
absent_groups = absent_only.groupby('Вид_отсутствия')
|
||||
|
||||
@@ -195,11 +217,8 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
|
||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
||||
)
|
||||
present = merged_df[is_working_mask]
|
||||
# 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД)
|
||||
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Итого на работе")
|
||||
ws.cell(row=current_row, column=2, value=len(present))
|
||||
@@ -214,10 +233,29 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
|
||||
# 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе)
|
||||
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
|
||||
ws.cell(row=current_row, column=2, value=len(remote_home))
|
||||
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
|
||||
current_row += 1
|
||||
|
||||
if not remote_home.empty:
|
||||
for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
|
||||
ws.cell(row=current_row, column=1, value=fio)
|
||||
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False)
|
||||
ws.row_dimensions[current_row].outlineLevel = 1
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 7. АНОМАЛИИ СКУД И 1С
|
||||
anomalies = merged_df[
|
||||
(~is_exc) & (
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
|
||||
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
|
||||
(~is_remote_reason) &
|
||||
(~reason_clean.str.contains('командировк', na=False))) |
|
||||
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
|
||||
)
|
||||
]
|
||||
@@ -262,6 +300,22 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список)
|
||||
exceptions_df = merged_df[is_exc]
|
||||
|
||||
ws.cell(row=current_row, column=1, value="Исключения")
|
||||
ws.cell(row=current_row, column=2, value=len(exceptions_df))
|
||||
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
|
||||
current_row += 1
|
||||
|
||||
if not exceptions_df.empty:
|
||||
for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
|
||||
ws.cell(row=current_row, column=1, value=fio)
|
||||
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
|
||||
ws.row_dimensions[current_row].outlineLevel = 1
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
ws.column_dimensions['A'].width = 45.0
|
||||
ws.column_dimensions['B'].width = 38.0
|
||||
|
||||
@@ -270,17 +324,23 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
|
||||
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} отчет.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
if merged_df is not None and not merged_df.empty:
|
||||
df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
|
||||
else:
|
||||
df_export = pd.DataFrame()
|
||||
|
||||
target_dir = get_dated_reports_dir(date_str)
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Детальный_отчет"
|
||||
@@ -305,13 +365,13 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
apply_borders_to_cell(cell)
|
||||
|
||||
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
|
||||
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
|
||||
|
||||
chars_per_line_h = 24
|
||||
|
||||
for idx, row in merged_df.reset_index(drop=True).iterrows():
|
||||
for idx, row in df_export.reset_index(drop=True).iterrows():
|
||||
is_present = row.get('Пришел', False)
|
||||
absence_reason = row.get('Вид_отсутствия', '')
|
||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
||||
@@ -339,13 +399,11 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
|
||||
row_num = 5 + idx
|
||||
|
||||
# ЗАЛИВКА СТРОК:
|
||||
if is_present and has_reason:
|
||||
row_fill = GREEN_FILL
|
||||
elif not is_present and has_reason:
|
||||
row_fill = YELLOW_FILL
|
||||
elif not is_present and not has_reason and not has_first_act:
|
||||
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
|
||||
row_fill = LIGHT_RED_FILL
|
||||
else:
|
||||
row_fill = None
|
||||
@@ -370,7 +428,6 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
else:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
# Динамический компактный автоподгон ширины колонок
|
||||
for col in ws.columns:
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
max_len = 0
|
||||
@@ -391,11 +448,11 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
print(f"[✓] Детальный отчет сохранен: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
alt_path = os.path.join(target_dir, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
output_path = os.path.join(REPORTS_DIR, filename)
|
||||
df_scud.to_excel(output_path, index=False)
|
||||
Reference in New Issue
Block a user