/
AxeOn
/
ProjectAi
Обзор
Документация
Войти
/
AxeOn
/
ProjectAi
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
load_ui.py
2 768 строк
133 KB
Дмитрий
refactor: split load_module into load_ui and load_service, fully separating tkinter from business logic
25 апр 2026, 17:48
25 апр 2026, 17:48
d5ad7f1
Код
Авторство
О чём код?
import tkinter as tk import tkinter.ttk as tk_ttk import ttkbootstrap as ttk from ttkbootstrap.constants import * from ttkbootstrap.tooltip import ToolTip from tkinter import messagebox, filedialog import pandas as pd import numpy as np from datetime import datetime, timedelta, date import calendar import re import logging import os import textwrap import io import csv import threading # === ИМПОРТЫ ДЛЯ ГРАФИКОВ === import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg logger = logging.getLogger(__name__) from base_module import setup_combobox_search from load_excel import ( validate_and_fix_date, safe_float, parse_date_strictly, normalize_key, read_workload_excel_files, find_workload_columns, read_single_workload_excel, export_services_to_excel, build_stats_workbook, ) from load_service import ( ProductionCalendar, resolve_workload_svc, compute_weekday_data, compute_vacation_anomalies, get_price_map, ) # === БАЗОВЫЙ КЛАСС ДЛЯ СПРАВОЧНИКОВ === class BaseDirectoryWindow(ttk.Toplevel): def __init__(self, parent, db_manager, title, display_cols): super().__init__(parent) self.transient(parent) self.db = db_manager self.title(title) self.geometry("1400x750") self.display_cols_ids = display_cols self.setup_ui() self.load_data() def setup_ui(self): style = ttk.Style() style.configure("Treeview.Heading", font=("Segoe UI", 11, "bold")) style.configure("Treeview", font=("Segoe UI", 10), rowheight=30) tb = ttk.Frame(self, padding=10) tb.pack(fill="x") btn_data = [ ("✚", "Добавить запись", self.add_row, "success"), ("💾", "Сохранить изменения", self.save_to_db, "warning"), ("✖", "Удалить выбранное", self.delete_row, "danger"), ("📂", "Импорт из Excel", self.import_csv, "primary"), ("📗", "Экспорт в Excel", self.export_excel, "success") ] for icon, tip, cmd, style in btn_data: btn = ttk.Button(tb, text=icon, command=cmd, bootstyle=style, width=5) btn.pack(side="left", padx=2) ToolTip(btn, text=tip, bootstyle=f"inverse-{style}") self.tree = ttk.Treeview(self, columns=self.display_cols_ids, show="headings") self.tree.pack(fill="both", expand=True, padx=10, pady=10) self.tree.bind("<Double-1>", self.on_double_click) def load_data(self): pass def add_row(self): pass def delete_row(self): sel = self.tree.selection() if sel and messagebox.askyesno("Удаление", "Удалить записи?"): for i in sel: self.tree.delete(i) def save_to_db(self): pass def on_double_click(self, event): pass def import_csv(self): pass def export_excel(self): pass # === СПРАВОЧНИК УСЛУГ (ТОЛЬКО СТРУКТУРА) === class ServicesDirectoryWindow(BaseDirectoryWindow): def __init__(self, parent, db_manager): self.cols_map = ["name", "s_type", "is_basic", "rule_day"] super().__init__(parent, db_manager, "Справочник услуг (Структура)", self.cols_map) def setup_ui(self): super().setup_ui() self.tree.heading("name", text="Наименование"); self.tree.column("name", width=350) self.tree.heading("s_type", text="Тип услуги"); self.tree.column("s_type", width=120, anchor="center") self.tree.heading("is_basic", text="Базовая"); self.tree.column("is_basic", width=80, anchor="center") self.tree.heading("rule_day", text="Ед. (День)"); self.tree.column("rule_day", width=120, anchor="center") def load_data(self): for i in self.tree.get_children(): self.tree.delete(i) for r in self.db.get_services(): r_dict = dict(r) s_type = r_dict.get('service_type', 'Осмотр') or 'Осмотр' r_day = r_dict.get('calc_rule', 'CNT') or '' is_basic_str = "Да" if r_dict.get('is_basic') else "Нет" self.tree.insert("", "end", values=(r_dict['name'], s_type, is_basic_str, r_day)) def on_double_click(self, event): item = self.tree.identify_row(event.y) col_id = self.tree.identify_column(event.x) if not item: return col_idx = int(col_id.replace("#", "")) - 1 col_name = self.display_cols_ids[col_idx] bbox = self.tree.bbox(item, col_id) vals = list(self.tree.item(item, 'values')) if col_name == "s_type": entry = ttk.Combobox(self.tree, values=["Осмотр", "Расчет", "Оценка", "Оценка РИ"], state="readonly") entry.set(vals[col_idx]) elif col_name == "is_basic": entry = ttk.Combobox(self.tree, values=["Да", "Нет"], state="readonly") entry.set(vals[col_idx]) else: entry = ttk.Entry(self.tree) entry.insert(0, vals[col_idx]) entry.place(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3]) entry.focus_set() def save(e=None): vals[col_idx] = entry.get() self.tree.item(item, values=vals) entry.destroy() entry.bind("<Return>", save) entry.bind("<FocusOut>", save) def add_row(self): self.tree.insert("", "end", values=("Новая услуга", "Осмотр", "Нет", "CNT")) def save_to_db(self): if not messagebox.askyesno("Внимание", "Сохранить изменения в справочнике услуг?", parent=self): return self.db.clear_services() for i in self.tree.get_children(): v = self.tree.item(i)['values'] is_basic = 1 if v[2] == "Да" else 0 self.db.add_service( name=v[0], s_type=v[1], is_basic=is_basic, rule_day=v[3], rule_month="", p_h=0, p1=0, p2=0, unit="", rule_salary="" ) messagebox.showinfo("Успех", "Данные сохранены", parent=self) def import_csv(self): path = filedialog.askopenfilename(filetypes=[("CSV", "*.csv")], parent=self) if path: try: if self.db.import_services_csv(path)[0]: self.load_data() except Exception as e: messagebox.showerror("Ошибка", f"Ошибка: {e}", parent=self) def export_excel(self): path = filedialog.asksaveasfilename(defaultextension=".xlsx", parent=self) if not path: return data = [self.tree.item(i)['values'] for i in self.tree.get_children()] export_services_to_excel(data, path) class BlacklistDialog(ttk.Toplevel): def __init__(self, parent, db_manager): super().__init__(parent) self.transient(parent) self.db = db_manager self.title("Список исключений (ФИО)") self.geometry("500x600") self.names_list = [] self.setup_ui() self.load_data() def setup_ui(self): f_add = ttk.Frame(self, padding=10); f_add.pack(fill="x") self.entry_name = ttk.Entry(f_add); self.entry_name.pack(side="left", fill="x", expand=True, padx=5) ttk.Button(f_add, text="✚", command=self.add_name, bootstyle="success", width=3).pack(side="left") self.tree = ttk.Treeview(self, columns=("name",), show="headings"); self.tree.heading("name", text="ФИО") self.tree.pack(fill="both", expand=True, padx=10, pady=5) ttk.Button(self, text="✖ Удалить", command=self.delete_sel, bootstyle="danger").pack(pady=5) ttk.Button(self, text="💾 Сохранить", command=self.save_and_close, bootstyle="primary").pack(pady=5) def load_data(self): d = self.db.get_setting("load_blacklist", "") self.names_list = sorted([x.strip() for x in d.split("\n") if x.strip()]) for x in self.names_list: self.tree.insert("", "end", values=(x,)) def add_name(self): v = self.entry_name.get().strip() if v and v not in self.names_list: self.names_list.append(v); self.tree.insert("", "end", values=(v,)); self.entry_name.delete(0, "end") def delete_sel(self): for i in self.tree.selection(): v = self.tree.item(i)['values'][0] if v in self.names_list: self.names_list.remove(v) self.tree.delete(i) def save_and_close(self): self.db.set_setting("load_blacklist", "\n".join(self.names_list)) self.destroy() class ShowMissingDialog(ttk.Toplevel): def __init__(self, parent, title_text, list_text): super().__init__(parent) self.transient(parent) self.title("Отчет") self.geometry("700x600") ttk.Label(self, text=title_text, font=("", 10, "bold"), padding=10).pack(fill="x") f = ttk.Frame(self); f.pack(fill="both", expand=True, padx=10) t = tk.Text(f); t.insert("1.0", list_text); t.config(state="disabled"); t.pack(side="left", fill="both", expand=True) ttk.Button(self, text="OK", command=self.destroy).pack(pady=10) class BaseWorkloadWindow(ttk.Toplevel): def __init__(self, parent, db_manager, is_daily=True): super().__init__(parent) self.transient(parent) self.db = db_manager self.is_daily = is_daily self.cal = ProductionCalendar() self.title("Ежедневный расчет" if is_daily else "Месячный расчет") self.geometry("1400x900") self.is_expanded = True self.setup_ui() self.after(100, self.load_from_db) def setup_ui(self): tb = ttk.Frame(self, padding=10); tb.pack(fill="x") btn_load = ttk.Button(tb, text="📂", command=lambda: self.load_csv("Осмотр"), bootstyle="primary", width=5) btn_load.pack(side="left", padx=2) ToolTip(btn_load, text="Загрузить из Excel", bootstyle="inverse-primary") btn_bl = ttk.Button(tb, text="⛔", command=lambda: BlacklistDialog(self, self.db), bootstyle="danger", width=5) btn_bl.pack(side="left", padx=2) ToolTip(btn_bl, text="Исключения", bootstyle="inverse-danger") f_save = ttk.Labelframe(tb, text="Параметры", padding=5); f_save.pack(side="right") if self.is_daily: self.de_date = ttk.DateEntry(f_save, width=12, dateformat="%d.%m.%Y"); self.de_date.pack(side="left") self.de_date.entry.bind("<Return>", lambda e: self.load_from_db()) self.de_date.bind("<<DateEntrySelected>>", lambda e: self.load_from_db()) btn_chk = ttk.Button(f_save, text="🔍", command=self.check_missing, bootstyle="secondary", width=5) btn_chk.pack(side="left", padx=2) ToolTip(btn_chk, text="Проверить пропущенные данные", bootstyle="inverse-secondary") btn_tog = ttk.Button(f_save, text="↕", command=self.toggle_view, bootstyle="info", width=5) btn_tog.pack(side="left", padx=2) ToolTip(btn_tog, text="Развернуть/Свернуть", bootstyle="inverse-info") btn_del = ttk.Button(f_save, text="🗑", command=self.delete_from_db, bootstyle="danger", width=5) btn_del.pack(side="left", padx=2) ToolTip(btn_del, text="Удалить данные", bootstyle="inverse-danger") self.tree = ttk.Treeview(self, columns=("type", "rule", "cnt"), show="tree headings", bootstyle="info") self.tree.heading("#0", text="Сотрудник / Услуга", command=lambda: self.sort_tree("#0", False)); self.tree.column("#0", width=450) self.tree.heading("type", text="Тип"); self.tree.column("type", width=150, anchor="center") self.tree.heading("rule", text="Ед."); self.tree.column("rule", width=150, anchor="center") self.tree.heading("cnt", text="Кол-во"); self.tree.column("cnt", width=100, anchor="center") self.tree.pack(fill="both", expand=True, padx=10, pady=10) self.lbl_total = ttk.Label(self, text="Итого: 0.00", font=("Segoe UI", 14, "bold")); self.lbl_total.pack(side="right", padx=20) def get_date_str(self): if self.is_daily: return self.de_date.entry.get() return f"01.{self.cb_month.get()}.{self.cb_year.get()}" def sort_tree(self, col, reverse): l = [(self.tree.set(k, col), k) if col != "#0" else (self.tree.item(k, "text"), k) for k in self.tree.get_children('')] try: l.sort(key=lambda t: float(t[0].replace(' ', '')), reverse=reverse) except ValueError: l.sort(reverse=reverse) for index, (val, k) in enumerate(l): self.tree.move(k, '', index) self.tree.heading(col, command=lambda: self.sort_tree(col, not reverse)) def toggle_view(self): self.is_expanded = not self.is_expanded for item in self.tree.get_children(): self.tree.item(item, open=self.is_expanded) def delete_from_db(self): ds = self.get_date_str() try: dt = datetime.strptime(ds, "%d.%m.%Y"); iso = dt.strftime("%Y-%m-%d"); per = dt.strftime("%Y-%m") except: return src = 'daily' if self.is_daily else 'monthly' if messagebox.askyesno("Удаление", f"Удалить данные за {ds}?"): if self.is_daily: self.db.delete_workload_day(iso, source=src) else: self.db.delete_workload_period(per, source=src) self.load_from_db() def load_from_db(self): for i in self.tree.get_children(): self.tree.delete(i) ds = self.get_date_str() try: dt = datetime.strptime(ds, "%d.%m.%Y"); iso = dt.strftime("%Y-%m-%d"); per = dt.strftime("%Y-%m") except: return src = 'daily' if getattr(self, 'is_daily', True) else 'monthly' rows = self.db.get_workload_by_day(iso, source=src) if self.is_daily else self.db.get_workload_by_period(per, source=src) try: svcs_info = {dict(s)['name'].strip().lower(): dict(s) for s in self.db.get_services()} except Exception: svcs_info = {} data = {}; tot = 0; tot_b = 0 emp_type_counts = {} for r in rows: # Определяем колонку с названием услуги service_name = None for col in ['service_name', 'val', 'task_name']: if col in r and r[col]: service_name = str(r[col]) break if not service_name: continue # Пропускаем если нет названия nm = service_name.split(' [')[0].strip().lower() info = svcs_info.get(nm, {'rule': '', 'type': 'Осмотр', 'is_basic': 0}) s_type = info.get('service_type', 'Осмотр') # Определяем имя сотрудника emp_name = r.get('employee_name') or r.get('emp_name') or r.get('name') or 'Неизвестно' # Определяем количество count = int(r.get('count', 1)) if emp_name not in emp_type_counts: emp_type_counts[emp_name] = {} emp_type_counts[emp_name][s_type] = emp_type_counts[emp_name].get(s_type, 0) + count if info.get('is_basic'): tot_b += count tot += count data.setdefault(emp_name, []).append((service_name, s_type, info.get('calc_rule', ''), count)) for emp, items in data.items(): types = emp_type_counts.get(emp, {}) dom = max(types, key=types.get) if types else "" p = self.tree.insert("", "end", text=emp, values=(dom, "-", int(sum(x[3] for x in items))), open=self.is_expanded) for x in items: self.tree.insert(p, "end", text=x[0], values=(x[1], x[2], f"{x[3]:g}")) self.lbl_total.config(text=f"Всего: {int(tot)} | Базовая: {int(tot_b)}") def load_csv(self, target_type): pass def check_missing(self): pass class DailyWorkloadWindow(BaseWorkloadWindow): def __init__(self, p, d): super().__init__(p, d, True) def check_missing(self): """Проверка пропущенных рабочих дней с 01.01.2026""" start_date = date(2026, 1, 1) end_date = date.today() missing_dates = [] # Получаем все даты, которые ЕСТЬ в базе # Оптимизация: берем данные за 2026 и далее existing_dates = set() for y in range(start_date.year, end_date.year + 1): rows = self.db.get_workload_by_year(str(y)) for r in rows: if r.get('source') == 'daily': # Определяем колонку с датой d_str = None for date_col in ['created_at', 'date', 'task_date']: if date_col in r and r[date_col]: # created_at может быть с T (ISO формат) d_str = str(r[date_col]).split("T")[0].split(" ")[0] break if d_str: existing_dates.add(d_str) # Проходим по всем дням curr = start_date while curr <= end_date: if self.cal.is_working_day(curr): iso = curr.strftime("%Y-%m-%d") if iso not in existing_dates: missing_dates.append(curr.strftime("%d.%m.%Y")) curr += timedelta(days=1) if missing_dates: text = "Отсутствуют данные за следующие рабочие дни:\n\n" + "\n".join(missing_dates) else: text = "Все рабочие дни загружены!" ShowMissingDialog(self, "Проверка данных", text) def load_csv(self, target_type): try: paths = filedialog.askopenfilenames(filetypes=[("Excel", "*.xlsx;*.xls"), ("CSV", "*.csv"), ("All", "*.*")], parent=self) if not paths: return # --- АВТООПРЕДЕЛЕНИЕ ДАТЫ --- if len(paths) >= 1: filename = os.path.basename(paths[0]) m = re.search(r'(\d{2})(\d{2})(\d{4})', filename) if m: file_date_str = f"{m.group(1)}.{m.group(2)}.{m.group(3)}" if file_date_str != self.de_date.entry.get(): if messagebox.askyesno("Дата", f"Установить дату {file_date_str}?"): self.de_date.entry.delete(0, "end") self.de_date.entry.insert(0, file_date_str) self.load_from_db() ds = self.get_date_str() try: dt = datetime.strptime(ds, "%d.%m.%Y") iso = dt.strftime("%Y-%m-%d") per = dt.strftime("%Y-%m") except Exception: messagebox.showerror("Ошибка", "Некорректная дата") return self.db.delete_workload_day(iso, source='daily') # ВАЖНО: конвертация в словарь для .get() svcs = {s['name'].strip().lower(): dict(s) for s in self.db.get_services()} blacklist = {x.strip().lower() for x in self.db.get_setting("load_blacklist", "").split("\n") if x.strip()} df = read_workload_excel_files(paths) if df is None: messagebox.showerror("Ошибка", "Не удалось прочитать данные (заголовок не найден)") return col_exp, col_svc, col_ne, cols_upper = find_workload_columns(df) if not col_exp or not col_svc: messagebox.showerror("Ошибка", "Колонки 'Эксперт' или 'Короткое наименование' не найдены") return # --- СТАТИСТИКА --- stats_loaded = 0 stats_skipped = 0 names_loaded = set() names_skipped = set() for _, row in df.iterrows(): emp = str(row.get(col_exp, "")).strip() svc = str(row.get(col_svc, "")).strip() ne_val = str(row.get(col_ne, "")).strip() if col_ne else None if not emp or not svc or emp.lower() in blacklist: continue svc_norm = svc.lower().replace(" ", "").replace(".", "") candidates = [s for n, s in svcs.items() if n.replace(" ", "").replace(".", "") == svc_norm] if not candidates: continue for c in candidates: rule = str(c.get('calc_rule', 'CNT')).upper().strip() rule_key = normalize_key(rule) col_rule = cols_upper.get(rule_key) if col_rule: val = safe_float(row.get(col_rule)) if val > 0: self.db.save_workload_record( emp, iso, val=c['name'], client_name=str(val), source='daily', ne_value=ne_val if ne_val else None ) stats_loaded += 1 names_loaded.add(c['name']) else: stats_skipped += 1 names_skipped.add(c['name']) self.load_from_db() report_text = f"=== РЕЗУЛЬТАТ ЗАГРУЗКИ ===\n\n" report_text += f"✅ ЗАГРУЖЕНО: {stats_loaded} записей\n" if names_loaded: report_text += f"Список услуг:\n" + "\n".join(sorted(list(names_loaded))) + "\n\n" else: report_text += "(Нет загруженных услуг)\n\n" report_text += f"⚠️ ПРОПУЩЕНО (Значение 0 или пусто): {stats_skipped} записей\n" if names_skipped: report_text += f"Список услуг:\n" + "\n".join(sorted(list(names_skipped))) else: report_text += "(Нет пропущенных услуг)" ShowMissingDialog(self, "Отчет о загрузке", report_text) except Exception as e: logger.error(f"Load error: {e}", exc_info=True) messagebox.showerror("Error", str(e)) class StatsSelector(ttk.Frame): def __init__(self, parent, index): super().__init__(parent) self.pack(fill="x", pady=2) self.var_enable = tk.BooleanVar(value=(index == 0)) self.var_type = tk.StringVar(value="Месяц") self.var_year = tk.StringVar(value=str(datetime.now().year)) self.var_sub = tk.StringVar(value=datetime.now().strftime("%m")) self.chk = ttk.Checkbutton(self, text=f"Период {index+1}", variable=self.var_enable, bootstyle="round-toggle") self.chk.pack(side="left", padx=5) self.cb_type = ttk.Combobox(self, textvariable=self.var_type, values=["Месяц", "Квартал", "Полугодие", "Год"], state="readonly", width=10) self.cb_type.pack(side="left", padx=5) self.cb_type.bind("<<ComboboxSelected>>", self.update_subs) _year_values = [str(y) for y in range(2024, 2031)] self.cb_year = ttk.Combobox(self, textvariable=self.var_year, values=_year_values, state="readonly", width=6) self.cb_year.pack(side="left", padx=5) setup_combobox_search(self.cb_year, _year_values) self.cb_sub = ttk.Combobox(self, textvariable=self.var_sub, state="readonly", width=15) self.cb_sub.pack(side="left", padx=5) self.update_subs() def update_subs(self, event=None): t = self.var_type.get() if t == "Месяц": self.cb_sub.config(state="readonly", values=[f"{i:02d}" for i in range(1, 13)]) if not self.var_sub.get() or len(self.var_sub.get()) > 2: self.var_sub.set("01") elif t == "Квартал": self.cb_sub.config(state="readonly", values=["1 кв.", "2 кв.", "3 кв.", "4 кв."]) self.var_sub.set("1 кв.") elif t == "Полугодие": self.cb_sub.config(state="readonly", values=["1 полугодие", "2 полугодие"]) self.var_sub.set("1 полугодие") elif t == "Год": self.cb_sub.config(state="disabled", values=[]) self.var_sub.set("") def get_months(self): if not self.var_enable.get(): return [], "" y = self.var_year.get() t = self.var_type.get() s = self.var_sub.get() label = f"{t} {s} {y}".strip() months = [] if t == "Месяц": months = [f"{y}-{s}"] elif t == "Квартал": q = int(s[0]); m_start = (q - 1) * 3 + 1 months = [f"{y}-{m:02d}" for m in range(m_start, m_start + 3)] elif t == "Полугодие": h = int(s[0]); m_start = 1 if h == 1 else 7 months = [f"{y}-{m:02d}" for m in range(m_start, m_start + 6)] elif t == "Год": months = [f"{y}-{m:02d}" for m in range(1, 13)] return months, label class YearlyStatsWindow(ttk.Toplevel): def __init__(self, parent, db_manager): super().__init__(parent) self.db = db_manager self.title("Сравнительная статистика") self.geometry("1300x900") self.selectors = [] self.setup_ui() def setup_ui(self): filter_frame = ttk.Labelframe(self, text="Фильтры данных", padding=10) filter_frame.pack(fill="x", padx=10, pady=5) ttk.Label(filter_frame, text="Режим:").pack(side="left", padx=(5, 2)) self.var_mode = tk.StringVar(value="По услугам") self.cb_mode = ttk.Combobox( filter_frame, textvariable=self.var_mode, values=["По услугам", "По отделам", "По зарплате", "По дням недели"], state="readonly", width=16) self.cb_mode.pack(side="left", padx=5) self.cb_mode.bind("<<ComboboxSelected>>", self._on_mode_change) # ── Стандартный фильтр «Сотрудник» (скрывается в режиме дней недели) ── self._lbl_emp = ttk.Label(filter_frame, text="Сотрудник:") self._lbl_emp.pack(side="left", padx=(15, 2)) self.var_emp = tk.StringVar(value="Все") all_emps = ["Все"] + sorted([e['name'] for e in self.db.get_employees()]) self.cb_emp = ttk.Combobox(filter_frame, textvariable=self.var_emp, values=all_emps, state="readonly", width=30) self.cb_emp.pack(side="left", padx=5) setup_combobox_search(self.cb_emp, all_emps) # ── Фильтры для режима «По дням недели» (изначально скрыты) ── self._wk_frame = ttk.Frame(filter_frame) # «Отдел» combobox ttk.Label(self._wk_frame, text="Отдел:").pack(side="left", padx=(0, 2)) depts = ["Все"] + sorted({(e.get('department') or '').strip() for e in self.db.get_employees() if (e.get('department') or '').strip()}) self.var_dept = tk.StringVar(value="Все") self.cb_dept = ttk.Combobox(self._wk_frame, textvariable=self.var_dept, values=depts, state="readonly", width=30) self.cb_dept.pack(side="left", padx=5) # Переключатель среднее/суммарное (год/месяц теперь из period-селекторов) self.var_wk_avg = tk.BooleanVar(value=False) ttk.Checkbutton(self._wk_frame, text="Среднее", variable=self.var_wk_avg, bootstyle="success-round-toggle").pack(side="left", padx=(12, 2)) ttk.Button(filter_frame, text="📗 Excel", command=self._export_to_excel, bootstyle="success-outline", width=7 ).pack(side="right", padx=(0, 4)) ttk.Button(filter_frame, text="📊 Построить график", command=self.build_chart, bootstyle="success").pack(side="right", padx=10) # ── Панель периодов (скрывается в режиме дней недели) ── self._settings_frame = ttk.Labelframe(self, text="Настройка периодов", padding=10) self._settings_frame.pack(fill="x", padx=10, pady=5) for i in range(3): sel = StatsSelector(self._settings_frame, i) self.selectors.append(sel) # ── Прокручиваемая область графиков ── self._chart_outer = ttk.Frame(self) self._chart_outer.pack(fill="both", expand=True, padx=10, pady=(5, 0)) self._chart_vscroll = ttk.Scrollbar(self._chart_outer, orient="vertical") self._chart_vscroll.pack(side="right", fill="y") self._chart_tk_cv = tk.Canvas(self._chart_outer, highlightthickness=0, background='#2b2b2b', yscrollcommand=self._chart_vscroll.set) self._chart_tk_cv.pack(side="left", fill="both", expand=True) self._chart_vscroll.config(command=self._chart_tk_cv.yview) # chart_frame — внутренний фрейм, который может быть крупнее видимой области self.chart_frame = ttk.Frame(self._chart_tk_cv) self._chart_frame_id = self._chart_tk_cv.create_window( (0, 0), window=self.chart_frame, anchor='nw') self.chart_frame.bind('<Configure>', lambda e: self._chart_tk_cv.configure( scrollregion=self._chart_tk_cv.bbox('all'))) self._chart_tk_cv.bind('<Configure>', lambda e: self._chart_tk_cv.itemconfig(self._chart_frame_id, width=e.width)) # Прокрутка колесом мыши self._chart_tk_cv.bind('<MouseWheel>', lambda e: self._chart_tk_cv.yview_scroll(int(-1 * (e.delta / 120)), 'units')) self.fig, self.ax = plt.subplots(figsize=(10, 8), dpi=100, layout='constrained') self.canvas = FigureCanvasTkAgg(self.fig, master=self.chart_frame) self.canvas.get_tk_widget().pack(fill="both", expand=True) self.set_chart_style() # ── Блок отпусков (скрыт до первого построения) ── self._vac_frame = ttk.Labelframe(self, text="🏖 Отпуска и аномалии в выбранном месяце", padding=8) vac_cols = ('emp', 'periods', 'vac_all', 'norm_avg', 'vac_avg', 'diff') self._vac_tree = ttk.Treeview(self._vac_frame, columns=vac_cols, show='headings', height=5, bootstyle="warning") hdr_cfg = [ ('emp', 'Сотрудник', 160, 'w'), ('periods', 'Период отпуска', 160, 'w'), ('vac_all', 'Услуг в отпуске', 100, 'center'), ('norm_avg', 'Ср./день (норма)', 110, 'center'), ('vac_avg', 'Ср./день (отп.)', 110, 'center'), ('diff', 'Разница', 90, 'center'), ] for cid, text, w, anch in hdr_cfg: self._vac_tree.heading(cid, text=text) self._vac_tree.column(cid, width=w, minwidth=60, anchor=anch, stretch=False) self._vac_tree.tag_configure('anomaly', foreground='#FF8080') vsb = ttk.Scrollbar(self._vac_frame, orient=VERTICAL, command=self._vac_tree.yview) self._vac_tree.configure(yscrollcommand=vsb.set) vsb.pack(side=RIGHT, fill=Y) self._vac_tree.pack(fill=X, expand=False) def _on_mode_change(self, event=None): is_wk = (self.var_mode.get() == "По дням недели") if is_wk: self._lbl_emp.pack_forget() self.cb_emp.pack_forget() self._wk_frame.pack(side="left", padx=(15, 0)) # Показываем period-селекторы и в weekday-режиме (для сравнения периодов) self._settings_frame.pack(fill="x", padx=10, pady=5, before=self._chart_outer) else: self._wk_frame.pack_forget() self._lbl_emp.pack(side="left", padx=(15, 2)) self.cb_emp.pack(side="left", padx=5) self._settings_frame.pack(fill="x", padx=10, pady=5, before=self._chart_outer) self._vac_frame.pack_forget() def _style_ax(self, ax): bg = '#2b2b2b'; text = '#e0e0e0' ax.set_facecolor(bg) ax.tick_params(axis='x', colors=text) ax.tick_params(axis='y', colors=text, pad=1, labelsize=11) for sp in ax.spines.values(): sp.set_color(text) def set_chart_style(self): self.fig.set_facecolor('#2b2b2b') self._style_ax(self.ax) # ══════════════════════════════════════════════════════════════════════ # ══ РЕЖИМ «ПО ДНЯМ НЕДЕЛИ» ══ # ══════════════════════════════════════════════════════════════════════ DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Выходные'] def _build_weekday_chart(self): """Построить графики по дням недели. Поддерживает сравнение до 3 периодов.""" dept_filter = self.var_dept.get() use_avg = self.var_wk_avg.get() # ── Собираем активные периоды из селекторов ── periods = [] # [(label, emp_stats, dept_total, day_counts)] all_daily_rows = [] all_basic_set = None first_months = [] # для vacation-таблицы (первый месяц каждого периода) for sel in self.selectors: months, label = sel.get_months() if not months: continue emp_st, dept_tot, basic_set, daily_rows, day_counts = \ compute_weekday_data(self.db, months, self.var_dept.get()) if emp_st: periods.append((label, emp_st, dept_tot, day_counts)) all_daily_rows.extend(daily_rows) all_basic_set = basic_set first_months.append(months[0]) # Пересоздаём matplotlib figure self.fig.clear() self.fig.set_facecolor('#2b2b2b') if not periods: ax = self.fig.add_subplot(1, 1, 1) self._style_ax(ax) ax.text(0.5, 0.5, 'Нет данных за выбранные периоды', ha='center', va='center', color='#e0e0e0', fontsize=13, transform=ax.transAxes) self.canvas.draw() self._vac_frame.pack_forget() return n_periods = len(periods) # Все сотрудники, встречающиеся хотя бы в одном периоде all_emps_sorted = sorted({nm for _, es, _, _ in periods for nm in es}) n_plots = 1 + len(all_emps_sorted) N_COLS = 2 n_rows = (n_plots + N_COLS - 1) // N_COLS DPI = 100 ROW_PX = 340 avail_w = self._chart_outer.winfo_width() if avail_w < 100: avail_w = self.winfo_toplevel().winfo_width() - 30 fig_w_px = max(600, avail_w - 22) fig_h_px = max(500, n_rows * ROW_PX) self.fig.set_size_inches(fig_w_px / DPI, fig_h_px / DPI) self.fig.set_constrained_layout(True) mpl_widget = self.canvas.get_tk_widget() mpl_widget.pack_forget() mpl_widget.config(width=fig_w_px, height=fig_h_px) mpl_widget.pack(side='top', anchor='nw') gs = self.fig.add_gridspec(n_rows, N_COLS, hspace=0.55, wspace=0.30) # ── Цвета и геометрия баров ── if n_periods == 1: # Классический вид: синий (все) + зелёный (базовые) PERIOD_COLORS = ['#4472C4'] BASIC_COLORS = ['#70AD47'] else: PERIOD_COLORS = ['#339af0', '#ff922b', '#51cf66'][:n_periods] BASIC_COLORS = ['#1864ab', '#d9480f', '#2f9e44'][:n_periods] x = list(range(6)) # Ширина группы баров на каждый день group_w = 0.80 # Для 1 периода: 2 бара (все + базовые); для 2-3: n_periods баров n_bars = 2 if n_periods == 1 else n_periods bar_w = group_w / n_bars def _to_disp(vals, dc): if not use_avg: return vals, False return [round(v / dc[i], 1) if dc[i] > 0 else 0.0 for i, v in enumerate(vals)], True def _fmt(h, is_avg): return f"{h:.1f}" if is_avg else str(int(h)) def _draw_sp(ax, title, show_legend=False, **period_data): """period_data: {label: (all_vals, basic_vals, day_counts)}""" self._style_ax(ax) all_bar_objs = [] legend_handles = [] if n_periods == 1: # 2-барный вид: синий «Все» + зелёный «Базовые» lbl, (all_v, basic_v, dc) = list(period_data.items())[0] disp_all, is_avg = _to_disp(all_v, dc) disp_basic, _ = _to_disp(basic_v, dc) xs_all = [i - bar_w / 2 for i in x] xs_basic = [i + bar_w / 2 for i in x] b1 = ax.bar(xs_all, disp_all, width=bar_w, color=PERIOD_COLORS[0], label='Все услуги') b2 = ax.bar(xs_basic, disp_basic, width=bar_w, color=BASIC_COLORS[0], label='Базовые') all_bar_objs = list(b1) + list(b2) legend_handles = [(b1[0], 'Все услуги'), (b2[0], 'Базовые')] total_wd = sum(dc[0:5]) total_all = sum(all_v) total_basic = sum(basic_v) if use_avg: avg_a = f"{sum(all_v[0:5]) / total_wd:.1f}" if total_wd else '—' avg_b = f"{sum(basic_v[0:5]) / total_wd:.1f}" if total_wd else '—' info = f"Ср./день (все): {avg_a} | Ср./день (базовые): {avg_b}" else: info = f"Всего: {total_all} | Базовых: {total_basic}" else: # Мульти-периодный вид: один бар на период (по «Все услуги») info_parts = [] for pi, (lbl, (all_v, basic_v, dc)) in enumerate(period_data.items()): disp_all, is_avg = _to_disp(all_v, dc) offset = (pi - (n_periods - 1) / 2) * bar_w bars = ax.bar([i + offset for i in x], disp_all, width=bar_w * 0.92, color=PERIOD_COLORS[pi], label=lbl, alpha=0.9) all_bar_objs.extend(list(bars)) legend_handles.append((bars[0], lbl)) total_wd = sum(dc[0:5]) total_all = sum(all_v) if use_avg: avg_a = f"{sum(all_v[0:5]) / total_wd:.1f}" if total_wd else '—' info_parts.append(f"{lbl}: {avg_a}/день") else: info_parts.append(f"{lbl}: {total_all}") info = " | ".join(info_parts) ax.set_xticks(x) ax.set_xticklabels(self.DAY_LABELS, color='#e0e0e0', fontsize=8) ax.set_title(title, color='#e0e0e0', fontsize=8.5, pad=4) all_heights = [b.get_height() for b in all_bar_objs] max_h = max(all_heights) if all_heights else 1 ax.set_ylim(0, max_h * 1.45) for bar in all_bar_objs: h = bar.get_height() if h > 0: ax.text(bar.get_x() + bar.get_width() / 2, h + max_h * 0.03, _fmt(h, n_periods == 1 and use_avg), ha='center', va='bottom', color='#e0e0e0', fontsize=7) if show_legend and legend_handles: ax.legend(handles=[h for h, _ in legend_handles], labels=[l for _, l in legend_handles], fontsize=7.5, loc='upper left', facecolor='#3a3a3a', edgecolor='#777', labelcolor='#e0e0e0') ax.set_xlabel(info, color='#aaaaaa', fontsize=8, labelpad=6) def _make_pdata(emp_name=None): """Собрать period_data dict для _draw_sp.""" result = {} for lbl, emp_st, dept_tot, dc in periods: if emp_name is None: all_v = dept_tot['all'] basic_v = dept_tot['basic'] else: st = emp_st.get(emp_name) if st is None: all_v = basic_v = [0] * 6 else: all_v, basic_v = st['all'], st['basic'] result[lbl] = (all_v, basic_v, dc) return result # ── 1. Итоговый субплот ── ax0 = self.fig.add_subplot(gs[0, 0]) dept_label = dept_filter if (dept_filter and dept_filter != 'Все') else 'Все отделы' _draw_sp(ax0, f"ИТОГО — {dept_label}", show_legend=True, **_make_pdata()) # ── 2. Субплоты по сотрудникам ── for idx, nm in enumerate(all_emps_sorted): plot_idx = idx + 1 row, col = divmod(plot_idx, N_COLS) ax = self.fig.add_subplot(gs[row, col]) _draw_sp(ax, nm, **_make_pdata(nm)) self.canvas.draw() # Сохраняем данные для экспорта (первый период) first_lbl, first_es, first_dt, first_dc = periods[0] self._last_export = { 'mode': 'weekday', 'year_month': first_months[0] if first_months else '', 'dept_filter': dept_filter, 'emp_stats': first_es, 'dept_total': first_dt, 'day_counts': first_dc, 'use_avg': use_avg, } # ── Таблица отпусков (по первому активному периоду) ── if first_months and all_basic_set is not None: anomalies = compute_vacation_anomalies( self.db, first_months[0], list(first_es.keys()), all_basic_set, all_daily_rows) self._refresh_vacation_table(anomalies) def _refresh_vacation_table(self, anomalies): """Обновить Treeview блока отпусков.""" for row in self._vac_tree.get_children(): self._vac_tree.delete(row) if not anomalies: self._vac_frame.pack_forget() return for nm, info in sorted(anomalies.items()): periods_str = '; '.join( f"{p['start']} — {p['end']}" for p in info['periods']) diff_str = (f"{info['diff_abs']:+.1f} ({info['diff_pct']:+.0f}%)" if info['vac_avg'] > 0 else '—') tag = 'anomaly' if info['worked_in_vac'] else '' self._vac_tree.insert('', 'end', tags=(tag,), values=( nm, periods_str, info['vac_all'] if info['vac_all'] else '—', info['norm_avg'], info['vac_avg'] if info['vac_avg'] else '—', diff_str, )) self._vac_frame.pack(fill=X, padx=10, pady=(0, 8)) def build_chart(self): if self.var_mode.get() == "По дням недели": self._build_weekday_chart() return # Восстанавливаем нормальный pack-режим (fill/expand) для обычных режимов mpl_widget = self.canvas.get_tk_widget() mpl_widget.pack_forget() mpl_widget.config(width=0, height=0) mpl_widget.pack(fill="both", expand=True) # === 1. Сбор данных === all_data = {} all_svcs = self.db.get_services() svc_map = {dict(s)['name'].strip().lower(): dict(s).get('is_basic', 0) for s in all_svcs} all_employees_data = self.db.get_employees() emp_dept_map = {e['name']: e['department'] for e in all_employees_data} mode = self.var_mode.get(); target_emp = self.var_emp.get() is_salary = (mode == "По зарплате") price_cache = {} for sel in self.selectors: months, label = sel.get_months() if not months: continue period_data = {} for m in months: year_str = m[:4] if is_salary and year_str not in price_cache: price_cache[year_str] = get_price_map(self.db, year_str) price_map = price_cache.get(year_str, {}) rows = self.db.get_workload_by_period(m, source='monthly') if not rows: rows = self.db.get_workload_by_period(m, source='daily') for r in rows: if target_emp != "Все" and r['emp_name'] != target_emp: continue base_name, rule_token = resolve_workload_svc(r['service_name']) clean_base = base_name.strip().lower() if not svc_map.get(clean_base, 0): continue is_km = (clean_base == 'выезд' and rule_token == 'NDISTANCE') is_trip = (clean_base == 'выезд' and not is_km) # Поездки (CNT): только в режиме «По зарплате» (объединяем с км под «Выезд») if is_trip and not is_salary: continue if mode in ("По услугам", "По зарплате"): # км → «Выезд (км)», поездки (CNT) и остальные → base_name key = "Выезд (км)" if is_km else base_name else: key = emp_dept_map.get(r['emp_name'], "Без отдела") if is_salary: name_key = clean_base price = price_map.get((name_key, rule_token.upper()), price_map.get(name_key, 0)) if rule_token else price_map.get(name_key, 0) period_data[key] = period_data.get(key, 0) + r['count'] * price else: period_data[key] = period_data.get(key, 0) + r['count'] if period_data: all_data[label] = period_data # === 2. Пересоздать figure / subplots === self.fig.clear() self.fig.set_facecolor('#2b2b2b') if not all_data: self.ax = self.fig.add_subplot(1, 1, 1) self._style_ax(self.ax) self.canvas.draw() return # В режимах «По услугам» / «По отделам» — км на отдельный subplot separate_km = not is_salary km_all = {lbl: {k: v for k, v in pd.items() if k == "Выезд (км)"} for lbl, pd in all_data.items()} rest_all = {lbl: {k: v for k, v in pd.items() if k != "Выезд (км)"} for lbl, pd in all_data.items()} has_km = separate_km and any(bool(d) for d in km_all.values()) COLORS = ['#339af0', '#ff922b', '#51cf66'] if has_km: gs = self.fig.add_gridspec(2, 1, height_ratios=[1, 4], hspace=0.5) ax_km = self.fig.add_subplot(gs[0]) ax_main = self.fig.add_subplot(gs[1]) self._style_ax(ax_km) else: ax_main = self.fig.add_subplot(1, 1, 1) ax_km = None self.ax = ax_main self._style_ax(ax_main) # === 3. Subplot километраж === if has_km: df_km = pd.DataFrame(km_all).fillna(0) df_km = df_km.loc[(df_km > 0).any(axis=1)] if not df_km.empty: df_km.plot(kind='barh', ax=ax_km, width=0.6, color=COLORS[:len(df_km.columns)], legend=False) ax_km.set_title("Километраж", color='#e0e0e0', fontsize=10, loc='left', pad=4) for container in ax_km.containers: lbs = [f"{int(v):,}".replace(',', ' ') if v > 0 else "" for v in container.datavalues] ax_km.bar_label(container, labels=lbs, padding=3, color='white', fontsize=8, fontweight='bold') # === 4. Основной subplot === draw_data = rest_all if has_km else all_data df = pd.DataFrame(draw_data).fillna(0) if df.empty: self.canvas.draw() return df['Total'] = df.sum(axis=1) df = df.sort_values('Total', ascending=True).drop(columns=['Total']) if len(df) > 15: df = df.tail(15) df.plot(kind='barh', ax=ax_main, width=0.85, color=COLORS[:len(df.columns)]) ax_main.set_yticklabels([textwrap.fill(str(l), 40) for l in df.index]) # Итоги по периодам в легенде period_totals = {col: df[col].sum() for col in df.columns} if is_salary: legend_labels = [f"{col}\nИтого: {int(period_totals[col]):,} ₽".replace(',', ' ') for col in df.columns] else: legend_labels = [f"{col}\nИтого: {int(period_totals[col])} шт." for col in df.columns] handles, _ = ax_main.get_legend_handles_labels() ax_main.legend(handles, legend_labels, facecolor='#2b2b2b', edgecolor='#e0e0e0', labelcolor='#e0e0e0') for container in ax_main.containers: if is_salary: lbs = [f"{v/1000:.0f}к" if v > 0 else "" for v in container.datavalues] ax_main.bar_label(container, labels=lbs, padding=3, color='white', fontsize=8, fontweight='bold') else: ax_main.bar_label(container, fmt='%d', padding=3, color='white', fontsize=8, fontweight='bold') self.canvas.draw() # Сохраняем данные для экспорта self._last_export = { 'mode': 'regular', 'chart_mode': mode, 'df': df.copy(), 'df_km': km_all if has_km else None, 'is_salary': is_salary, } def _export_to_excel(self): """Экспорт последнего построенного графика в Excel.""" exp = getattr(self, '_last_export', None) if not exp: messagebox.showwarning("Экспорт", "Сначала постройте график.") return wb = build_stats_workbook(exp) ts = datetime.now().strftime("%Y-%m-%d_%H-%M") init_name = ( f"stats_{exp.get('year_month', ts)}.xlsx" if exp['mode'] == 'weekday' else f"stats_{ts}.xlsx" ) path = filedialog.asksaveasfilename( defaultextension=".xlsx", filetypes=[("Excel файл", "*.xlsx")], initialfile=init_name, title="Сохранить экспорт", ) if not path: return try: wb.save(path) messagebox.showinfo("Экспорт", f"Файл сохранён:\n{path}") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось сохранить файл:\n{e}") # === ДИАЛОГ НАСТРОЕК EMAIL-РАССЫЛКИ (ЗАГРУЗКИ) === class WorkloadEmailSettingsDialog(tk.Toplevel): """Настройки email-рассылки для Текущие загрузки. Показывает всех руководителей (is_manager=1) с их email, позволяет включить/выключить каждого.""" _SETTING_KEY = 'workload_email_mgr_enabled' def __init__(self, parent, db): super().__init__(parent) self.db = db self.title("⚙ Настройки Email-рассылки (Загрузки)") self.geometry("540x460") self.resizable(True, True) self.grab_set() self._vars = {} # manager_name → BooleanVar self._rows = [] # (name, email_var) self._build_ui() self._load() def _build_ui(self): top = ttk.Frame(self, padding=(10, 8, 10, 4)) top.pack(fill=X) ttk.Label(top, text="Руководители, которым отправляется письмо с загрузкой их подчинённых:", wraplength=500, bootstyle="secondary").pack(anchor='w') # Колонки-заголовки hdr = ttk.Frame(self, padding=(10, 0, 10, 0)) hdr.pack(fill=X) ttk.Label(hdr, text="✓", width=3, anchor='center').pack(side=LEFT) ttk.Label(hdr, text="ФИО руководителя", width=28, anchor='w').pack(side=LEFT, padx=(4, 0)) ttk.Label(hdr, text="Email", anchor='w').pack(side=LEFT, padx=(8, 0)) ttk.Separator(self, orient=HORIZONTAL).pack(fill=X, padx=10, pady=2) # Прокручиваемый список cf = ttk.Frame(self) cf.pack(fill=BOTH, expand=True, padx=10, pady=4) vsb = ttk.Scrollbar(cf, orient=VERTICAL) vsb.pack(side=RIGHT, fill=Y) self._canvas = tk.Canvas(cf, highlightthickness=0, yscrollcommand=vsb.set) self._canvas.pack(side=LEFT, fill=BOTH, expand=True) vsb.config(command=self._canvas.yview) self._inner = ttk.Frame(self._canvas) self._canvas.create_window((0, 0), window=self._inner, anchor='nw') self._inner.bind('<Configure>', lambda e: self._canvas.configure( scrollregion=self._canvas.bbox('all'))) # Кнопки bf = ttk.Frame(self, padding=(10, 4)) bf.pack(fill=X) ttk.Button(bf, text="💾 Сохранить", command=self._save, bootstyle="success").pack(side=LEFT, padx=4) ttk.Button(bf, text="✖ Закрыть", command=self.destroy, bootstyle="secondary").pack(side=LEFT) def _load(self): # Загружаем список руководителей enabled_raw = self.db.get_setting(self._SETTING_KEY, '') enabled_names = {n.strip() for n in enabled_raw.split(',') if n.strip()} managers = [dict(e) for e in self.db.get_employees() if e.get('is_manager')] # Дополним теми, кто явно в enabled_names (на случай снятия флага) all_emps = {e['name']: dict(e) for e in self.db.get_employees()} extra = {n: all_emps[n] for n in enabled_names if n in all_emps and not all_emps[n].get('is_manager')} managers.extend(extra.values()) seen = set() deduped = [] for m in managers: if m['name'] not in seen: seen.add(m['name']) deduped.append(m) managers = sorted(deduped, key=lambda e: e['name']) for m in managers: name = m['name'] var = tk.BooleanVar(value=(name in enabled_names or not enabled_raw)) email_var = tk.StringVar(value=m.get('email') or '') self._vars[name] = var self._rows.append((name, email_var)) row = ttk.Frame(self._inner) row.pack(fill=X, pady=1) ttk.Checkbutton(row, variable=var, bootstyle="round-toggle", width=3).pack(side=LEFT) ttk.Label(row, text=name, width=28, anchor='w').pack( side=LEFT, padx=(4, 0)) ttk.Entry(row, textvariable=email_var, width=30).pack( side=LEFT, padx=(8, 0)) def _save(self): # Сохраняем список включённых + обновляем email в БД enabled = [name for name, var in self._vars.items() if var.get()] self.db.set_setting(self._SETTING_KEY, ','.join(enabled)) # Обновляем email если изменился all_emps = {e['name']: dict(e) for e in self.db.get_employees()} for name, email_var in self._rows: new_email = email_var.get().strip() emp = all_emps.get(name) if emp and (emp.get('email') or '') != new_email: emp['email'] = new_email self.db.upsert_employee(emp) messagebox.showinfo("Сохранено", f"Настройки сохранены.\nВключено руководителей: {len(enabled)}", parent=self) self.destroy() # === ПРИЛОЖЕНИЕ МОДУЛЯ ЗАГРУЗКИ (С ПОЛНЫМ ДАШБОРДОМ) === class LoadApp(ttk.Frame): def __init__(self, master, db_manager, tg_service=None): super().__init__(master) self.pack(fill="both", expand=True) self.db = db_manager self.tg = tg_service self.cal = ProductionCalendar() self.var_ne_filter = tk.StringVar(value="all") # Навигация по архивным данным self._dash_df = None self._all_days = [] self._day_idx = 0 self._all_weeks = [] self._week_idx = 0 self._week_sets_cache = {} self._week_streaks = {'low3': {}, 'no_basic': {}} self.setup_ui() self.setup_charts() self.after(500, self.refresh_dashboard) def setup_ui(self): tn = ttk.Frame(self, padding=5); tn.pack(fill="x") # Кнопки в одной строке (без рамок) btn_svc = ttk.Button(tn, text="⚙", command=lambda: ServicesDirectoryWindow(self.winfo_toplevel(), self.db), bootstyle="info", width=5) btn_svc.pack(side="left", padx=3) ToolTip(btn_svc, text="Справочник услуг", bootstyle="inverse-info") btn_day = ttk.Button(tn, text="📊", command=lambda: DailyWorkloadWindow(self.winfo_toplevel(), self.db), bootstyle="primary", width=5) btn_day.pack(side="left", padx=3) ToolTip(btn_day, text="Ежедневная загрузка", bootstyle="inverse-primary") btn_stat = ttk.Button(tn, text="📈", command=lambda: YearlyStatsWindow(self.winfo_toplevel(), self.db), bootstyle="info", width=5) btn_stat.pack(side="left", padx=3) ToolTip(btn_stat, text="Статистика за год", bootstyle="inverse-info") btn_tg = ttk.Button(tn, text="✈", command=self._ask_and_send_notifications, bootstyle="warning", width=5) btn_tg.pack(side="left", padx=3) ToolTip(btn_tg, text="Отправить уведомления TG", bootstyle="inverse-warning") btn_mail = ttk.Button(tn, text="📧", command=self._ask_and_send_workload_email, bootstyle="info", width=5) btn_mail.pack(side="left", padx=3) ToolTip(btn_mail, text="Отправить итоги по email руководителям", bootstyle="inverse-info") btn_mail_cfg = ttk.Button(tn, text="📬", command=self._open_workload_email_settings, bootstyle="secondary", width=5) btn_mail_cfg.pack(side="left", padx=3) ToolTip(btn_mail_cfg, text="Настройки email-рассылки (руководители)", bootstyle="inverse-secondary") btn_ref = ttk.Button(tn, text="🔄", command=self.refresh_dashboard, bootstyle="link", width=5) btn_ref.pack(side="right", padx=10) ToolTip(btn_ref, text="Обновить дашборд", bootstyle="inverse-secondary") f_ne = ttk.Frame(tn) f_ne.pack(side="right", padx=5) ttk.Radiobutton(f_ne, text="Москва", variable=self.var_ne_filter, value="moscow", command=self._on_ne_filter_changed, bootstyle="info-toolbutton").pack(side="right", padx=2) ttk.Radiobutton(f_ne, text="Все", variable=self.var_ne_filter, value="all", command=self._on_ne_filter_changed, bootstyle="info-toolbutton").pack(side="right", padx=2) # Дашборд self.canvas = tk.Canvas(self, highlightthickness=0) self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview) self.df = ttk.Frame(self.canvas, padding=10) self.win = self.canvas.create_window((0,0), window=self.df, anchor="nw") self.canvas.bind('<Configure>', self._on_canvas_configure) self.canvas.configure(yscrollcommand=self.scrollbar.set) self.scrollbar.pack(side="right", fill="y") self.canvas.pack(fill="both", expand=True) self.df.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.df.columnconfigure(0, weight=1); self.df.columnconfigure(1, weight=1) self.df.rowconfigure(0, weight=1); self.df.rowconfigure(1, weight=1) self.c_chart_day = ttk.Labelframe(self.df, text="Услуги (День)"); self.c_chart_day.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) self.c_chart_week = ttk.Labelframe(self.df, text="Услуги (Неделя)"); self.c_chart_week.grid(row=0, column=1, sticky="nsew", padx=5, pady=5) # Лидеры - расширение (expand=True) self.f_recs_d = ttk.Labelframe(self.df, text="Лидеры (День)") self.f_recs_d.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) nav_d = ttk.Frame(self.f_recs_d) nav_d.pack(fill="x", padx=2, pady=(2, 0)) ttk.Button(nav_d, text="◀", width=3, command=lambda: self._nav_day(-1), bootstyle="link").pack(side="left") self.lbl_day_nav = ttk.Label(nav_d, text="", anchor="center", font=("Segoe UI", 8)) self.lbl_day_nav.pack(side="left", fill="x", expand=True) ttk.Button(nav_d, text="▶", width=3, command=lambda: self._nav_day(1), bootstyle="link").pack(side="right") self.c_recs_day = tk.Text(self.f_recs_d, height=1, bg="#2b2b2b", fg="white", relief="flat", insertbackground="white") self.c_recs_day.pack(fill="both", expand=True, padx=0, pady=0) self.f_recs_w = ttk.Labelframe(self.df, text="Лидеры (Неделя)") self.f_recs_w.grid(row=1, column=1, sticky="nsew", padx=5, pady=5) nav_w = ttk.Frame(self.f_recs_w) nav_w.pack(fill="x", padx=2, pady=(2, 0)) ttk.Button(nav_w, text="◀", width=3, command=lambda: self._nav_week(-1), bootstyle="link").pack(side="left") self.lbl_week_nav = ttk.Label(nav_w, text="", anchor="center", font=("Segoe UI", 8)) self.lbl_week_nav.pack(side="left", fill="x", expand=True) ttk.Button(nav_w, text="▶", width=3, command=lambda: self._nav_week(1), bootstyle="link").pack(side="right") self.c_recs_week = tk.Text(self.f_recs_w, height=1, bg="#2b2b2b", fg="white", relief="flat", insertbackground="white") self.c_recs_week.pack(fill="both", expand=True, padx=0, pady=0) # === ВСТАВИТЬ ВНУТРИ КЛАССА LoadApp (ЗАМЕНИТЬ СТАРЫЙ load_file) === def log(self, msg): self.log_text.config(state="normal") self.log_text.insert("end", str(msg) + "\n") self.log_text.see("end") self.log_text.config(state="disabled") # === ЗАМЕНИТЬ ТОЛЬКО ЭТОТ МЕТОД ВНУТРИ КЛАССА LoadApp === def load_file(self): path = filedialog.askopenfilename(filetypes=[("Excel", "*.xlsx *.xls")]) if not path: return try: # 1. Логирование if hasattr(self, 'lbl_info'): self.lbl_info.config(text=f"Чтение: {os.path.basename(path)}") if hasattr(self, 'log'): self.log(f"--- Старт: {os.path.basename(path)} ---") df = read_single_workload_excel(path) # === БЛОК ЛЕЧЕНИЯ ДАТ === date_col = None for col in df.columns: if 'dat' in str(col).lower() or 'дат' in str(col).lower(): date_col = col break if not date_col: date_col = df.columns[0] df[date_col] = df[date_col].apply(validate_and_fix_date) # 2. Выбор колонок dlg = ColumnSelectionDialog(self, list(df.columns)) self.wait_window(dlg) if not dlg.result: if hasattr(self, 'lbl_info'): self.lbl_info.config(text="Отмена") return cols = dlg.result if not cols['emp'] or not cols['svc'] or not cols['count']: messagebox.showerror("Ошибка", "Нет обязательных полей") return is_daily = (self.var_type.get() == "daily") # === ГЛАВНОЕ ИСПРАВЛЕНИЕ: ПОЛУЧЕНИЕ ДАТЫ === # Получаем дату из виджета календаря global_date_raw = self.date_entry.entry.get() # Принудительно конвертируем её в ISO (2026-02-02) global_iso_date = parse_date_strictly(global_date_raw) use_global_date = False if is_daily: # Если в файле нет колонки даты, используем календарь if not cols['date']: if not global_iso_date: messagebox.showerror("Ошибка", "Некорректная дата в календаре!\nВыберите дату загрузки.") return use_global_date = True if hasattr(self, 'log'): self.log(f"Используем дату из календаря: {global_iso_date}") blacklist = {x.strip().lower() for x in self.db.get_setting("load_blacklist", "").split("\n") if x.strip()} records = [] count_err_date = 0 # 3. Обработка строк for _, row in df.iterrows(): emp = str(row[cols['emp']]).strip() if emp.lower() in blacklist: continue svc = str(row[cols['svc']]).strip() cnt = safe_float(row.get(cols['count'], 0)) if cnt <= 0: continue summ = safe_float(row.get(cols['sum'], 0)) if cols['sum'] else 0 time = safe_float(row.get(cols['time'], 0)) if cols['time'] else 0 period = "" created_at = "" if is_daily: if use_global_date: # Используем дату из календаря (уже ISO) period = global_iso_date created_at = global_iso_date else: # Если в Excel есть колонка даты - берем оттуда raw = row[cols['date']] iso = parse_date_strictly(raw) if not iso: count_err_date += 1 continue period = iso created_at = iso else: # Месячная загрузка period = datetime.now().strftime("%Y-%m") created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") records.append((period, emp, svc, cnt, summ, time, created_at)) if not records: messagebox.showwarning("Внимание", "Нет данных для сохранения") return # 4. Очистка базы перед вставкой if is_daily: # А) Удаляем данные за конкретный день (чтобы не дублировать при повторной загрузке) dates_to_refresh = set(r[0] for r in records) for d in dates_to_refresh: self.db.delete_workload_day(d, source='daily') # Б) Удаляем мусор (записи вида "2026-02" без дня) if records: sample = records[0][0] # например "2026-02-02" if len(sample) >= 7: month_prefix = sample[:7] # "2026-02" with self.db.lock: self.db.cursor.execute( "DELETE FROM workload WHERE source='daily' AND period LIKE ? AND length(period) < 10", (f"{month_prefix}%",) ) deleted = self.db.cursor.rowcount self.db.conn.commit() if deleted > 0 and hasattr(self, 'log'): self.log(f"Удален мусор (неполные даты): {deleted}") # 5. Сохранение count_ok = 0 for r in records: source = 'daily' if is_daily else 'monthly' self.db.save_workload_record(r[0], r[1], r[2], r[3], r[4], r[5], r[6], source=source) count_ok += 1 if hasattr(self, 'lbl_info'): self.lbl_info.config(text=f"Загружено: {count_ok}") if hasattr(self, 'log'): self.log(f"Успешно сохранено: {count_ok}") msg_res = f"Загружено строк: {count_ok}" if use_global_date: msg_res += f"\nНа дату: {global_iso_date}" messagebox.showinfo("Готово", msg_res) # Автоотправка уведомлений если включена в настройках if is_daily and use_global_date and global_iso_date and self.tg and self.tg.token: sent = self.send_workload_notifications(global_iso_date) if sent: self.log(f"TG: отправлено уведомлений {sent}") except Exception as e: logger.error(f"Load file error: {e}", exc_info=True) if hasattr(self, 'log'): self.log(f"Ошибка: {e}") messagebox.showerror("Ошибка", str(e)) def _on_canvas_configure(self, event): new_height = max(event.height, self.df.winfo_reqheight()) self.canvas.itemconfig(self.win, width=event.width, height=new_height) def setup_charts(self): self.fig_d, self.ax_d = plt.subplots(figsize=(5,3), dpi=90, layout='constrained') self.set_chart_style(self.fig_d, self.ax_d) self.canvas_d = FigureCanvasTkAgg(self.fig_d, master=self.c_chart_day); self.canvas_d.get_tk_widget().pack(fill="both", expand=True) self.fig_w, self.ax_w = plt.subplots(figsize=(5,3), dpi=90, layout='constrained') self.set_chart_style(self.fig_w, self.ax_w) self.canvas_w = FigureCanvasTkAgg(self.fig_w, master=self.c_chart_week); self.canvas_w.get_tk_widget().pack(fill="both", expand=True) def set_chart_style(self, fig, ax): bg = '#2b2b2b'; text = '#e0e0e0' fig.set_facecolor(bg); ax.set_facecolor(bg) ax.tick_params(axis='x', colors=text); ax.tick_params(axis='y', colors=text, pad=1, labelsize=10) for sp in ax.spines.values(): sp.set_color(text) def refresh_dashboard(self): try: plt.close('all') now = datetime.now() # ЗАГРУЖАЕМ ДАННЫЕ ЗА ВЕСЬ ГОД year_str = now.strftime("%Y") raw_data = self.db.get_workload_by_year(year_str) # Фильтруем только daily data = raw_data df = pd.DataFrame([dict(r) for r in data]) self.c_recs_day.config(state="normal"); self.c_recs_day.delete("1.0", "end") self.c_recs_week.config(state="normal"); self.c_recs_week.delete("1.0", "end") if df.empty: self.c_chart_day.configure(text="Услуги (Нет данных)") self.ax_d.clear(); self.canvas_d.draw() self.ax_w.clear(); self.canvas_w.draw() return df = df.copy() # Определяем колонку с названием услуги (может быть service_name, val, task_name) service_col = None for col_name in ['service_name', 'val', 'task_name']: if col_name in df.columns: service_col = col_name break if service_col is None: # Нет подходящей колонки - используем заглушку df['clean_name'] = 'Услуга' else: df['clean_name'] = df[service_col].apply(lambda x: str(x).split(' [')[0].strip() if pd.notna(x) else 'Услуга') # Определяем колонку с датой date_col = None for col_name in ['created_at', 'date', 'task_date', 'period']: if col_name in df.columns: date_col = col_name break if date_col is None: # Нет даты - используем текущую дату df['date_obj'] = pd.to_datetime('today') else: # Парсим даты с format='mixed' для поддержки разных форматов # ('2026-02-09' и '2026-02-09T12:00:00' одновременно) df['date_obj'] = pd.to_datetime(df[date_col], format='mixed', errors='coerce') df['date_only'] = df['date_obj'].dt.strftime('%Y-%m-%d') # Убираем строки где дата не определена (NaT -> 'NaT' строка) df = df[df['date_only'].notna() & (df['date_only'] != 'NaT')].copy() if df.empty: self.c_chart_day.configure(text="Услуги (Нет данных с корректными датами)") self.ax_d.clear(); self.canvas_d.draw() self.ax_w.clear(); self.canvas_w.draw() return # Определяем колонку с количеством (может быть count или фиксированная 1) if 'count' in df.columns: df['count'] = pd.to_numeric(df['count'], errors='coerce').fillna(1) else: # Нет count - считаем каждую запись как 1 df['count'] = 1 # Теперь можно безопасно сортировать valid_dates = df['date_only'].unique() valid_dates = [d for d in valid_dates if isinstance(d, str) and d != 'NaT'] if not valid_dates: self.c_chart_day.configure(text="Услуги (Нет валидных дат)") self.ax_d.clear(); self.canvas_d.draw() self.ax_w.clear(); self.canvas_w.draw() return last_date_str = sorted(valid_dates)[-1] last_date_dt = datetime.strptime(last_date_str, "%Y-%m-%d") # === РАСЧЕТ ДАТ ДЛЯ СРАВНЕНИЯ === # 1. Предыдущий рабочий день (поиск назад) offset = 1 while True: prev_date = last_date_dt - timedelta(days=offset) if self.cal.is_working_day(prev_date.date()): prev_work_day_str = prev_date.strftime("%Y-%m-%d") break offset += 1 if offset > 30: # Защита от бесконечного цикла prev_work_day_str = None break # 2. Тот же день на прошлой неделе same_day_last_week_dt = last_date_dt - timedelta(days=7) same_day_last_week_str = same_day_last_week_dt.strftime("%Y-%m-%d") is_same_day_working = self.cal.is_working_day(same_day_last_week_dt.date()) # Вычисляем метки графиков и сохраняем состояние для фильтра НЭ fmt_today = last_date_dt.strftime("%d.%m") fmt_prev = prev_date.strftime("%d.%m") if prev_work_day_str else "??" chart_title = f"Услуги: {fmt_today} vs {fmt_prev}" if is_same_day_working: chart_title += f" vs {same_day_last_week_dt.strftime('%d.%m')}" label_prev = prev_date.strftime('%d.%m') if prev_work_day_str else "Пред." label_last_week = same_day_last_week_dt.strftime('%d.%m') if is_same_day_working else None start_curr_week_dt = last_date_dt - timedelta(days=last_date_dt.weekday()) start_curr_week = start_curr_week_dt.strftime("%Y-%m-%d") start_prev_week_dt = last_date_dt - timedelta(days=last_date_dt.weekday() + 7) start_prev_week = start_prev_week_dt.strftime("%Y-%m-%d") end_prev_week = (last_date_dt - timedelta(days=last_date_dt.weekday() + 1)).strftime("%Y-%m-%d") w_label_curr = f"{start_curr_week_dt.strftime('%d.%m')} - {last_date_dt.strftime('%d.%m')}" w_label_prev = f"{start_prev_week_dt.strftime('%d.%m')} - {(last_date_dt - timedelta(days=last_date_dt.weekday() + 1)).strftime('%d.%m')}" # Сохраняем данные и инициализируем навигацию self._dash_df = df self._week_sets_cache = {} self._chart_state = { 'last_date_str': last_date_str, 'prev_work_day_str': prev_work_day_str, 'same_day_last_week_str': same_day_last_week_str, 'is_same_day_working': is_same_day_working, 'chart_title': chart_title, 'fmt_today': fmt_today, 'label_prev': label_prev, 'label_last_week': label_last_week, 'start_curr_week': start_curr_week, 'start_prev_week': start_prev_week, 'end_prev_week': end_prev_week, 'w_label_curr': w_label_curr, 'w_label_prev': w_label_prev, } self._redraw_charts() # День: только текущий месяц current_month = last_date_dt.strftime("%Y-%m") month_days = sorted([d for d in valid_dates if d.startswith(current_month)]) self._all_days = month_days if month_days else sorted(valid_dates) self._day_idx = len(self._all_days) - 1 # Неделя: весь год week_starts = set() for d in valid_dates: dt = datetime.strptime(d, "%Y-%m-%d") monday = dt - timedelta(days=dt.weekday()) week_starts.add(monday.strftime("%Y-%m-%d")) self._all_weeks = sorted(week_starts) self._week_idx = len(self._all_weeks) - 1 self._refresh_leaders_day() self._refresh_leaders_week() except Exception as e: logger.error(f"Dashboard refresh error: {e}", exc_info=True) def _on_ne_filter_changed(self): self._redraw_charts() def _redraw_charts(self): """Перерисовать графики с учётом текущего фильтра НЭ (Все / Москва).""" if self._dash_df is None or not hasattr(self, '_chart_state'): return s = self._chart_state df = self._dash_df # Применяем фильтр по НЭ if self.var_ne_filter.get() == 'moscow' and 'ne_value' in df.columns: df = df[df['ne_value'].fillna('').str.lower().str.strip() == 'москва'] # График Дня self.c_chart_day.configure(text=s['chart_title']) df_chart_d = self.prepare_comparison_data( df, curr_period=s['last_date_str'], prev_period=s['prev_work_day_str'], is_week=False, extra_period=s['same_day_last_week_str'] if s['is_same_day_working'] else None ) labels_map = {'Предыдущий': s['label_prev'], 'Текущий': s['fmt_today']} if s['is_same_day_working']: labels_map['Прошл.неделя'] = s['label_last_week'] self.update_chart(self.ax_d, self.canvas_d, df_chart_d, labels_map) # График Недели self.c_chart_week.configure(text="Услуги: Неделя (текущая vs прошлая)") df_chart_w = self.prepare_comparison_data( df, (s['start_curr_week'], s['last_date_str']), (s['start_prev_week'], s['end_prev_week']), is_week=True ) self.update_chart(self.ax_w, self.canvas_w, df_chart_w, {'Предыдущий': s['w_label_prev'], 'Текущий': s['w_label_curr']}) def prepare_comparison_data(self, df, curr_period, prev_period, is_week, extra_period=None): # Получаем справочник услуг (может быть пустым) try: all_svcs = self.db.get_services() basic_map = {dict(s)['name'].lower().strip(): dict(s).get('is_basic', 0) for s in all_svcs} except Exception: basic_map = {} # Если нет справочника - считаем все услуги базовыми if not basic_map: df['is_basic'] = 1 else: df['is_basic'] = df['clean_name'].apply(lambda x: basic_map.get(x.lower(), 0)) mask_common = ((df['clean_name'] != 'Километраж') & (df['clean_name'] != 'Выезд') & (df['is_basic'] == 1)) df_clean = df[mask_common] s_extra = None if is_week: mask_curr = (df_clean['date_only'] >= curr_period[0]) & (df_clean['date_only'] <= curr_period[1]) mask_prev = (df_clean['date_only'] >= prev_period[0]) & (df_clean['date_only'] <= prev_period[1]) else: mask_curr = (df_clean['date_only'] == curr_period) mask_prev = (df_clean['date_only'] == prev_period) if extra_period: mask_extra = (df_clean['date_only'] == extra_period) s_extra = df_clean[mask_extra].groupby('clean_name')['count'].sum() s_curr = df_clean[mask_curr].groupby('clean_name')['count'].sum() s_prev = df_clean[mask_prev].groupby('clean_name')['count'].sum() data = {'Текущий': s_curr, 'Предыдущий': s_prev} if s_extra is not None: data['Прошл.неделя'] = s_extra res = pd.DataFrame(data).fillna(0) res = res.sort_values(by='Текущий', ascending=True) # Filter rows if s_extra is not None: res = res[(res['Текущий'] > 0) | (res['Предыдущий'] > 0) | (res['Прошл.неделя'] > 0)] return res[['Предыдущий', 'Прошл.неделя', 'Текущий']] else: res = res[(res['Текущий'] > 0) | (res['Предыдущий'] > 0)] return res[['Предыдущий', 'Текущий']] def update_chart(self, ax, canvas, df, labels_map): ax.clear() bg_color = '#2b2b2b' text_color = '#e0e0e0' ax.set_facecolor(bg_color) ax.tick_params(axis='x', colors=text_color) ax.tick_params(axis='y', colors=text_color, pad=1, labelsize=10) ax.spines['bottom'].set_color(text_color) ax.spines['top'].set_color(text_color) ax.spines['left'].set_color(text_color) ax.spines['right'].set_color(text_color) if df.empty: canvas.draw() return # Rename columns for display df.columns = [labels_map.get(c, c) for c in df.columns] # Simpler: just create a color list matching columns color_list = [] # Logic: Current is always last column in our prepare_comparison_data return count = len(df.columns) if count == 3: color_list = ['#6c757d', '#ced4da', '#4dabf7'] # Gray, LightGray, Blue else: color_list = ['#6c757d', '#4dabf7'] # Gray, Blue # Plot df_plot = df df_plot.index.name = None labels = [textwrap.fill(str(label), width=30) for label in df_plot.index] bars = df_plot.plot(kind='barh', ax=ax, color=color_list, width=0.8) ax.set_ylabel('') ax.set_xlabel('') ax.set_yticklabels(labels) legend = ax.legend(loc='lower right', facecolor=bg_color, edgecolor=text_color) for text in legend.get_texts(): text.set_color(text_color) # Labels on bars (only for the last bar / current usually, or all?) # Let's label only current (last column) ax.bar_label(bars.containers[-1], padding=3, color='white', fontweight='bold') canvas.draw() def is_emp_working(self, emp_id, dt): d_str = dt.strftime("%Y-%m-%d") # Исправлено: get_employee_vacations_range принимает 3 параметра (emp_id, start, end) ranges_data = self.db.get_employee_vacations_range(emp_id, d_str, d_str) ranges = [(r.start, r.end) for r in ranges_data] if ranges_data else [] for s, e in ranges: if s <= d_str <= e: return False ovr = self.db.get_employee_day_override(emp_id, d_str) if ovr is not None: return bool(ovr) # Проверяем есть ли метод get_calendar_exception if hasattr(self.db, 'get_calendar_exception'): g_ovr = self.db.get_calendar_exception(d_str) if g_ovr: return bool(g_ovr.get('is_working', True)) # Проверяем выходной день if dt.weekday() >= 5: return False return True # === НАВИГАЦИЯ ПО АРХИВНЫМ ДАННЫМ === def _nav_day(self, delta): if not self._all_days: return new_idx = self._day_idx + delta if 0 <= new_idx < len(self._all_days): self._day_idx = new_idx self._refresh_leaders_day() def _nav_week(self, delta): if not self._all_weeks: return new_idx = self._week_idx + delta if 0 <= new_idx < len(self._all_weeks): self._week_idx = new_idx self._refresh_leaders_week() def _refresh_leaders_day(self): if not self._all_days or self._dash_df is None: return day_str = self._all_days[self._day_idx] day_dt = datetime.strptime(day_str, "%Y-%m-%d") label = day_dt.strftime('%d.%m.%Y') suffix = " [архив]" if self._day_idx < len(self._all_days) - 1 else "" self.f_recs_d.configure(text=f"Лидеры: {label}{suffix}") self.lbl_day_nav.configure(text=f"{self._day_idx + 1} / {len(self._all_days)}") df_day = self._dash_df[self._dash_df['date_only'] == day_str] self.fill_text(self.c_recs_day, df_day, 'day', day_dt) def _refresh_leaders_week(self): if not self._all_weeks or self._dash_df is None: return week_start_str = self._all_weeks[self._week_idx] week_start_dt = datetime.strptime(week_start_str, "%Y-%m-%d") week_end_dt = week_start_dt + timedelta(days=6) week_end_str = week_end_dt.strftime("%Y-%m-%d") df_week = self._dash_df[ (self._dash_df['date_only'] >= week_start_str) & (self._dash_df['date_only'] <= week_end_str) ] if df_week.empty: actual_end_dt = week_end_dt else: actual_end_str = df_week['date_only'].max() actual_end_dt = datetime.strptime(actual_end_str, "%Y-%m-%d") w_label = f"{week_start_dt.strftime('%d.%m')} - {actual_end_dt.strftime('%d.%m')}" suffix = " [архив]" if self._week_idx < len(self._all_weeks) - 1 else "" self.f_recs_w.configure(text=f"Лидеры: {w_label}{suffix}") self.lbl_week_nav.configure(text=f"{self._week_idx + 1} / {len(self._all_weeks)}") # Вычисляем полосы повторяющихся результатов (⚡ = N недель подряд) actual_end_str = actual_end_dt.strftime("%Y-%m-%d") curr_sets = self._get_week_employee_sets(week_start_str, actual_end_str) streaks = {'low3': {}, 'no_basic': {}} for section in ('low3', 'no_basic'): for name in curr_sets[section]: streak = 0 for back in range(1, 9): # смотрим не дальше 8 недель назад back_idx = self._week_idx - back if back_idx < 0: break prev_start = self._all_weeks[back_idx] prev_end_dt = datetime.strptime(prev_start, "%Y-%m-%d") + timedelta(days=6) df_prev = self._dash_df[ (self._dash_df['date_only'] >= prev_start) & (self._dash_df['date_only'] <= prev_end_dt.strftime("%Y-%m-%d")) ] if df_prev.empty: break prev_sets = self._get_week_employee_sets(prev_start, df_prev['date_only'].max()) if name in prev_sets[section]: streak += 1 else: break if streak > 0: streaks[section][name] = streak self._week_streaks = streaks self.fill_text(self.c_recs_week, df_week, 'week', week_start_dt, actual_end_dt) def _get_week_employee_sets(self, week_start_str, actual_end_str): """Множества сотрудников из разделов 'Менее 3' и 'Нет закрытых' за неделю. Результат кэшируется до следующей перезагрузки данных.""" cache_key = (week_start_str, actual_end_str) if cache_key in self._week_sets_cache: return self._week_sets_cache[cache_key] result = {'low3': set(), 'no_basic': set()} if self._dash_df is None: return result try: df = self._dash_df[ (self._dash_df['date_only'] >= week_start_str) & (self._dash_df['date_only'] <= actual_end_str) ].copy() if df.empty: self._week_sets_cache[cache_key] = result return result all_emps = {e['name']: dict(e) for e in self.db.get_employees()} svcs = {s['name'].lower().strip(): dict(s) for s in self.db.get_services()} main_svc_names = [n for n, info in svcs.items() if info.get('is_basic') and not any(x in n for x in ['километраж', 'пробег', 'выезд'])] df['clean_lower'] = df['clean_name'].str.lower().str.strip() df_main = df[df['clean_lower'].isin(main_svc_names)] emp_totals = df_main.groupby('emp_name')['count'].sum() period_dates = sorted(df['date_only'].unique()) exclude_depts = ['руководство', 'контроль', 'судебный'] # Менее 3 базовых услуг for name, total in emp_totals.items(): emp_data = all_emps.get(name) if not emp_data or emp_data.get('is_temp', 0): continue dept = emp_data.get('department', '') if not dept or dept.lower() in exclude_depts: continue emp_id = emp_data['id'] emp_work_days = sum( 1 for d_str in period_dates if self.is_emp_working(emp_id, datetime.strptime(d_str, "%Y-%m-%d")) ) if emp_work_days < 1: continue if (total / emp_work_days) < 3: result['low3'].add(name) # Нет закрытых базовых услуг active_names = set(df['emp_name'].unique()) week_start_dt = datetime.strptime(week_start_str, "%Y-%m-%d") actual_end_dt = datetime.strptime(actual_end_str, "%Y-%m-%d") for name in set(all_emps.keys()) - active_names: emp = all_emps[name] dept = emp.get('department', '').lower().strip() if dept in exclude_depts or emp.get('is_temp', 0): continue emp_id = emp['id'] curr = week_start_dt has_work_day = False while curr <= actual_end_dt: if self.is_emp_working(emp_id, curr): has_work_day = True break curr += timedelta(days=1) if has_work_day: result['no_basic'].add(name) except Exception as e: logger.error(f"_get_week_employee_sets error: {e}", exc_info=True) self._week_sets_cache[cache_key] = result return result def fill_text(self, txt, df, period_type, d_start, d_end=None): txt.config(state="normal") txt.delete("1.0", "end") df = df.copy() if df.empty: txt.insert("end", "Нет данных\n") txt.config(state="disabled") return all_emps = {e['name']: dict(e) for e in self.db.get_employees()} df['clean_lower'] = df['clean_name'].str.lower().str.strip() mask_km = df['clean_lower'].str.contains('выезд|километраж|пробег', na=False, case=False) top_km = df[mask_km].groupby('emp_name')['count'].sum().sort_values(ascending=False).head(3) txt.insert("end", "🚗 Километраж:\n") if top_km.empty: txt.insert("end", " -\n") for n, v in top_km.items(): txt.insert("end", f" • {n}: {int(v)}\n") # FIX: УТОЧНЕННЫЙ ФИЛЬТР ДЛЯ РИСК/ЮЛ (ИСКЛЮЧАЕТ 'Оценка ЮЛ') mask_risk = df['clean_lower'].str.contains(r'псо.*юл|осмотр.*риск', regex=True, na=False) top_risk = df[mask_risk].groupby('emp_name')['count'].sum().sort_values(ascending=False).head(3) txt.insert("end", "\n🏗 Осмотры ЮЛ (Часы):\n") if top_risk.empty: txt.insert("end", " -\n") for n, v in top_risk.items(): txt.insert("end", f" • {n}: {float(v):.1f}\n") txt.insert("end", "\n🏢 Лидеры отделов:\n") svcs = {s['name'].lower().strip(): dict(s) for s in self.db.get_services()} main_svc_names = [name for name, info in svcs.items() if info.get('is_basic') and not any(x in name for x in ['километраж', 'пробег', 'выезд'])] df_main = df[df['clean_lower'].isin(main_svc_names)] emp_totals = df_main.groupby('emp_name')['count'].sum() dept_leaders = {} exclude_depts = ['судебный', 'руководство'] for name, total in emp_totals.items(): emp_data = all_emps.get(name) if not emp_data: continue dept = emp_data['department'] if not dept or dept.lower() in exclude_depts: continue if dept not in dept_leaders: dept_leaders[dept] = (name, total) else: if total > dept_leaders[dept][1]: dept_leaders[dept] = (name, total) if not dept_leaders: txt.insert("end", " -\n") for dept in sorted(dept_leaders.keys()): name, val = dept_leaders[dept] txt.insert("end", f" • {dept}: {name} ({int(val)})\n") # === МЕНЕЕ 3 БАЗОВЫХ УСЛУГ (по отделам) === # Расчёт с учётом индивидуального числа рабочих дней сотрудника exclude_depts_low = ['руководство', 'контроль', 'судебный'] if period_type == 'week': period_dates = sorted(df['date_only'].unique()) txt.insert("end", "\n⚠️ Менее 3 базовых услуг (ср/день):\n") else: txt.insert("end", "\n⚠️ Менее 3 базовых услуг:\n") low_by_dept = {} for name, total in emp_totals.items(): emp_data = all_emps.get(name) if not emp_data: continue if emp_data.get('is_temp', 0): continue dept = emp_data.get('department', '') if not dept or dept.lower() in exclude_depts_low: continue emp_id = emp_data['id'] if period_type == 'week': # Рабочие дни именно этого сотрудника в периоде emp_work_days = sum( 1 for d_str in period_dates if self.is_emp_working(emp_id, datetime.strptime(d_str, "%Y-%m-%d")) ) if emp_work_days < 1: continue # сотрудник не работал в периоде — не показываем val = total / emp_work_days else: # Для дня: если у сотрудника выходной/отпуск — не показываем if not self.is_emp_working(emp_id, d_start): continue val = total if val >= 3: continue low_by_dept.setdefault(dept, []).append((name, val)) if not low_by_dept: txt.insert("end", " Все выполнили 3+\n") else: for dept in sorted(low_by_dept.keys()): txt.insert("end", f" {dept}:\n") for name, val in sorted(low_by_dept[dept], key=lambda x: x[1]): streak_str = "" if period_type == 'week': s = self._week_streaks.get('low3', {}).get(name, 0) if s > 0: streak_str = " " + "⚡" * s txt.insert("end", f" • {name} ({val:.1f}){streak_str}\n") # === НОВЫЙ БЛОК: СПЕЦ ЗАДАЧИ === txt.insert("end", "\n🔧 Исправления:\n") specific_tasks = [ ("Испр. смету коммент (имущ)", "Испр. смету"), ("Исправить кальку (ком) (имущ)", "Испр. кальку"), ("Доп. отчет риск.инж.", "Доп. отчет РИ") ] found_any_spec = False for raw_pattern, display_label in specific_tasks: # Ищем точное совпадение или вхождение (приводим к нижнему регистру) pat_lower = raw_pattern.lower() # Используем escape, т.к. в названиях есть скобки и точки mask_spec = df['clean_lower'].str.contains(re.escape(pat_lower), na=False) top_spec = df[mask_spec].groupby('emp_name')['count'].sum().sort_values(ascending=False).head(1) if not top_spec.empty: name = top_spec.index[0] val = top_spec.values[0] txt.insert("end", f" • {display_label}: {name} ({int(val)})\n") found_any_spec = True if not found_any_spec: txt.insert("end", " -\n") txt.insert("end", "\n💤 Нет закрытых базовых услуг:\n") active_names = set(df['emp_name'].unique()) all_emp_names = set(all_emps.keys()) potential_lazy = sorted(list(all_emp_names - active_names)) real_lazy = [] exclude_depts_lazy = ['руководство', 'контроль', 'судебный'] for name in potential_lazy: emp = all_emps[name] dept = emp.get('department', '').lower().strip() if dept in exclude_depts_lazy: continue if emp.get('is_temp', 0): continue # === НОВАЯ ЛОГИКА ПРОВЕРКИ === is_lazy = False if period_type == 'day': # Если это конкретный день: проверяем, должен ли был работать if self.is_emp_working(emp['id'], d_start): is_lazy = True else: # Если период (неделя/месяц): проверяем, был ли ХОТЬ ОДИН рабочий день # Если человек был в отпуске ВЕСЬ период - он не lazy. # Если вышел хотя бы на 1 день и ничего не сделал - lazy. curr = d_start has_at_least_one_work_day = False # Ограничиваем цикл разумно (не больше 31 дня для месяца) # d_end должен быть datetime while curr <= d_end: if self.is_emp_working(emp['id'], curr): has_at_least_one_work_day = True break curr += timedelta(days=1) if has_at_least_one_work_day: is_lazy = True if is_lazy: real_lazy.append(name) if not real_lazy: txt.insert("end", " Все работают!\n") for n in real_lazy[:10]: streak_str = "" if period_type == 'week': s = self._week_streaks.get('no_basic', {}).get(n, 0) if s > 0: streak_str = " " + "⚡" * s txt.insert("end", f" • {n}{streak_str}\n") if len(real_lazy) > 10: txt.insert("end", f" ...и еще {len(real_lazy)-10}\n") txt.config(state="disabled") # ========================================================= # Telegram-уведомления по итогам дня # ========================================================= def _get_prev_working_day_iso(self): """Вернуть ISO-строку предыдущего рабочего дня""" d = date.today() - timedelta(days=1) for _ in range(14): # не более 2 недель назад if self.cal.is_working_day(d): return d.strftime("%Y-%m-%d") d -= timedelta(days=1) return (date.today() - timedelta(days=1)).strftime("%Y-%m-%d") def _rule_is_enabled(self, subject_key): """Проверить, включено ли правило рассылки по ключевому слову темы""" try: rules = self.db.get_tg_rules() for r in rules: if subject_key.lower() in r.get('subject', '').lower(): return bool(r.get('is_enabled', 1)) except Exception: pass return True # по умолчанию включено def _build_workload_notification_data(self, iso_date): """ Подготовить данные для уведомлений по итогам дня. Возвращает (lazy_list, dept_totals, date_display) """ # Данные по нагрузке за день rows = self.db.get_workload_by_day(iso_date, source='daily') # Базовые услуги (исключая выезд/километраж) all_svcs = self.db.get_services() basic_names = { dict(s)['name'].lower().strip() for s in all_svcs if dict(s).get('is_basic') and not any(x in dict(s)['name'].lower() for x in ['километраж', 'пробег', 'выезд']) } # Сотрудники с базовыми услугами за день active_with_basic = { r.get('emp_name', '') for r in rows if (r.get('service_name') or r.get('val', '')).lower().strip() in basic_names } # Все сотрудники all_emps = {e['name']: dict(e) for e in self.db.get_employees()} exclude_depts = {'руководство', 'контроль', 'судебный'} # Кто не выполнил базовые услуги d_obj = datetime.strptime(iso_date, "%Y-%m-%d").date() lazy = [] for name, emp in all_emps.items(): if name in active_with_basic: continue dept = emp.get('department', '').lower().strip() if dept in exclude_depts: continue if emp.get('is_temp', 0): continue try: if self.is_emp_working(emp['id'], d_obj): lazy.append(name) except Exception: pass lazy.sort() # Итоги по отделам: { отдел: { сотрудник: кол-во } } dept_emp_counts = {} for r in rows: svc = (r.get('service_name') or r.get('val', '')).lower().strip() if svc not in basic_names: continue emp_name = r.get('emp_name', '') emp = all_emps.get(emp_name, {}) dept = emp.get('department', 'Без отдела') or 'Без отдела' dept_low = dept.lower().strip() if dept_low in exclude_depts: continue try: cnt = int(r.get('count', 0) or 0) except Exception: cnt = 0 dept_emp_counts.setdefault(dept, {}) dept_emp_counts[dept][emp_name] = dept_emp_counts[dept].get(emp_name, 0) + cnt date_display = datetime.strptime(iso_date, "%Y-%m-%d").strftime("%d.%m.%Y") return lazy, dept_emp_counts, date_display def _resolve_chat_ids(self, recipients_str, employee_names=None): """ Преобразовать строку «Получатели» в список chat_id. 'Руководители сотрудника' → из поля 'managers' каждого сотрудника (employee_names) берутся ФИО руководителей, затем их TG ID из таблицы employees 'Рабочая группа' → TG ID всех рабочих групп 'Руководители + Группа' → объединение обоих вариантов любой другой текст → TG ID или имена через запятую/точку с запятой """ import logging log = logging.getLogger(__name__) chat_ids = [] val = (recipients_str or '').strip() try: if val in ('Руководители сотрудника', 'Руководители + Группа'): all_emps = {e['name']: dict(e) for e in self.db.get_employees()} names = employee_names or list(all_emps.keys()) manager_names = set() for name in names: emp = all_emps.get(name) if emp and emp.get('managers'): for m_name in emp['managers'].split(','): m_name = m_name.strip() if m_name: manager_names.add(m_name) for m_name in manager_names: mgr = all_emps.get(m_name) if mgr and mgr.get('tg_id'): tid = mgr['tg_id'] if tid not in chat_ids: chat_ids.append(tid) else: log.warning(f"Manager '{m_name}' not found or no TG ID") if val in ('Рабочая группа', 'Руководители + Группа'): groups = self.db.get_working_groups() for g in groups: tid = g.tg_id if tid and tid not in chat_ids: chat_ids.append(tid) if val not in ('Руководители сотрудника', 'Рабочая группа', 'Руководители + Группа'): parts = [p.strip() for p in re.split(r'[,;]+', val) if p.strip()] for part in parts: if part.lstrip('-').isdigit(): if part not in chat_ids: chat_ids.append(part) else: emp = self.db.get_employee_by_name(part) if emp and emp.tg_id and emp.tg_id not in chat_ids: chat_ids.append(emp.tg_id) else: log.warning(f"Recipient not found or no TG ID: '{part}'") except Exception as e: log.error(f"Error resolving chat IDs for '{val}': {e}") return chat_ids def _get_managers_map(self, employee_names): """ Для списка сотрудников вернуть словарь: { manager_tg_id: [имена подчинённых из списка] } Руководители берутся из поля 'managers' каждого сотрудника. """ all_emps = {e['name']: dict(e) for e in self.db.get_employees()} mgr_map = {} # tg_id → [emp_name, ...] for name in employee_names: emp = all_emps.get(name) if not emp or not emp.get('managers'): continue for m_name in emp['managers'].split(','): m_name = m_name.strip() if not m_name: continue mgr = all_emps.get(m_name) if mgr and mgr.get('tg_id'): tid = mgr['tg_id'] mgr_map.setdefault(tid, []).append(name) return mgr_map def _get_managers_dept_map(self): """ Вернуть словарь { manager_tg_id: set(отделы подчинённых) }. Для каждого сотрудника берётся поле 'managers' и 'department'. """ all_emps = {e['name']: dict(e) for e in self.db.get_employees()} mgr_depts = {} # tg_id → set(department) for emp in all_emps.values(): dept = (emp.get('department') or '').strip() if not dept or not emp.get('managers'): continue for m_name in emp['managers'].split(','): m_name = m_name.strip() if not m_name: continue mgr = all_emps.get(m_name) if mgr and mgr.get('tg_id'): mgr_depts.setdefault(mgr['tg_id'], set()).add(dept) return mgr_depts def _get_rule(self, subject_key): """Найти правило рассылки по ключевому слову темы""" try: for r in self.db.get_tg_rules(): if subject_key.lower() in r.get('subject', '').lower(): return r except Exception: pass return None def send_workload_notifications(self, iso_date): """ Отправить Telegram-уведомления по итогам рабочего дня: 1. Нет Базовых услуг — персонализировано каждому руководителю (только его подчинённые из поля 'managers') 2. Итоги по отделам — общая сводка всем руководителям """ import logging log = logging.getLogger(__name__) if not self.tg or not self.tg.token: messagebox.showwarning("Telegram", "Токен бота не настроен.\nПерейдите в Задачи и введите токен.") return 0 lazy, dept_emp_counts, date_display = self._build_workload_notification_data(iso_date) sent_total = 0 # === Уведомление 1: Нет Базовых услуг (персонализированное) === rule1 = self._get_rule("Нет Базовых") if rule1 and rule1.get('is_enabled', 1): recip1 = (rule1.get('recipients') or '').strip() if recip1 in ('Руководители сотрудника', 'Руководители + Группа'): # Каждый руководитель получает список только СВОИХ подчинённых if lazy: mgr_map = self._get_managers_map(lazy) for tg_id, emp_names in mgr_map.items(): body = "\n".join(f" \u2022 {n}" for n in sorted(emp_names)) msg = (f"\U0001f4cb <b>Итоги за {date_display}</b>\n\n" f"\u274c <b>Нет закрытых базовых услуг ({len(emp_names)}):</b>\n{body}") try: if self.tg.send_message(tg_id, msg): sent_total += 1 except Exception as e: log.error(f"Error sending lazy msg to {tg_id}: {e}") # Дополнительно в рабочие группы if recip1 == 'Руководители + Группа': body_all = "\n".join(f" \u2022 {n}" for n in lazy) msg_grp = (f"\U0001f4cb <b>Итоги за {date_display}</b>\n\n" f"\u274c <b>Нет закрытых базовых услуг ({len(lazy)}):</b>\n{body_all}") for g in self.db.get_working_groups(): tid = g.tg_id if tid: try: if self.tg.send_message(tid, msg_grp): sent_total += 1 except Exception as e: log.error(f"Error sending lazy msg to group {tid}: {e}") else: # Другие варианты получателей — общий список ids1 = self._resolve_chat_ids(recip1) if ids1: if lazy: body = "\n".join(f" \u2022 {n}" for n in lazy) msg1 = (f"\U0001f4cb <b>Итоги за {date_display}</b>\n\n" f"\u274c <b>Нет закрытых базовых услуг ({len(lazy)}):</b>\n{body}") else: msg1 = (f"\U0001f4cb <b>Итоги за {date_display}</b>\n\n" f"\u2705 Все сотрудники выполнили базовые услуги!") for chat_id in ids1: try: if self.tg.send_message(chat_id, msg1): sent_total += 1 except Exception as e: log.error(f"Error sending lazy msg to {chat_id}: {e}") # === Уведомление 2: Итоги по отделам (персонализированное) === # dept_emp_counts = { "Выездной": {"Иванов": 8, "Петров": 5}, ... } rule2 = self._get_rule("Итоги по отделам") if rule2 and rule2.get('is_enabled', 1) and dept_emp_counts: recip2 = (rule2.get('recipients') or '').strip() def _build_dept_msg(dept, emp_counts): """Сформировать сообщение по одному отделу""" lines = "\n".join( f" \u2022 {name}: {cnt}" for name, cnt in sorted(emp_counts.items(), key=lambda x: -x[1]) ) total = sum(emp_counts.values()) return (f"\U0001f4ca <b>Итоги по отделу: {dept}</b>\n" f"<i>{date_display}</i>\n\n" f"{lines}\n\n" f"<b>Итого: {total}</b>") if recip2 in ('Руководители сотрудника', 'Руководители + Группа'): # Каждый руководитель получает сообщения только по отделам # своих подчинённых, отдельное сообщение на каждый отдел mgr_depts = self._get_managers_dept_map() for tg_id, depts in mgr_depts.items(): for dept in sorted(depts): emp_counts = dept_emp_counts.get(dept) if not emp_counts: continue msg = _build_dept_msg(dept, emp_counts) try: if self.tg.send_message(tg_id, msg): sent_total += 1 except Exception as e: log.error(f"Error sending dept '{dept}' msg to {tg_id}: {e}") # Дополнительно в рабочие группы — все отделы if recip2 == 'Руководители + Группа': for g in self.db.get_working_groups(): tid = g.tg_id if not tid: continue for dept in sorted(dept_emp_counts.keys()): msg = _build_dept_msg(dept, dept_emp_counts[dept]) try: if self.tg.send_message(tid, msg): sent_total += 1 except Exception as e: log.error(f"Error sending dept msg to group {tid}: {e}") else: # Другие варианты получателей — все отделы каждому ids2 = self._resolve_chat_ids(recip2) if ids2: for dept in sorted(dept_emp_counts.keys()): msg = _build_dept_msg(dept, dept_emp_counts[dept]) for chat_id in ids2: try: if self.tg.send_message(chat_id, msg): sent_total += 1 except Exception as e: log.error(f"Error sending dept msg to {chat_id}: {e}") return sent_total def _ask_and_send_notifications(self): """Диалог выбора даты и отправка уведомлений""" from tkinter import simpledialog prev = self._get_prev_working_day_iso() prev_display = datetime.strptime(prev, "%Y-%m-%d").strftime("%d.%m.%Y") date_str = simpledialog.askstring( "Отправка уведомлений TG", f"Введите дату (ДД.ММ.ГГГГ) для отправки итогов:\n(по умолчанию — прошлый рабочий день)", initialvalue=prev_display, parent=self.winfo_toplevel() ) if not date_str: return try: iso = datetime.strptime(date_str.strip(), "%d.%m.%Y").strftime("%Y-%m-%d") except ValueError: messagebox.showerror("Ошибка", "Неверный формат даты. Используйте ДД.ММ.ГГГГ") return sent = self.send_workload_notifications(iso) if sent: messagebox.showinfo("Готово", f"Отправлено уведомлений: {sent}") def show_tg_stats(self): try: s, r, m = self.db.get_tg_stats_today() messagebox.showinfo("TG", f"Sent: {s}\nResp: {r}\nMsgs: {m}") except: messagebox.showerror("Err", "No data") # ── Email-рассылка загрузок руководителям ──────────────────────────────── def _open_workload_email_settings(self): WorkloadEmailSettingsDialog(self.winfo_toplevel(), self.db) def _ask_and_send_workload_email(self): """Диалог выбора даты и email-рассылка итогов руководителям.""" from tkinter import simpledialog prev = self._get_prev_working_day_iso() prev_display = datetime.strptime(prev, "%Y-%m-%d").strftime("%d.%m.%Y") date_str = simpledialog.askstring( "Отправка итогов по Email", "Введите дату (ДД.ММ.ГГГГ) для отправки итогов руководителям:", initialvalue=prev_display, parent=self.winfo_toplevel() ) if not date_str: return try: iso = datetime.strptime(date_str.strip(), "%d.%m.%Y").strftime("%Y-%m-%d") except ValueError: messagebox.showerror("Ошибка", "Неверный формат даты. Используйте ДД.ММ.ГГГГ") return self.send_workload_email(iso) def _get_managers_email_map(self, employee_names=None): """ Вернуть { manager_email: [имена подчинённых] } для включённых руководителей. Руководители берутся из поля 'managers' каждого сотрудника. Если employee_names=None — берём всех сотрудников. """ enabled_raw = self.db.get_setting(WorkloadEmailSettingsDialog._SETTING_KEY, '') enabled_set = {n.strip() for n in enabled_raw.split(',') if n.strip()} if enabled_raw else None all_emps = {e['name']: dict(e) for e in self.db.get_employees()} names = employee_names if employee_names is not None else list(all_emps.keys()) mgr_map = {} # email → [emp_name, ...] for name in names: emp = all_emps.get(name) if not emp or not emp.get('managers'): continue for m_name in emp['managers'].split(','): m_name = m_name.strip() if not m_name: continue if enabled_set is not None and m_name not in enabled_set: continue mgr = all_emps.get(m_name) if mgr and mgr.get('email'): mgr_map.setdefault(mgr['email'], {'name': m_name, 'emps': []}) mgr_map[mgr['email']]['emps'].append(name) return mgr_map # { email: {'name': mgr_name, 'emps': [...]} } def send_workload_email(self, iso_date): """Отправить персонализированные итоги дня каждому руководителю по email.""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart host = self.db.get_setting('smtp_host', '') login = self.db.get_setting('smtp_login', '') password = self.db.get_setting('smtp_password', '') try: port = int(self.db.get_setting('smtp_port', '587') or '587') except ValueError: port = 587 use_ssl = self.db.get_setting('smtp_ssl', '0') == '1' if not host or not login: messagebox.showerror("Ошибка", "Настройте параметры SMTP.\n" "Откройте «По экспертам» → ⚙ (Email SMTP).", parent=self) return lazy, dept_emp_counts, date_display = self._build_workload_notification_data(iso_date) all_emps = {e['name']: dict(e) for e in self.db.get_employees()} # Собираем все данные за день по сотруднику (все услуги, не только базовые) rows = self.db.get_workload_by_day(iso_date, source='daily') all_svcs = self.db.get_services() basic_names = { dict(s)['name'].lower().strip() for s in all_svcs if dict(s).get('is_basic') and not any(x in dict(s)['name'].lower() for x in ['километраж', 'пробег', 'выезд']) } # emp_name → {svc_name: count} emp_svc_counts = {} for r in rows: nm = r.get('emp_name', '') svc = (r.get('service_name') or r.get('val', '') or '').strip() if not nm or not svc: continue try: cnt = int(r.get('count', 0) or 0) except Exception: cnt = 0 emp_svc_counts.setdefault(nm, {}) emp_svc_counts[nm][svc] = emp_svc_counts[nm].get(svc, 0) + cnt mgr_map = self._get_managers_email_map() if not mgr_map: messagebox.showwarning("Нет получателей", "Нет руководителей с email для рассылки.\n" "Откройте настройки 📬 и настройте получателей.", parent=self) return # Стили tbl_s = 'border-collapse:collapse;font-size:11px;font-family:Calibri,Arial,sans-serif;' th_s = ('background:#4472C4;color:#fff;font-weight:bold;padding:4px 8px;' 'text-align:center;border:1px solid #2F5496;white-space:nowrap;') th_l = th_s.replace('text-align:center;', 'text-align:left;') td_c = 'text-align:center;padding:3px 6px;border:1px solid #D9D9D9;' td_l = 'text-align:left;padding:3px 8px;border:1px solid #D9D9D9;' td_tot = ('background:#4472C4;color:#fff;font-weight:bold;' 'padding:4px 8px;border:1px solid #2F5496;text-align:center;') sent_emails = [] errors = [] for email_addr, info in mgr_map.items(): mgr_name = info['name'] sub_names = sorted(set(info['emps'])) # Группируем подчинённых по отделу by_dept = {} for nm in sub_names: emp = all_emps.get(nm, {}) dept = (emp.get('department') or 'Без отдела').strip() or 'Без отдела' by_dept.setdefault(dept, []).append(nm) # Строим HTML-таблицу rows_html = [] for dept in sorted(by_dept.keys()): # Строка-разделитель отдела rows_html.append( f'<tr><td colspan="3" style="background:#D6DCE4;font-weight:bold;' f'padding:3px 8px;border:1px solid #C0C0C0;">{dept}</td></tr>' ) dept_basic_total = 0 dept_total = 0 for emp_nm in sorted(by_dept[dept]): svc_data = emp_svc_counts.get(emp_nm, {}) basic_total = sum(cnt for svc, cnt in svc_data.items() if svc.lower().strip() in basic_names) all_total = sum(svc_data.values()) dept_basic_total += basic_total dept_total += all_total no_basic = emp_nm in lazy mark = ' ⚠' if no_basic else '' row_bg = '#FFF2CC' if no_basic else ('#F2F2F2' if len(rows_html) % 2 == 0 else '#FFFFFF') rows_html.append( f'<tr>' f'<td style="{td_l}background:{row_bg};">{emp_nm}{mark}</td>' f'<td style="{td_c}background:{row_bg};">{basic_total if basic_total else ""}</td>' f'<td style="{td_c}background:{row_bg};">{all_total if all_total else ""}</td>' f'</tr>' ) # Итог по отделу rows_html.append( f'<tr>' f'<td style="{td_tot}text-align:left;">Итого {dept}</td>' f'<td style="{td_tot}">{dept_basic_total or ""}</td>' f'<td style="{td_tot}">{dept_total or ""}</td>' f'</tr>' ) html_body = ( f'<html><body style="font-family:Calibri,Arial,sans-serif;font-size:13px;margin:20px;">' f'<p>Добрый день, {mgr_name}!</p>' f'<p>Итоги за {date_display}:</p>' f'<table style="{tbl_s}">' f'<tr>' f'<th style="{th_l}">Сотрудник</th>' f'<th style="{th_s}">Базовые</th>' f'<th style="{th_s}">Всего</th>' f'</tr>' + ''.join(rows_html) + f'</table>' f'{"<p>⚠ — не выполнили базовые услуги</p>" if lazy else ""}' f'</body></html>' ) plain_body = f"Добрый день, {mgr_name}!\n\nИтоги за {date_display}:\n\n" for dept in sorted(by_dept.keys()): plain_body += f"\n=== {dept} ===\n" for nm in sorted(by_dept[dept]): svc_data = emp_svc_counts.get(nm, {}) basic_t = sum(cnt for svc, cnt in svc_data.items() if svc.lower().strip() in basic_names) all_t = sum(svc_data.values()) mark = ' ⚠' if nm in lazy else '' plain_body += f" {nm}{mark}: базовые={basic_t}, всего={all_t}\n" subject = f"Итоги за {date_display}" threading.Thread( target=self._send_workload_smtp, args=(email_addr, subject, plain_body, html_body, host, port, use_ssl, login, password), daemon=True ).start() sent_emails.append(f"{mgr_name} <{email_addr}>") if sent_emails: messagebox.showinfo( "Отправка", f"Письмо отправляется на {len(sent_emails)} адрес(а):\n" + "\n".join(sent_emails), parent=self ) else: messagebox.showwarning("Нет получателей", "Нет руководителей с email для рассылки.", parent=self) def _send_workload_smtp(self, to_addr, subject, plain_body, html_body, host, port, use_ssl, login, password): """Отправить письмо (вызывается из потока).""" import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart try: msg = MIMEMultipart('alternative') msg['Subject'] = subject msg['From'] = login msg['To'] = to_addr msg.attach(MIMEText(plain_body, 'plain', 'utf-8')) msg.attach(MIMEText(html_body, 'html', 'utf-8')) if use_ssl: server = smtplib.SMTP_SSL(host, port, timeout=15) else: server = smtplib.SMTP(host, port, timeout=15) server.ehlo(); server.starttls(); server.ehlo() server.login(login, password) server.sendmail(login, [to_addr], msg.as_string()) server.quit() except Exception as e: err = str(e) self.after(0, lambda: messagebox.showerror("Ошибка отправки", err)) # === ГЛАВНЫЙ КЛАСС LoadApp ОПРЕДЕЛЕН ВЫШЕ (СТРОКА 773) === # Класс LoadApp с дашбордом, графиками и рейтингами используется как главный модуль. # Кнопки в дашборде открывают дополнительные окна: # - ⚙ Справочник услуг (ServicesDirectoryWindow) # - 📅 Ежедневная загрузка (DailyWorkloadWindow) # - 📈 Годовая статистика (YearlyStatsWindow)