Загрузка данных


import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import datetime


# =====================================================================
# ЯДРО РАСЧЕТА ПРЕМИИ (СОТ 2ГИС г. СОЧИ от 02.02.2026)
# =====================================================================
class SochiBonusCalculator:
    K1_PAYMENTS = 0.0145       # 1.45% - Сочи (Оплаты)
    K2_VOLUME = 0.0150         # 1.50% - Сочи (Общий объем)
    K3_GROWTH = 0.20           # 20.0% - Сочи (Прирост объема)

    NEW_SALES_RATES = {
        "КП от 2 мес. (30%)": 0.30,
        "КП от 3 мес. (40%)": 0.40,
        "КП от 4 мес. (50%)": 0.50,
        "КП от 5 мес. (55%)": 0.55,
        "КП от 6 мес. (65%)": 0.65,
        "КП от 12 мес. (80%)": 0.80,
    }

    CPC_LIMIT = 1_000_000.0
    CPC_RATE_LOW = 0.0145      # 1.45% до 1 млн
    CPC_RATE_HIGH = 0.003625   # 0.3625% свыше 1 млн
    SPEC_PROJECT_RATE = 0.10   # 10% спецпроекты

    INTERN_THRESHOLD = 50_000.0
    INTERN_MIN_PAYMENT = 5_000.0
    INTERN_MAX_BONUS = 10_000.0

    @staticmethod
    def calc_p_coeff(fact: float, plan: float) -> float:
        """Расчет коэффициента P1 или P2 с учетом порогов 90% и 150%."""
        if plan <= 0 or fact <= 0:
            return 0.0
        ratio = fact / plan
        if ratio < 0.90:
            return 0.0
        if ratio >= 1.50:
            return 1.50
        return ratio

    @classmethod
    def calc_payments_bonus(cls, plan_money: float, fact_money: float) -> dict:
        p1 = cls.calc_p_coeff(fact_money, plan_money)
        pct = (fact_money / plan_money * 100.0) if plan_money > 0 else 0.0
        bonus = p1 * cls.K1_PAYMENTS * fact_money
        return {"pct": pct, "p1": p1, "bonus": bonus}

    @classmethod
    def calc_volume_bonus(cls, plan_vol: float, fact_shipped_vol: float, total_vol: float) -> dict:
        p2 = cls.calc_p_coeff(fact_shipped_vol, plan_vol)
        pct = (fact_shipped_vol / plan_vol * 100.0) if plan_vol > 0 else 0.0
        bonus = p2 * cls.K2_VOLUME * total_vol
        return {"pct": pct, "p2": p2, "bonus": bonus}

    @classmethod
    def calc_growth_bonus(cls, total_vol_current: float, total_vol_prev: float) -> dict:
        diff = total_vol_current - total_vol_prev
        bonus = diff * cls.K3_GROWTH if diff > 0 else 0.0
        return {"diff": diff, "bonus": bonus}

    @classmethod
    def calc_cpc_bonus(cls, cpc_payments: float) -> float:
        if cpc_payments <= 0:
            return 0.0
        if cpc_payments <= cls.CPC_LIMIT:
            return cpc_payments * cls.CPC_RATE_LOW
        return (cls.CPC_LIMIT * cls.CPC_RATE_LOW) + ((cpc_payments - cls.CPC_LIMIT) * cls.CPC_RATE_HIGH)

    @classmethod
    def calc_intern_threshold_bonus(cls, fact_money: float) -> dict:
        if fact_money < cls.INTERN_MIN_PAYMENT:
            return {"r_po": 0.0, "bonus": 0.0}
        r_po = fact_money / cls.INTERN_THRESHOLD
        if r_po >= 1.0:
            r_po = 1.0
        return {"r_po": r_po, "bonus": r_po * cls.INTERN_MAX_BONUS}


def run_self_tests():
    """Автоматическая проверка математического ядра перед запуском."""
    # Тест 1: 110% по деньгам (550к из 500к)
    res_pay = SochiBonusCalculator.calc_payments_bonus(500_000, 550_000)
    assert abs(res_pay["bonus"] - 8772.50) < 0.01, "Ошибка в расчете бонуса от оплат"

    # Тест 2: < 90% (440к из 500к -> 0 руб)
    res_zero = SochiBonusCalculator.calc_payments_bonus(500_000, 440_000)
    assert res_zero["bonus"] == 0.0, "Ошибка порога 90%"

    # Тест 3: > 150% по объему (510к из 300к -> P2=1.5)
    res_vol = SochiBonusCalculator.calc_volume_bonus(300_000, 510_000, 510_000)
    assert abs(res_vol["bonus"] - 11475.00) < 0.01, "Ошибка ограничения 150%"

    # Тест 4: Прирост 50к -> 10 000 руб
    res_gr = SochiBonusCalculator.calc_growth_bonus(400_000, 350_000)
    assert abs(res_gr["bonus"] - 10000.00) < 0.01, "Ошибка расчета прироста"


# =====================================================================
# ГРАФИЧЕСКИЙ ИНТЕРФЕЙС WINDOWS (TKINTER)
# =====================================================================
class BonusApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Калькулятор премии МПП 2ГИС (г. Сочи — СОТ 2026)")
        self.geometry("1180x780")
        self.minsize(1050, 700)
        self.configure(bg="#f4f6f9")

        self._updating_sim_money = False
        self._updating_sim_vol = False

        self._setup_styles()
        self._build_ui()
        self.calculate_main()
        self.update_simulator_ranges()

    def _setup_styles(self):
        style = ttk.Style(self)
        if "vista" in style.theme_names():
            style.theme_use("vista")
        elif "clam" in style.theme_names():
            style.theme_use("clam")

        style.configure("TFrame", background="#f4f6f9")
        style.configure("Card.TLabelframe", background="#ffffff", padding=12)
        style.configure("Card.TLabelframe.Label", font=("Segoe UI", 10, "bold"), foreground="#1a3b5c", background="#ffffff")
        style.configure("Sim.TLabelframe", background="#eef6ff", padding=12)
        style.configure("Sim.TLabelframe.Label", font=("Segoe UI", 10, "bold"), foreground="#0052cc", background="#eef6ff")
        style.configure("TLabel", background="#ffffff", font=("Segoe UI", 9))
        style.configure("Sim.TLabel", background="#eef6ff", font=("Segoe UI", 9))
        style.configure("Header.TLabel", font=("Segoe UI", 13, "bold"), foreground="#1a3b5c", background="#f4f6f9")
        style.configure("BigResult.TLabel", font=("Segoe UI", 12, "bold"), foreground="#0b6e4f", background="#ffffff")
        style.configure("SimResult.TLabel", font=("Segoe UI", 11, "bold"), foreground="#0052cc", background="#eef6ff")
        style.configure("Accent.TButton", font=("Segoe UI", 10, "bold"))

    @staticmethod
    def _parse_float(val_str: str) -> float:
        if not val_str:
            return 0.0
        cleaned = str(val_str).replace(" ", "").replace("\xa0", "").replace(",", ".")
        try:
            return float(cleaned)
        except ValueError:
            return 0.0

    @staticmethod
    def _fmt_money(val: float) -> str:
        return f"{val:,.2f} ₽".replace(",", " ")

    def _build_ui(self):
        # Верхний заголовок
        top_bar = ttk.Frame(self, padding=(15, 10))
        top_bar.pack(fill="x")
        ttk.Label(
            top_bar,
            text="Расчет заработной платы и бонуса МПП 2ГИС (г. Сочи)",
            style="Header.TLabel"
        ).pack(side="left")

        # Основной контейнер (Левая колонка — Основной расчет, Правая — Симулятор и Итоги)
        main_frame = ttk.Frame(self, padding=(15, 5))
        main_frame.pack(fill="both", expand=True)
        main_frame.columnconfigure(0, weight=5)
        main_frame.columnconfigure(1, weight=5)

        # ==================== ЛЕВАЯ КОЛОНКА ====================
        left_col = ttk.Frame(main_frame)
        left_col.grid(row=0, column=0, sticky="nsew", padx=(0, 8))

        # 1. Оклад и режим работы
        frm_base = ttk.LabelFrame(left_col, text="1. Базовая часть и статус сотрудника", style="Card.TLabelframe")
        frm_base.pack(fill="x", pady=(0, 8))

        ttk.Label(frm_base, text="Статус МПП:").grid(row=0, column=0, sticky="w", pady=3)
        self.var_mode = tk.StringVar(value="standard")
        modes = [
            ("Действующий МПП (со 2-го месяца — полная СОТ)", "standard"),
            ("Новичок 1-й месяц (только бонус от оплат)", "first_month"),
            ("Стажер 1-й месяц (без объема, с порогом оплат 50 000 ₽)", "intern"),
        ]
        for idx, (text, val) in enumerate(modes):
            rb = ttk.Radiobutton(frm_base, text=text, variable=self.var_mode, value=val, command=self.calculate_main)
            rb.grid(row=idx, column=1, columnspan=3, sticky="w", pady=2)

        ttk.Label(frm_base, text="Оклад (₽):").grid(row=3, column=0, sticky="w", pady=(8, 2))
        self.ent_salary = ttk.Entry(frm_base, width=16)
        self.ent_salary.insert(0, "40000")
        self.ent_salary.grid(row=3, column=1, sticky="w", padx=5, pady=(8, 2))

        ttk.Label(frm_base, text="Квал. надбавка (₽):").grid(row=3, column=2, sticky="w", padx=(10, 0), pady=(8, 2))
        self.ent_qual = ttk.Entry(frm_base, width=16)
        self.ent_qual.insert(0, "0")
        self.ent_qual.grid(row=3, column=3, sticky="w", padx=5, pady=(8, 2))

        # 2. План.Деньги и План.Объем (Основной расчет)
        frm_plans = ttk.LabelFrame(left_col, text="2. Показатели: План.Деньги, План.Объем и Прирост", style="Card.TLabelframe")
        frm_plans.pack(fill="x", pady=(0, 8))

        # План.Деньги
        ttk.Label(frm_plans, text="План.Деньги:", font=("Segoe UI", 9, "bold")).grid(row=0, column=0, sticky="w", pady=4)
        self.ent_plan_money = ttk.Entry(frm_plans, width=16)
        self.ent_plan_money.insert(0, "500000")
        self.ent_plan_money.grid(row=0, column=1, padx=5, pady=4)

        ttk.Label(frm_plans, text="Факт.Деньги (Оплаты):").grid(row=0, column=2, sticky="w", padx=(10, 0), pady=4)
        self.ent_fact_money = ttk.Entry(frm_plans, width=16)
        self.ent_fact_money.insert(0, "550000")
        self.ent_fact_money.grid(row=0, column=3, padx=5, pady=4)

        # План.Объем
        ttk.Label(frm_plans, text="План. Объем:", font=("Segoe UI", 9, "bold")).grid(row=1, column=0, sticky="w", pady=4)
        self.ent_plan_vol = ttk.Entry(frm_plans, width=16)
        self.ent_plan_vol.insert(0, "400000")
        self.ent_plan_vol.grid(row=1, column=1, padx=5, pady=4)

        ttk.Label(frm_plans, text="Факт отгруж. объем:").grid(row=1, column=2, sticky="w", padx=(10, 0), pady=4)
        self.ent_fact_vol = ttk.Entry(frm_plans, width=16)
        self.ent_fact_vol.insert(0, "400000")
        self.ent_fact_vol.grid(row=1, column=3, padx=5, pady=4)

        # Общий объем в выпуске и прошлый месяц
        self.var_same_vol = tk.BooleanVar(value=True)
        chk_same = ttk.Checkbutton(
            frm_plans,
            text="Общий объем в выпуске равен Факт. отгруженному объему",
            variable=self.var_same_vol,
            command=self._toggle_total_vol
        )
        chk_same.grid(row=2, column=0, columnspan=4, sticky="w", pady=(4, 2))

        ttk.Label(frm_plans, text="Общий объем (тек.):").grid(row=3, column=0, sticky="w", pady=4)
        self.ent_total_vol = ttk.Entry(frm_plans, width=16, state="disabled")
        self.ent_total_vol.insert(0, "400000")
        self.ent_total_vol.grid(row=3, column=1, padx=5, pady=4)

        ttk.Label(frm_plans, text="Объем прошл. мес.:").grid(row=3, column=2, sticky="w", padx=(10, 0), pady=4)
        self.ent_prev_vol = ttk.Entry(frm_plans, width=16)
        self.ent_prev_vol.insert(0, "350000")
        self.ent_prev_vol.grid(row=3, column=3, padx=5, pady=4)

        # 3. Новые продажи и Доп. продукты
        frm_new = ttk.LabelFrame(left_col, text="3. Бонус за Новые продажи (по КП), CPC/CPA и Спецпроекты", style="Card.TLabelframe")
        frm_new.pack(fill="both", expand=True, pady=(0, 8))

        ttk.Label(
            frm_new,
            text="Укажите сумму месячного объема новых БЗ по коэффициенту предоплаты (КП):",
             foreground="#555555"
        ).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 6))

        self.new_sales_entries = {}
        rates_items = list(SochiBonusCalculator.NEW_SALES_RATES.items())
        for i, (label_text, rate) in enumerate(rates_items):
            r = (i // 2) + 1
            c = (i % 2) * 2
            ttk.Label(frm_new, text=f"{label_text}:").grid(row=r, column=c, sticky="w", pady=3, padx=(0 if c == 0 else 10, 2))
            ent = ttk.Entry(frm_new, width=14)
            ent.insert(0, "20000" if i == 2 else "0")  # По умолчанию пример в КП от 4 мес
            ent.grid(row=r, column=c + 1, sticky="w", pady=3, padx=3)
            self.new_sales_entries[label_text] = (ent, rate)

        ttk.Separator(frm_new, orient="horizontal").grid(row=4, column=0, columnspan=4, sticky="ew", pady=8)

        ttk.Label(frm_new, text="Оплаты CPC/CPA (₽):").grid(row=5, column=0, sticky="w", pady=3)
        self.ent_cpc = ttk.Entry(frm_new, width=14)
        self.ent_cpc.insert(0, "0")
        self.ent_cpc.grid(row=5, column=1, sticky="w", padx=3, pady=3)

        ttk.Label(frm_new, text="Спецпроекты 10% (₽):").grid(row=5, column=2, sticky="w", padx=(10, 2), pady=3)
        self.ent_spec = ttk.Entry(frm_new, width=14)
        self.ent_spec.insert(0, "0")
        self.ent_spec.grid(row=5, column=3, sticky="w", padx=3, pady=3)

        # Привязка пересчета при вводе в поля основного расчета
        for entry in [
            self.ent_salary, self.ent_qual, self.ent_plan_money, self.ent_fact_money,
            self.ent_plan_vol, self.ent_fact_vol, self.ent_total_vol, self.ent_prev_vol,
            self.ent_cpc, self.ent_spec
        ] + [item[0] for item in self.new_sales_entries.values()]:
            entry.bind("<KeyRelease>", self._on_main_entry_change)

        # ==================== ПРАВАЯ КОЛОНКА ====================
        right_col = ttk.Frame(main_frame)
        right_col.grid(row=0, column=1, sticky="nsew", padx=(8, 0))

        # БЛОК ОТДЕЛЬНОГО СИМУЛЯТОРА С ПОЛЗУНКАМИ
        frm_sim = ttk.LabelFrame(
            right_col,
            text="4. ОТДЕЛЬНЫЙ СИМУЛЯТОР: Прогноз премии ползунком («Что если?»)",
            style="Sim.TLabelframe"
        )
        frm_sim.pack(fill="x", pady=(0, 10))

        ttk.Label(
            frm_sim,
            text="Двигайте ползунок влево-вправо ИЛИ впишите любую сумму вручную.\n"
                 "Расчет идет отдельно от основного факта (на базе указанных План.Деньги и План.Объем).",
            style="Sim.TLabel",
            foreground="#334e68"
        ).pack(anchor="w", pady=(0, 8))

        # --- Ползунок 1: Симулятор План.Деньги ---
        sim_money_box = ttk.Frame(frm_sim)
        sim_money_box.pack(fill="x", pady=4)
        ttk.Label(sim_money_box, text="Симуляция ОПЛАТ (₽):", style="Sim.TLabel", font=("Segoe UI", 9, "bold")).grid(row=0, column=0, sticky="w")

        self.ent_sim_money = ttk.Entry(sim_money_box, width=16, font=("Segoe UI", 9, "bold"))
        self.ent_sim_money.insert(0, "550000")
        self.ent_sim_money.grid(row=0, column=1, padx=6)
        self.ent_sim_money.bind("<KeyRelease>", self._on_sim_money_entry)

        self.lbl_sim_money_pct = ttk.Label(sim_money_box, text="Выполнение: 110.0% (P1 = 1.10)", style="Sim.TLabel")
        self.lbl_sim_money_pct.grid(row=0, column=2, padx=6, sticky="w")

        self.scale_sim_money = tk.Scale(
            frm_sim, from_=0, to=1000000, orient="horizontal", resolution=1000,
            bg="#eef6ff", highlightthickness=0, troughcolor="#cce0ff",
            command=self._on_sim_money_slider
        )
        self.scale_sim_money.set(550000)
        self.scale_sim_money.pack(fill="x", pady=(0, 2))

        self.lbl_sim_money_res = ttk.Label(frm_sim, text="Бонус за деньги по ползунку: 8 772.50 ₽", style="SimResult.TLabel")
        self.lbl_sim_money_res.pack(anchor="w", pady=(0, 8))

        ttk.Separator(frm_sim, orient="horizontal").pack(fill="x", pady=4)

        # --- Ползунок 2: Симулятор План.Объем ---
        sim_vol_box = ttk.Frame(frm_sim)
        sim_vol_box.pack(fill="x", pady=4)
        ttk.Label(sim_vol_box, text="Симуляция ОБЪЕМА (₽):", style="Sim.TLabel", font=("Segoe UI", 9, "bold")).grid(row=0, column=0, sticky="w")

        self.ent_sim_vol = ttk.Entry(sim_vol_box, width=16, font=("Segoe UI", 9, "bold"))
        self.ent_sim_vol.insert(0, "400000")
        self.ent_sim_vol.grid(row=0, column=1, padx=6)
        self.ent_sim_vol.bind("<KeyRelease>", self._on_sim_vol_entry)

        self.lbl_sim_vol_pct = ttk.Label(sim_vol_box, text="Выполнение: 100.0% (P2 = 1.00)", style="Sim.TLabel")
        self.lbl_sim_vol_pct.grid(row=0, column=2, padx=6, sticky="w")

        self.scale_sim_vol = tk.Scale(
            frm_sim, from_=0, to=800000, orient="horizontal", resolution=1000,
            bg="#eef6ff", highlightthickness=0, troughcolor="#cce0ff",
            command=self._on_sim_vol_slider
        )
        self.scale_sim_vol.set(400000)
        self.scale_sim_vol.pack(fill="x", pady=(0, 2))

        self.lbl_sim_vol_res = ttk.Label(frm_sim, text="Бонус за объем: 6 000.00 ₽ | Прирост (20%): 10 000.00 ₽", style="SimResult.TLabel")
        self.lbl_sim_vol_res.pack(anchor="w", pady=(0, 6))

        self.lbl_sim_total = ttk.Label(
            frm_sim,
            text="ИТОГО по симулятору (Деньги + Объем + Прирост): 24 772.50 ₽",
            font=("Segoe UI", 11, "bold"), foreground="#0b6e4f", background="#eef6ff"
        )
        self.lbl_sim_total.pack(anchor="w", pady=(4, 0))

        # БЛОК 5: ДЕТАЛЬНЫЙ ИТОГ ОСНОВНОГО РАСЧЕТА
        frm_res = ttk.LabelFrame(right_col, text="5. Итоговый расчет премии и зарплаты (по основным полям)", style="Card.TLabelframe")
        frm_res.pack(fill="both", expand=True, pady=(0, 8))

        self.txt_report = tk.Text(frm_res, height=13, font=("Consolas", 9), bg="#fafbfc", relief="flat", padx=8, pady=8)
        self.txt_report.pack(fill="both", expand=True)

        btn_frame = ttk.Frame(frm_res)
        btn_frame.pack(fill="x", pady=(8, 0))
        ttk.Button(btn_frame, text="Пересчитать всё", style="Accent.TButton", command=self.calculate_main).pack(side="left", padx=(0, 8))
        ttk.Button(btn_frame, text="Сохранить расчет в файл (.txt)", command=self.save_report).pack(side="left")

    def _toggle_total_vol(self):
        if self.var_same_vol.get():
            self.ent_total_vol.configure(state="normal")
            self.ent_total_vol.delete(0, tk.END)
            self.ent_total_vol.insert(0, self.ent_fact_vol.get())
            self.ent_total_vol.configure(state="disabled")
        else:
            self.ent_total_vol.configure(state="normal")
        self.calculate_main()

    def _on_main_entry_change(self, event=None):
        if self.var_same_vol.get():
            self.ent_total_vol.configure(state="normal")
            self.ent_total_vol.delete(0, tk.END)
            self.ent_total_vol.insert(0, self.ent_fact_vol.get())
            self.ent_total_vol.configure(state="disabled")
        self.update_simulator_ranges()
        self.calculate_main()

    # =====================================================================
    # ЛОГИКА СИМУЛЯТОРА (ДВУСТОРОННЯЯ СВЯЗЬ ПОЛЗУНОК <-> ПОЛЕ ВВОДА)
    # =====================================================================
    def update_simulator_ranges(self):
        plan_m = max(self._parse_float(self.ent_plan_money.get()), 100_000.0)
        plan_v = max(self._parse_float(self.ent_plan_vol.get()), 100_000.0)

        max_m = int(plan_m * 2.0)
        max_v = int(plan_v * 2.0)

        self.scale_sim_money.configure(to=max_m)
        self.scale_sim_vol.configure(to=max_v)
        self._recalc_simulator()

    def _on_sim_money_slider(self, val):
        if self._updating_sim_money:
            return
        self._updating_sim_money = True
        amount = float(val)
        self.ent_sim_money.delete(0, tk.END)
        self.ent_sim_money.insert(0, f"{int(amount)}")
        self._recalc_simulator()
        self._updating_sim_money = False

    def _on_sim_money_entry(self, event=None):
        if self._updating_sim_money:
            return
        self._updating_sim_money = True
        amount = self._parse_float(self.ent_sim_money.get())
        if amount > self.scale_sim_money.cget("to"):
            self.scale_sim_money.configure(to=int(amount * 1.2))
        self.scale_sim_money.set(amount)
        self._recalc_simulator()
        self._updating_sim_money = False

    def _on_sim_vol_slider(self, val):
        if self._updating_sim_vol:
            return
        self._updating_sim_vol = True
        amount = float(val)
        self.ent_sim_vol.delete(0, tk.END)
        self.ent_sim_vol.insert(0, f"{int(amount)}")
        self._recalc_simulator()
        self._updating_sim_vol = False

    def _on_sim_vol_entry(self, event=None):
        if self._updating_sim_vol:
            return
        self._updating_sim_vol = True
        amount = self._parse_float(self.ent_sim_vol.get())
        if amount > self.scale_sim_vol.cget("to"):
            self.scale_sim_vol.configure(to=int(amount * 1.2))
        self.scale_sim_vol.set(amount)
        self._recalc_simulator()
        self._updating_sim_vol = False

    def _recalc_simulator(self):
        plan_m = self._parse_float(self.ent_plan_money.get())
        sim_m = self._parse_float(self.ent_sim_money.get())
        res_m = SochiBonusCalculator.calc_payments_bonus(plan_m, sim_m)

        status_m = " (< 90% — не начисляется)" if (0 < res_m["pct"] < 90) else (" (Макс. порог 1.5)" if res_m["pct"] >= 150 else "")
        self.lbl_sim_money_pct.configure(text=f"Выполнение: {res_m['pct']:.1f}% | P1 = {res_m['p1']:.2f}{status_m}")
        self.lbl_sim_money_res.configure(text=f"Бонус за деньги по ползунку: {self._fmt_money(res_m['bonus'])}")

        plan_v = self._parse_float(self.ent_plan_vol.get())
        prev_v = self._parse_float(self.ent_prev_vol.get())
        sim_v = self._parse_float(self.ent_sim_vol.get())

        res_v = SochiBonusCalculator.calc_volume_bonus(plan_v, sim_v, sim_v)
        res_gr = SochiBonusCalculator.calc_growth_bonus(sim_v, prev_v)

        status_v = " (< 90% — 0 ₽)" if (0 < res_v["pct"] < 90) else (" (Макс. порог 1.5)" if res_v["pct"] >= 150 else "")
        self.lbl_sim_vol_pct.configure(text=f"Выполнение: {res_v['pct']:.1f}% | P2 = {res_v['p2']:.2f}{status_v}")
        self.lbl_sim_vol_res.configure(
            text=f"Бонус за объем: {self._fmt_money(res_v['bonus'])}  |  Бонус за прирост (20%): {self._fmt_money(res_gr['bonus'])}"
        )

        sim_total = res_m["bonus"] + res_v["bonus"] + res_gr["bonus"]
        self.lbl_sim_total.configure(text=f"ИТОГО по симулятору (Деньги + Объем + Прирост): {self._fmt_money(sim_total)}")

    # =====================================================================
    # ОСНОВНОЙ РАСЧЕТ И ФОРМИРОВАНИЕ ОТЧЕТА
    # =====================================================================
    def calculate_main(self):
        mode = self.var_mode.get()
        salary = self._parse_float(self.ent_salary.get())
        qual = self._parse_float(self.ent_qual.get())

        plan_money = self._parse_float(self.ent_plan_money.get())
        fact_money = self._parse_float(self.ent_fact_money.get())

        plan_vol = self._parse_float(self.ent_plan_vol.get())
        fact_vol = self._parse_float(self.ent_fact_vol.get())
        total_vol = fact_vol if self.var_same_vol.get() else self._parse_float(self.ent_total_vol.get())
        prev_vol = self._parse_float(self.ent_prev_vol.get())

        # 1. Бонус от оплат
        res_pay = SochiBonusCalculator.calc_payments_bonus(plan_money, fact_money)
        bonus_pay = res_pay["bonus"]

        # 2. Бонус от объема, прироста и новых продаж (со 2-го месяца)
        if mode == "standard":
            res_vol = SochiBonusCalculator.calc_volume_bonus(plan_vol, fact_vol, total_vol)
            bonus_vol = res_vol["bonus"]

            res_growth = SochiBonusCalculator.calc_growth_bonus(total_vol, prev_vol)
            bonus_growth = res_growth["bonus"]

            bonus_new = 0.0
            for _, (ent, rate) in self.new_sales_entries.items():
                bonus_new += self._parse_float(ent.get()) * rate
        else:
            res_vol = {"pct": 0.0, "p2": 0.0, "bonus": 0.0}
            res_growth = {"diff": 0.0, "bonus": 0.0}
            bonus_vol = 0.0
            bonus_growth = 0.0
            bonus_new = 0.0

        # 3. Бонус стажера за порог оплат (50 000 руб.)
        if mode == "intern":
            res_intern = SochiBonusCalculator.calc_intern_threshold_bonus(fact_money)
            bonus_intern = res_intern["bonus"]
        else:
            res_intern = {"r_po": 0.0, "bonus": 0.0}
            bonus_intern = 0.0

        # 4. CPC/CPA и Спецпроекты
        cpc_pay = self._parse_float(self.ent_cpc.get())
        bonus_cpc = SochiBonusCalculator.calc_cpc_bonus(cpc_pay)

        spec_pay = self._parse_float(self.ent_spec.get())
        bonus_spec = spec_pay * SochiBonusCalculator.SPEC_PROJECT_RATE

        # Итоги
        total_bonus = bonus_pay + bonus_vol + bonus_growth + bonus_new + bonus_intern + bonus_cpc + bonus_spec
        gross_total = salary + qual + total_bonus
        ndfl = gross_total * 0.13
        net_total = gross_total - ndfl

        # Формирование текста отчета
        lines = [
            f"=== ДЕТАЛЬНЫЙ РАСЧЕТ ПРЕМИИ МПП (г. СОЧИ) ===",
            f"План.Деньги: {self._fmt_money(plan_money)} | Факт: {self._fmt_money(fact_money)} ({res_pay['pct']:.1f}%, P1={res_pay['p1']:.2f})",
            f" -> 1. Бонус от оплат (1.45%):         {self._fmt_money(bonus_pay)}",
        ]

        if mode == "standard":
            lines.extend([
                f"План. Объем: {self._fmt_money(plan_vol)} | Факт: {self._fmt_money(fact_vol)} ({res_vol['pct']:.1f}%, P2={res_vol['p2']:.2f})",
                f" -> 2. Бонус от общего объема (1.50%): {self._fmt_money(bonus_vol)}",
                f" -> 3. Бонус за прирост ({self._fmt_money(res_growth['diff'])}, 20%): {self._fmt_money(bonus_growth)}",
                f" -> 4. Бонус за новые продажи (по КП): {self._fmt_money(bonus_new)}",
            ])
        elif mode == "first_month":
            lines.append(" * Режим 1-го месяца: объем, прирост и новые продажи начисляются со 2-го мес.")
        elif mode == "intern":
            lines.append(
                f" -> Порог оплат стажера ({res_intern['r_po']*100:.1f}% от 50 000 ₽): {self._fmt_money(bonus_intern)}"
            )

        if bonus_cpc > 0 or bonus_spec > 0:
            lines.append(f" -> 5. Бонус CPC/CPA и Спецпроекты:    {self._fmt_money(bonus_cpc + bonus_spec)}")

        lines.extend([
            "-" * 58,
            f"ИТОГО БОНУС (до НДФЛ):                 {self._fmt_money(total_bonus)}",
            f"Оклад + Квал. надбавка:                {self._fmt_money(salary + qual)}",
            f"ВСЕГО НАЧИСЛЕНО (до НДФЛ):             {self._fmt_money(gross_total)}",
            f"НА РУКИ (за вычетом НДФЛ 13%):         {self._fmt_money(net_total)}",
        ])

        self.txt_report.delete("1.0", tk.END)
        self.txt_report.insert(tk.END, "\n".join(lines))

    def save_report(self):
        file_path = filedialog.asksaveasfilename(
            defaultextension=".txt",
            filetypes=[("Текстовый файл", "*.txt")],
            initialfile=f"Расчет_премии_Сочи_{datetime.now().strftime('%Y-%m-%d')}.txt"
        )
        if file_path:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(self.txt_report.get("1.0", tk.END))
            messagebox.showinfo("Сохранено", f"Отчет успешно сохранен:\n{file_path}")


if __name__ == "__main__":
    run_self_tests()
    app = BonusApp()
    app.mainloop()