feat(web): стабилизация UI, Gemini-скроллинг, роутеры контекста/снапшотов и актуализация роадмапа

This commit is contained in:
2026-09-07 17:29:21 +03:00
parent 1bb95cd8e1
commit d391a08224
33 changed files with 7768 additions and 1832 deletions
+74 -16
View File
@@ -7,6 +7,7 @@ ROLE: Надежная загрузка штата и отсутствий (MS S
import os
import warnings
from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ
import pandas as pd
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
@@ -101,27 +102,84 @@ def load_absent_data(date_str):
if df_absent is None:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# Обогащение удаленщиками из static_reason_workers.csv
# Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей
try:
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = []
for _, s_row in df_static.iterrows():
if s_row['fio_clean'] not in existing_fios:
new_rows.append({
'fio_clean': s_row['fio_clean'],
'Вид_отсутствия': s_row['reason']
df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("")
for col in ['fio', 'reason', 'department', 'date_from', 'date_to']:
if col not in df_static.columns:
df_static[col] = ""
today_date = datetime.now().date()
# Разбор целевой даты отчета
clean_target_str = str(date_str).replace('_', '.')
try:
clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date()
except ValueError:
clean_target_date = today_date
def parse_date_safe(d_val):
if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']:
return None
s = str(d_val).strip().replace('_', '.')
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
return None
active_for_file = []
new_rows_for_report = []
file_changed = False
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
for _, s_row in df_static.iterrows():
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
})
if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv")
existing_fios.add(fio_c)
# Перезаписываем CSV только если реально были удалены просроченные сотрудники
if file_changed:
pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8')
print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.")
if new_rows_for_report:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}")
except Exception as e:
print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}")
print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
return df_absent if not df_absent.empty else None
+283 -218
View File
@@ -1,12 +1,18 @@
import math
"""
===============================================================================
FILE: services/excel_exporter.py
ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
Корректный расчет часов удаленщиков и исключение лишних списков.
===============================================================================
"""
import os
import math
import time
import openpyxl
import pandas as pd
import xlsxwriter
from datetime import datetime, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from xlsxwriter.exceptions import FileCreateError
from config import REPORTS_DIR
MONTHS_RU_GENITIVE = {
@@ -47,29 +53,25 @@ def get_dated_reports_dir(date_str):
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_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid")
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")
def safe_close_workbook(wb, output_path, target_dir, filename):
try:
wb.close()
print(f"[✓] Успешно сохранен: {output_path}")
return output_path
except (FileCreateError, OSError, PermissionError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
try:
wb.filename = alt_path
wb._store_workbook()
print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}")
return alt_path
except Exception as e:
print(f"[❌] Ошибка сохранения даже резервного файла: {e}")
return output_path
def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
"""
⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода
рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00.
"""
try:
parts = time_in_str.strip().split(':')
hh = int(parts[0])
@@ -84,10 +86,25 @@ def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
"""
Расчет отклонения от нормы.
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
"""
reason_clean = str(reason).strip().lower() if pd.notna(reason) else ""
is_remote = "удален" in reason_clean or "дистанцион" in reason_clean
has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN']
# Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка
if reason_clean != "" and not is_remote:
return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
# Если удаленщик работал исключительно из дома (00:00 в здании)
if is_remote and not has_building_time:
return "0:00"
# Если сотрудника не было в здании и нет уважительной причины
if not has_building_time:
return f"-{norm_hours}:00"
try:
@@ -97,7 +114,7 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
total_in_building_minutes = hh * 60 + mm
if total_in_building_minutes == 0:
return f"-{norm_hours}:00"
return "0:00" if is_remote else f"-{norm_hours}:00"
work_minutes = max(0, total_in_building_minutes - lunch_minutes)
norm_minutes = norm_hours * 60
@@ -113,29 +130,12 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
return f"{sign}{res_hh}:{res_mm:02d}"
except Exception:
return f"-{norm_hours}:00"
return "0:00" if is_remote else f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER):
cell.border = border
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ ---
# =============================================================================
# 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ
# =============================================================================
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename:
@@ -143,65 +143,92 @@ 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 = Workbook()
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False
ws.sheet_properties.outlinePr.summaryRight = False
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Лист_1")
bold_font = Font(name="Calibri", size=11, bold=True)
ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
ws.cell(row=1, column=1, value="Сводка на")
ws.cell(row=1, column=2, value=date_clean)
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
d = {
'font_name': 'Calibri',
'font_size': 11,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
apply_borders_to_cell(ws.cell(row=2, column=1))
apply_borders_to_cell(ws.cell(row=2, column=2))
fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left")
fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right")
fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left")
fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right")
fmt_empty = make_fmt()
ws.cell(row=3, column=1, value="По списку")
ws.cell(row=3, column=2, value=len(merged_df))
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
ws.set_row(0, 20)
ws.write(0, 0, "Сводка на", fmt_hdr_l)
ws.write(0, 1, date_clean, fmt_hdr_r)
current_row = 4
ws.set_row(1, 20)
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, len(merged_df), 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[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].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, 1, "", fmt_unexp_rr)
current_row += 1
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="Нет пропуска")
ws.cell(row=current_row, column=2, value=len(no_pass_df))
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
# 2. Нет пропуска (Раскрыто по умолчанию)
no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left")
fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right")
fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left")
fmt_np_rr = make_fmt(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, 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.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].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, 1, "", fmt_np_rr)
current_row += 1
# 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда)
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
@@ -209,55 +236,63 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) &
(~is_exc)
(~is_remote_reason)
]
absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
ws.cell(row=current_row, column=1, value=cat_name)
ws.cell(row=current_row, column=2, value=len(group))
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_c = pastels[idx_cat % len(pastels)]
fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left")
fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right")
fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left")
fmt_cat_rr = make_fmt(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, 1, len(group), fmt_cat_hr)
current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = 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, 1, "", fmt_cat_rr)
current_row += 1
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))
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
# 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)]
total_present_count = len(present_scud) + len(exc_without_doc)
fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left")
fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1
if not present.empty:
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 5. Удаленная работа (Свернуто)
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)
fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left")
fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right")
fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left")
fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
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
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, 1, "", fmt_rem_rr)
current_row += 1
# 6. Аномалии СКУД и 1С (Свернуто)
anomalies = merged_df[
(~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
@@ -267,10 +302,14 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
ws.cell(row=current_row, column=2, value=len(anomalies))
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True)
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
current_row += 1
chars_per_line_b = 30
@@ -285,50 +324,24 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
else:
reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.row_dimensions[current_row].height = max(lines_count * 18, 20)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
row_h = max(lines_count * 18, 20)
ws.set_row(current_row, row_h, 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)
current_row += 1
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
try:
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
ws.set_column(0, 0, 45)
ws.set_column(1, 1, 38)
safe_close_workbook(wb, output_path, target_dir, filename)
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) ---
# =============================================================================
# 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА
# =============================================================================
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.')
if not filename:
@@ -341,29 +354,37 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename)
wb = Workbook()
ws = wb.active
ws.title = "Детальный_отчет"
ws["B2"] = "Дата:"
ws["D2"] = date_clean
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True)
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Детальный_отчет")
def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
d = {
'font_name': 'Arial',
'font_size': 10,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
ws.write(1, 1, "Дата:", fmt_date_lbl)
ws.write(1, 3, date_clean, fmt_date_lbl)
headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
ws.append([])
ws.append(headers)
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=4, column=col_idx)
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True)
ws.set_row(3, 26)
for col_idx, h_text in enumerate(headers):
ws.write(3, col_idx, h_text, fmt_hdr)
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
@@ -372,6 +393,7 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
chars_per_line_h = 24
for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx
is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
@@ -382,7 +404,6 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
# ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия)
if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason:
out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val)
else:
@@ -390,60 +411,104 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
ws.append([
idx + 1,
row.get('Сотрудник', ''),
dept_scud_val,
in_val,
first_act_val,
out_val,
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
row_color = None
if is_present and has_reason:
row_fill = GREEN_FILL
row_color = '#E2EFDA'
elif not is_present and has_reason:
row_fill = YELLOW_FILL
row_color = '#FFF2CC'
elif not is_present and not has_reason and not has_first_act:
row_fill = LIGHT_RED_FILL
else:
row_fill = None
row_color = '#FCE4D6'
val_h_str = str(absence_reason) if has_reason else ""
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
ws.row_dimensions[row_num].height = max(lines_count * 18, 20)
ws.set_row(row_num, max(lines_count * 18, 20))
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_idx)
apply_borders_to_cell(cell)
if row_fill:
cell.fill = row_fill
if col_idx == 8:
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
cell.alignment = Alignment(horizontal="center", vertical="center")
else:
cell.alignment = Alignment(horizontal="left", vertical="center")
values = [
(idx + 1, 'center', False),
(row.get('Сотрудник', ''), '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),
(8, 'center', False),
(deviation_val, 'center', False)
]
for col in ws.columns:
col_letter = get_column_letter(col[0].column)
max_len = max((len(str(cell.value or '')) for cell in col), default=8)
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35)
for col_idx, (val, align_type, is_wrap) in enumerate(values):
fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap)
ws.write(row_num, col_idx, val, fmt)
try:
wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}")
except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
col_widths = {
0: 4,
1: 33,
2: 13,
3: 11,
4: 11,
5: 11,
6: 12,
7: 24,
8: 6,
9: 11
}
for col_idx, width in col_widths.items():
ws.set_column(col_idx, col_idx, width)
safe_close_workbook(wb, output_path, target_dir, filename)
# =============================================================================
# 3. СЫРОЙ СКУД
# =============================================================================
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(REPORTS_DIR, filename)
df_scud.to_excel(output_path, index=False)
target_dir = os.path.dirname(output_path)
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Сырые_данные")
fmt_hdr = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter'
})
fmt_cell = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left'
})
headers = list(df_scud.columns)
ws.set_row(3, 28)
for col_idx, header in enumerate(headers):
ws.write(0, col_idx, str(header), fmt_hdr)
col_widths = [len(str(h)) for h in headers]
for row_idx, row_values in enumerate(df_scud.values, start=1):
ws.set_row(row_idx, 19)
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
ws.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
safe_close_workbook(wb, output_path, target_dir, filename)
+21 -20
View File
@@ -2,6 +2,7 @@
===============================================================================
FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
===============================================================================
"""
@@ -17,10 +18,8 @@ logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]:
"""Загружает подтвержденные сопоставления ФИО (СКУД -> 1С:ЗУП) из SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
# Автоматическая инициализация таблицы при первом обращении
cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -44,7 +43,6 @@ def load_identity_mappings() -> Dict[str, str]:
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
"""Схлопывает дубликаты пропусков одного человека в СКУД."""
if df_scud is None or df_scud.empty:
return df_scud
@@ -53,7 +51,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio)
# Применяем сохраненный кэш связок ФИО
mapping_dict = load_identity_mappings()
if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
@@ -95,10 +92,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
"""
⭐️ Для совместителей с несколькими должностями в 1С:ЗУП
выбирает ту ставку, которая соответствует отделу физического нахождения по СКУД.
"""
if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame()
@@ -210,15 +203,20 @@ def merge_scud_and_1c(
fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios or has_official_absence:
if fio in whitelist_fios:
df_res.at[idx, "is_excluded"] = False
continue
dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower()
if 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):
df_res.at[idx, "is_excluded"] = True
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
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
@@ -228,21 +226,24 @@ def merge_scud_and_1c(
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
active_df = df_merged[df_merged.get("is_excluded", False) == False].copy()
total_staff = len(active_df)
total_staff = len(df_merged)
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа")
working_in_office = active_df[came_to_office_mask]
working_in_office_count = len(working_in_office)
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", "Исключение"])
)
df_not_came = active_df[~came_to_office_mask]
working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask])
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask]
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_came[is_remote_mask]
remote_home = df_not_working[is_remote_mask]
remote_home_count = len(remote_home)
df_remaining_absent = df_not_came[~is_remote_mask]
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") & \
+95 -57
View File
@@ -1,6 +1,9 @@
"""
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
===============================================================================
FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
===============================================================================
"""
import argparse
@@ -10,9 +13,14 @@ import sys
import warnings
from datetime import datetime, timedelta
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import pandas as pd
import pyodbc
from openpyxl.utils import get_column_letter
import xlsxwriter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
@@ -31,12 +39,17 @@ logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
try:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
except (PermissionError, OSError) as e:
sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n")
_console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler)
@@ -73,8 +86,8 @@ WITH DailyLogs AS (
log.Event,
log.Mode,
CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
WHEN log.Mode = 2 OR log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (1, 21, 26, 28, 32, 54, 64) THEN 'IN'
ELSE 'OTHER'
END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
@@ -82,7 +95,7 @@ WITH DailyLogs AS (
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
@@ -90,10 +103,22 @@ Passages AS (
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
-- Реальный выход: должен быть позже входа минимум на 5 минут (отсекает проезд через шлагбаум/парковку)
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS RawLastOut
FROM DailyLogs
GROUP BY EmployeeID
),
EvaluatedPassages AS (
SELECT
p.*,
CASE
WHEN p.RawLastOut IS NOT NULL
AND p.FirstIn IS NOT NULL
AND p.RawLastOut > DATEADD(MINUTE, 5, p.FirstIn)
THEN p.RawLastOut
ELSE NULL
END AS FilteredLastOut
FROM Passages p
)
SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
@@ -115,12 +140,8 @@ SELECT
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода'
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
WHEN pass.FilteredLastOut IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
@@ -128,14 +149,16 @@ SELECT
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
@@ -146,7 +169,7 @@ SELECT
FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
WHERE
ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
@@ -164,24 +187,54 @@ ORDER BY p.Name ASC;
"""
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
from openpyxl import load_workbook
def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
worksheet = workbook.add_worksheet(sheet_name)
wb = load_workbook(file_path)
ws = wb[sheet_name]
fmt_header = workbook.add_format({
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter',
'font_name': 'Calibri',
'font_size': 11
})
for col_cells in ws.columns:
max_len = 0
col_letter = get_column_letter(col_cells[0].column)
for cell in col_cells:
if cell.value is not None:
cell_len = len(str(cell.value))
if cell_len > max_len:
max_len = cell_len
width = max(min_width, min(max_len + padding, max_width))
ws.column_dimensions[col_letter].width = width
fmt_cell = workbook.add_format({
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left',
'font_name': 'Calibri',
'font_size': 11
})
wb.save(file_path)
headers = list(df.columns)
col_widths = [len(str(h)) for h in headers]
for col_idx, header in enumerate(headers):
worksheet.write(0, col_idx, str(header), fmt_header)
for row_idx, row_values in enumerate(df.values, start=1):
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
worksheet.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
workbook.close()
def get_targets(input_date: str | None):
@@ -230,8 +283,6 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
period_label = target["name"]
is_yesterday = (period_label == "Вчера")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue
@@ -273,8 +324,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет")
save_df_to_clean_excel(df, file_path, sheet_name="Отчет")
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
@@ -291,22 +341,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
if __name__ == "__main__":
help_text = """
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
ПРИМЕРЫ ЗАПУСКА:
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
parser = argparse.ArgumentParser()
parser.add_argument("--date", dest="input_date", default=None)
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
args = parser.parse_args()
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+10 -1
View File
@@ -1,3 +1,10 @@
"""
===============================================================================
FILE: services/share_copier.py
ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/.
===============================================================================
"""
import os
import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
@@ -52,7 +59,9 @@ def copy_1c_files_from_share():
local_target_path = os.path.join(ZUP_1C_DIR, filename)
try:
shutil.copy2(remote_file, local_target_path)
# shutil.copyfile копирует только содержимое потока байтов
# без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре
shutil.copyfile(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1
except Exception as e:
+112
View File
@@ -0,0 +1,112 @@
"""
===============================================================================
FILE: services/snapshots/retention.py
ROLE: Политика ротации и очистки промежуточных почасовых срезов в SQLite.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple
from core.connection import get_connection
logger = logging.getLogger("SNAPSHOT_RETENTION")
def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
"""
Очищает промежуточные дневные срезы старше days_to_keep_all дней.
Правило сохранения для архивных дней:
- Сохраняется срез Y (23:59:59).
- Сохраняется полуденный срез (ближайший к 13:00).
- Все остальные промежуточные снапшоты удаляются из scud_logs.
Возвращает количество удаленных записей.
"""
cutoff_date = datetime.now() - timedelta(days=days_to_keep_all)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
deleted_rows_count = 0
with get_connection() as conn:
cursor = conn.cursor()
# 1. Получаем список всех уникальных дат старше cutoff_date
cursor.execute("""
SELECT DISTINCT log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
""")
all_dates = [row[0] for row in cursor.fetchall()]
for raw_date in all_dates:
try:
# Преобразуем формат ДД.ММ.ГГГГ в объект даты
d_clean = raw_date.replace('_', '.')
dt_obj = datetime.strptime(d_clean, "%d.%m.%Y")
if dt_obj >= cutoff_date:
continue # Свежие дни не трогаем — там хранятся все срезы
# 2. Для архивной даты ищем все снапшоты
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ?
""", (raw_date,))
snapshots = cursor.fetchall()
if len(snapshots) <= 2:
continue # Если и так 1-2 среза, ничего чистить не нужно
keep_ids = set()
# Ищем финальный срез Y / 23:59:59 / 22:00:00
for s_id, s_time in snapshots:
s_id_str = str(s_id)
s_time_str = str(s_time)
if s_id_str.startswith('Y') or '23:59:59' in s_time_str or '22:00:00' in s_time_str:
keep_ids.add(s_id)
break
# Ищем полуденный срез (ближайший к 13:00)
noon_target = datetime.strptime(f"{d_clean} 13:00:00", "%d.%m.%Y %H:%M:%S")
best_noon_id = None
min_noon_diff = float('inf')
for s_id, s_time in snapshots:
if s_id in keep_ids:
continue
try:
raw_t = str(s_time).strip()
if '.' in raw_t.split()[0]:
dt_s = datetime.strptime(raw_t, "%d.%m.%Y %H:%M:%S")
else:
dt_s = datetime.strptime(raw_t, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_s - noon_target).total_seconds())
if diff < min_noon_diff:
min_noon_diff = diff
best_noon_id = s_id
except Exception:
continue
if best_noon_id:
keep_ids.add(best_noon_id)
# 3. Все снапшоты, не попавшие в keep_ids, удаляем
to_delete_ids = [s_id for s_id, _ in snapshots if s_id not in keep_ids]
for del_id in to_delete_ids:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (del_id,))
deleted_rows_count += cursor.rowcount
logger.info(f"Удален промежуточный архивный срез {del_id} за {raw_date}")
except Exception as e:
logger.warning(f"Ошибка при обработке ротации за дату {raw_date}: {e}")
conn.commit()
if deleted_rows_count > 0:
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
return deleted_rows_count
+46 -75
View File
@@ -3,99 +3,70 @@
FILE: services/tasks/exporter.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP).
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
ROLE: Экспорт задач в чистый Markdown и генерация прямой ссылки на скачивание.
===============================================================================
"""
import os
import uuid
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
from config import OUTPUT_DIR
from typing import Dict, Any, Optional
from config import BASE_DIR
from .repository import repo_get_tasks
logger = logging.getLogger("TASK_EXPORTER")
WEB_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "db_export_tasks_markdown")
# ANCHOR[TASK_EXPORT_MARKDOWN]
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
tasks = repo_get_tasks(user_id)
if not tasks:
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
def export_tasks_to_markdown(
user_id: int,
filename: Optional[str] = "ROADMAP.md",
status_filter: Optional[str] = None
) -> Dict[str, Any]:
tasks = repo_get_tasks(user_id=user_id, status=status_filter)
safe_filename = os.path.basename(filename or "ROADMAP.md")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
# 1. Фильтрация задач по статусу
if status_filter and status_filter.upper() != "ALL":
tgt = status_filter.upper()
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]]
elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]]
elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]:
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]]
session_uuid = str(uuid.uuid4())[:8]
target_dir = os.path.join(WEB_OUTPUT_DIR, session_uuid)
os.makedirs(target_dir, exist_ok=True)
target_filepath = os.path.join(target_dir, safe_filename)
if not tasks:
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"}
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md"
if not target_filename.endswith(".md"):
target_filename = f"{target_filename}.md"
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 2. Группировка по модулям
modules: Dict[str, List[Dict[str, Any]]] = {}
for t in tasks:
mod = t.get("module") or "general"
modules.setdefault(mod, []).append(t)
md_lines = [
"# 🗺️ Дорожная карта задач проекта (ROADMAP)\n",
f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n",
"---\n"
lines = [
f"# 📋 Реестр задач проекта ({safe_filename})",
f"**Всего задач:** {len(tasks)} ",
f"**Пользователь ID:** {user_id} ",
"",
"| ID | Статус | Приоритет | Модуль | Срок | Задача |",
"| :--- | :--- | :--- | :--- | :--- | :--- |"
]
for mod_name, mod_tasks in sorted(modules.items()):
md_lines.append(f"## Модуль `{mod_name}`\n")
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)):
status = str(t.get("status", "BACKLOG")).upper()
is_done = status in ["COMPLETED", "DONE"]
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
check_box = "[x]" if is_done else "[ ]"
t_id = t.get("id")
title = t.get("title", "Без названия")
prio = t.get("priority", "MEDIUM")
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else ""
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "")
status_icons = {
"IN_PROGRESS": "⚙️ В работе",
"COMPLETED": "✓ Завершено",
"BACKLOG": "📋 Бэклог"
}
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
md_lines.append("\n---\n")
for t in tasks:
t_id = t.get("task_id") or f"#{t.get('id')}"
t_status = status_icons.get(t.get("status"), t.get("status", "BACKLOG"))
t_prio = t.get("priority", "MEDIUM")
t_mod = t.get("module", "general")
t_due = t.get("due_date") or "—"
t_title = str(t.get("title", "")).replace("|", "\\|").strip()
lines.append(f"| `{t_id}` | {t_status} | {t_prio} | `{t_mod}` | {t_due} | {t_title} |")
content = "\n".join(md_lines)
lines.append("")
content = "\n".join(lines)
# 3. Сохранение файла в изолированную сессионную папку
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
os.makedirs(tool_dir, exist_ok=True)
session_token = uuid.uuid4().hex[:8]
session_dir = os.path.join(tool_dir, session_token)
os.makedirs(session_dir, exist_ok=True)
filepath = os.path.join(session_dir, target_filename)
with open(filepath, "w", encoding="utf-8") as f:
with open(target_filepath, "w", encoding="utf-8") as f:
f.write(content)
download_url = f"/api/v1/files/download/db_export_tasks_markdown/{session_uuid}/{safe_filename}"
return {
"status": "success",
"filename": target_filename,
"filepath": filepath,
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}",
"tasks_count": len(tasks),
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
"message": f"Отчет успешно сформирован в файл `{safe_filename}` (всего задач: {len(tasks)}).",
"filename": safe_filename,
"download_url": download_url,
"tasks_count": len(tasks)
}